Compare commits
No files matched your search
@@ -4,3 +4,4 @@ Cargo.lock.bak
|
|||||||
www/dungeoncraft.wasm
|
www/dungeoncraft.wasm
|
||||||
www/mq_js_bundle.js
|
www/mq_js_bundle.js
|
||||||
play/
|
play/
|
||||||
|
dist/
|
||||||
@@ -14,6 +14,28 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d"
|
||||||
|
dependencies = [
|
||||||
|
"alsa-sys",
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa-sys"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -26,6 +48,27 @@ 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 = "block2"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 +81,12 @@ 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 = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -50,6 +99,16 @@ 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 = "combine"
|
||||||
|
version = "4.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core_maths"
|
name = "core_maths"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -59,6 +118,50 @@ dependencies = [
|
|||||||
"libm",
|
"libm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coreaudio-rs"
|
||||||
|
version = "0.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"libc",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpal"
|
||||||
|
version = "0.18.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f02e8d0327b42d3e2e4ab2119af397344eb9fc54a34bf0ddeaa1277af8681f1"
|
||||||
|
dependencies = [
|
||||||
|
"alsa",
|
||||||
|
"block2",
|
||||||
|
"coreaudio-rs",
|
||||||
|
"dasp_sample",
|
||||||
|
"jni",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"mach2",
|
||||||
|
"ndk",
|
||||||
|
"ndk-context",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"objc2",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-avf-audio",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"web-sys",
|
||||||
|
"windows",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.4"
|
version = "1.5.4"
|
||||||
@@ -68,11 +171,31 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_sample"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dispatch2"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"block2",
|
||||||
|
"libc",
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dungeoncraft"
|
name = "dungeoncraft"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"cpal",
|
||||||
"macroquad",
|
"macroquad",
|
||||||
|
"rfd",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
@@ -115,10 +238,34 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e796cb6894e6418daacea84b28dd4f98d3b32c74d5bb452ba1ecfbf5ad8e84ca"
|
checksum = "e796cb6894e6418daacea84b28dd4f98d3b32c74d5bb452ba1ecfbf5ad8e84ca"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown",
|
"hashbrown 0.15.5",
|
||||||
"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]]
|
[[package]]
|
||||||
name = "glam"
|
name = "glam"
|
||||||
version = "0.27.0"
|
version = "0.27.0"
|
||||||
@@ -136,6 +283,12 @@ dependencies = [
|
|||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "image"
|
name = "image"
|
||||||
version = "0.24.9"
|
version = "0.24.9"
|
||||||
@@ -149,12 +302,91 @@ dependencies = [
|
|||||||
"png",
|
"png",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
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 = "jni"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-macros",
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
"log",
|
||||||
|
"simd_cesu8",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"walkdir",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-macros"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"simd_cesu8",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys-macros"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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"
|
||||||
@@ -167,6 +399,18 @@ version = "0.2.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "macroquad"
|
name = "macroquad"
|
||||||
version = "0.4.16"
|
version = "0.4.16"
|
||||||
@@ -209,7 +453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "dcbaea978d741f3b60ced666b06a2a1e4a2bc81cb8b9dd29f4a40965e1f59e65"
|
checksum = "dcbaea978d741f3b60ced666b06a2a1e4a2bc81cb8b9dd29f4a40965e1f59e65"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"ndk-sys",
|
"ndk-sys 0.2.2",
|
||||||
"objc-rs",
|
"objc-rs",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
@@ -234,12 +478,52 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndk"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"jni-sys 0.3.1",
|
||||||
|
"log",
|
||||||
|
"ndk-sys 0.6.0+11769913",
|
||||||
|
"num_enum",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndk-context"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk-sys"
|
name = "ndk-sys"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e1bcdd74c20ad5d95aacd60ef9ba40fdf77f767051040541df557b7a9b2a2121"
|
checksum = "e1bcdd74c20ad5d95aacd60ef9ba40fdf77f767051040541df557b7a9b2a2121"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndk-sys"
|
||||||
|
version = "0.6.0+11769913"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys 0.3.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -249,6 +533,28 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num_enum"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
|
||||||
|
dependencies = [
|
||||||
|
"num_enum_derive",
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num_enum_derive"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc-rs"
|
name = "objc-rs"
|
||||||
version = "0.2.8"
|
version = "0.2.8"
|
||||||
@@ -258,19 +564,154 @@ dependencies = [
|
|||||||
"malloc_buf",
|
"malloc_buf",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
|
||||||
|
dependencies = [
|
||||||
|
"objc2-encode",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-app-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"block2",
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-audio-toolbox"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"libc",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-avf-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||||
|
dependencies = [
|
||||||
|
"dispatch2",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-foundation"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"block2",
|
||||||
|
"dispatch2",
|
||||||
|
"libc",
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-encode"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-foundation"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"block2",
|
||||||
|
"libc",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||||
|
|
||||||
[[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 = "pollster"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro-crate"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||||
|
dependencies = [
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
@@ -295,6 +736,66 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "raw-window-handle"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.17.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
|
||||||
|
dependencies = [
|
||||||
|
"block2",
|
||||||
|
"dispatch2",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"percent-encoding",
|
||||||
|
"pollster",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "same-file"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.229"
|
version = "1.0.229"
|
||||||
@@ -322,7 +823,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -344,6 +845,39 @@ 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 = "simd_cesu8"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||||
|
dependencies = [
|
||||||
|
"rustc_version",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simdutf8"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
|
[[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 +889,85 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio"
|
||||||
|
version = "1.54.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "1.1.3+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.25.19+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd43688c4858d7264ae1cf9a9f874a67c3feb74a883b1cd7bebd6a4ea8307218"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.1.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||||
|
dependencies = [
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ttf-parser"
|
name = "ttf-parser"
|
||||||
version = "0.25.1"
|
version = "0.25.1"
|
||||||
@@ -370,6 +983,81 @@ 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 = "walkdir"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||||
|
dependencies = [
|
||||||
|
"same-file",
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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-futures"
|
||||||
|
version = "0.4.80"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"tokio",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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-sys"
|
||||||
|
version = "0.3.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -386,12 +1074,149 @@ 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 = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-util"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
version = "0.4.0"
|
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 = "windows"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||||
|
dependencies = [
|
||||||
|
"windows-collections",
|
||||||
|
"windows-core",
|
||||||
|
"windows-future",
|
||||||
|
"windows-numerics",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-collections"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-future"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
"windows-threading",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-numerics"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-threading"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
|
|||||||
@@ -17,3 +17,8 @@ codegen-units = 1
|
|||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1
|
opt-level = 1
|
||||||
|
|
||||||
|
# desktop builds: system file dialog (xdg portal on Linux) and sound output
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
|
||||||
|
cpal = "0.18"
|
||||||
@@ -31,4 +31,6 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|||||||
COPY www/ /usr/share/nginx/html/
|
COPY www/ /usr/share/nginx/html/
|
||||||
COPY --from=build /out/ /usr/share/nginx/html/
|
COPY --from=build /out/ /usr/share/nginx/html/
|
||||||
COPY --from=standalone /out/standalone/ /usr/share/nginx/html/standalone/
|
COPY --from=standalone /out/standalone/ /usr/share/nginx/html/standalone/
|
||||||
|
# packed once for gzip_static (nginx.conf)
|
||||||
|
RUN cd /usr/share/nginx/html && gzip -k -9 dungeoncraft.wasm mq_js_bundle.js standalone/DungeonCraft.html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
@@ -37,6 +37,33 @@ cp ~/.cargo/registry/src/*/macroquad-*/js/mq_js_bundle.js www/
|
|||||||
python3 -m http.server 8080 -d www # open http://localhost:8080
|
python3 -m http.server 8080 -d www # open http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Desktop build
|
||||||
|
|
||||||
|
The game also runs as a native program (Linux, Windows, macOS), without server and multiplayer:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release
|
||||||
|
./target/release/dungeoncraft
|
||||||
|
```
|
||||||
|
|
||||||
|
On Linux the build needs the ALSA headers (`alsa-lib` / `libasound2-dev`); the sound goes through ALSA (PipeWire and
|
||||||
|
PulseAudio take it). The settings, the autosave, the campaign progress and the profile are kept as files in the
|
||||||
|
data folder: `~/.local/share/dungeoncraft/` (Linux, or `$XDG_DATA_HOME/dungeoncraft`), `%APPDATA%\DungeonCraft`
|
||||||
|
(Windows), `~/Library/Application Support/DungeonCraft` (macOS); `DUNGEONCRAFT_DATA` chooses another one.
|
||||||
|
*Save Game* and saved campaigns land in its `saves/` folder; *Load Game* opens the system file dialog there (on
|
||||||
|
Linux through the desktop portal), and dropping a file onto the window works as well.
|
||||||
|
|
||||||
|
Release packages are built by `python3 tools/build_desktop.py [linux] [windows] [macos]` into `dist/`:
|
||||||
|
|
||||||
|
- **Linux**: `DungeonCraft-<version>-x86_64.AppImage`, built against glibc 2.28 (distributions from about 2019 on).
|
||||||
|
- **Windows**: `DungeonCraft-<version>-windows-x64.exe`, a single file for Windows 10 and newer, cross-compiled on Linux.
|
||||||
|
- **macOS**: `DungeonCraft-<version>-macos.zip` with a universal `DungeonCraft.app` (Apple silicon and Intel); only on a
|
||||||
|
Mac. It is signed ad hoc, not notarised: on first start open it with right click -> *Open*.
|
||||||
|
|
||||||
|
Linux and Windows need [zig](https://ziglang.org/) (`mise install zig@0.15` works) and
|
||||||
|
`cargo install --locked cargo-zigbuild`, plus `rustup target add x86_64-pc-windows-gnu`; the AppImage tool is downloaded
|
||||||
|
once into `~/.cache/dungeoncraft/`.
|
||||||
|
|
||||||
### Testing the game logic
|
### Testing the game logic
|
||||||
|
|
||||||
The tests run natively: a headless simulation of whole games, a savegame round trip, rendering of all music modules and the audio engine:
|
The tests run natively: a headless simulation of whole games, a savegame round trip, rendering of all music modules and the audio engine:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ An evil little dungeon game for the browser - in glorious pixels, in 3D, for fre
|
|||||||
<img src="https://img.shields.io/badge/made%20with-Claude%20Code-d97757?logo=anthropic&logoColor=white" alt="Made with Claude Code">
|
<img src="https://img.shields.io/badge/made%20with-Claude%20Code-d97757?logo=anthropic&logoColor=white" alt="Made with Claude Code">
|
||||||
<img src="https://img.shields.io/badge/made%20with-Qwen%20%2B%20OpenCode-7b6cf6" alt="Made with qwen3.8-flash-next-iq3xxs and Strata in OpenCode">
|
<img src="https://img.shields.io/badge/made%20with-Qwen%20%2B%20OpenCode-7b6cf6" alt="Made with qwen3.8-flash-next-iq3xxs and Strata in OpenCode">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-2ea44f" alt="MIT License">
|
<img src="https://img.shields.io/badge/license-MIT-2ea44f" alt="MIT License">
|
||||||
<img src="https://img.shields.io/badge/languages-EN%20%7C%20DE-555" alt="English and German">
|
<img src="https://img.shields.io/badge/languages-18-555" alt="18 languages">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -72,7 +72,7 @@ to *step through shadows*, the chainbreaker to spin like a *whirlwind*.
|
|||||||
frost glows - and doors of wood, iron and magic. Lock them, and nobody gets through. Not even the giant. Well. Not for a while.
|
frost glows - and doors of wood, iron and magic. Lock them, and nobody gets through. Not even the giant. Well. Not for a while.
|
||||||
|
|
||||||
**✨ Cast nasty spells.** Lightning, healing, seer's eye, cave-in, haste, stoneskin, gold rain, invisibility,
|
**✨ Cast nasty spells.** Lightning, healing, seer's eye, cave-in, haste, stoneskin, gold rain, invisibility,
|
||||||
plague and quake. Your scholars **research** them in the library - one discovery after another.
|
plague, quake and frost. Your scholars **research** them in the library - one discovery after another.
|
||||||
|
|
||||||
**🌫 Explore the dark.** Everything beyond your walls is black until your creatures go there.
|
**🌫 Explore the dark.** Everything beyond your walls is black until your creatures go there.
|
||||||
Somewhere out there: a cave of neutral **dwarves** guarding gold and gems - and sometimes a chest with the plans
|
Somewhere out there: a cave of neutral **dwarves** guarding gold and gems - and sometimes a chest with the plans
|
||||||
@@ -84,10 +84,28 @@ of a dwarven **rune forge tower**. Islands in lava rivers. Mana springs. Creatur
|
|||||||
<br><i>Left: the forge and its little coglings. Right: prison, graveyard, arena, guard posts and the altar. Cosy.</i>
|
<br><i>Left: the forge and its little coglings. Right: prison, graveyard, arena, guard posts and the altar. Cosy.</i>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
**🌳 Up into the daylight.** Some maps reach the surface: grass, birds, sunshine - disgusting - and forests your
|
||||||
|
imps fell for **wood**, the second resource (down below only the odd giant mushroom gives some). Wood builds bridges
|
||||||
|
and wooden doors. Raise walls again where you dug too much, burn the knights' wooden bridges, pull up your
|
||||||
|
**drawbridges** when they come, freeze lava into obsidian to cross it - and roll **battering rams and catapults** out
|
||||||
|
of your forge to break a fortress in the forest.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/surface.png" width="400" alt="The cliff between the dungeon and a sunny forest above ground">
|
||||||
|
<img src="docs/screenshots/bridges.png" width="400" alt="A raised drawbridge, a lowered one, and obsidian on a lava river">
|
||||||
|
<br><i>Left: the world up there. Too bright. Right: a drawbridge up, a drawbridge down, and lava that thinks it is a floor.</i>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**🏆 Champions and trophies.** Every fifth wave a champion of the Order leads the knights - Sir Aldric, Dame
|
||||||
|
Seraphine, Brother Halvard or Captain Rothgar, each with a trick of his own and loot for whoever brings him down.
|
||||||
|
After every game a chart shows how it went, and 64 achievements wait - some of them secret, all of them petty.
|
||||||
|
|
||||||
**👑 Other masters want your realm, too.** Mossgrim the Mouldy (green, smells like a wet cellar) and Lady Nightwell
|
**👑 Other masters want your realm, too.** Mossgrim the Mouldy (green, smells like a wet cellar) and Lady Nightwell
|
||||||
(blue, writes threatening letters on scented paper) dig their own dungeons in free play and in the campaign.
|
(blue, writes threatening letters on scented paper) dig their own dungeons in free play and in the campaign.
|
||||||
And the humans attack *everybody*. Let them bite each other first.
|
And the humans attack *everybody*. Let them bite each other first.
|
||||||
|
|
||||||
|
**🎓 New down here?** The *Tutorial* in the title menu: Crookfinger shows you the ropes, one step at a time.
|
||||||
|
|
||||||
**📜 A campaign of thirty missions.** Take back the shards of your soul core - from the first little outpost by the
|
**📜 A campaign of thirty missions.** Take back the shards of your soul core - from the first little outpost by the
|
||||||
border brook to the citadel of Grand Master Valerian Brightshield. Rivers, fortresses, treasure islands, sealed
|
border brook to the citadel of Grand Master Valerian Brightshield. Rivers, fortresses, treasure islands, sealed
|
||||||
caves, rival masters, and me, telling you the story. With tea.
|
caves, rival masters, and me, telling you the story. With tea.
|
||||||
@@ -128,6 +146,20 @@ caves, rival masters, and me, telling you the story. With tea.
|
|||||||
campaigns. *Open the demo* in the editor shows every trigger at work. Details: [docs/CAMPAIGN_EDITOR.md](docs/CAMPAIGN_EDITOR.md),
|
campaigns. *Open the demo* in the editor shows every trigger at work. Details: [docs/CAMPAIGN_EDITOR.md](docs/CAMPAIGN_EDITOR.md),
|
||||||
[docs/MISSION_SCRIPTING.md](docs/MISSION_SCRIPTING.md).
|
[docs/MISSION_SCRIPTING.md](docs/MISSION_SCRIPTING.md).
|
||||||
|
|
||||||
|
## Together
|
||||||
|
|
||||||
|
- **Versus** for 2-4 masters, with teams, gold for allies and fallen masters watching the rest of the match.
|
||||||
|
- **Co-op**: the 20-mission campaign *League against the Lightwatch*, the built-in missions or any editor campaign,
|
||||||
|
each master with an own dungeon; empty seats are played by the AI. Hero waves march on the strongest master.
|
||||||
|
- **Offline with an AI partner**: every co-op mission also runs without the server - the partner builds his own
|
||||||
|
dungeon, follows your attack flag and helps out with gold. Interrupted runs go on with *Continue*.
|
||||||
|
Details: [docs/MULTIPLAYER.md](docs/MULTIPLAYER.md).
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/coop-league.png" width="400" alt="The Match page with the co-op league and the AI partner">
|
||||||
|
<img src="docs/screenshots/languages.png" width="400" alt="The language dropdown with pixel flags for 18 languages">
|
||||||
|
</p>
|
||||||
|
|
||||||
## Meet the household
|
## Meet the household
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -176,9 +208,13 @@ On phones: tap to use a tool, drag to mark or move, two fingers to zoom and turn
|
|||||||
- **Rust**, compiled to **WebAssembly**, with [macroquad](https://github.com/not-fl3/macroquad) - one small module, no engine, no plugins.
|
- **Rust**, compiled to **WebAssembly**, with [macroquad](https://github.com/not-fl3/macroquad) - one small module, no engine, no plugins.
|
||||||
- A real 3D isometric world in pixel art: chunked meshes, flickering point lights, a glow pass, particles,
|
- A real 3D isometric world in pixel art: chunked meshes, flickering point lights, a glow pass, particles,
|
||||||
billboard sprites with eight viewing directions, every frame of it drawn by code - **no image files** except the font.
|
billboard sprites with eight viewing directions, every frame of it drawn by code - **no image files** except the font.
|
||||||
- Procedural chiptune soundtrack as FastTracker 2 modules with its own XM replayer and synthesised sound effects.
|
- Procedural chiptune soundtrack as FastTracker 2 modules with its own XM replayer and synthesised sound effects:
|
||||||
|
47 pieces around one leitmotif - in the dungeon dark and minor, above ground bright, with flute, harp, birdsong
|
||||||
|
and wind; the music follows the camera between the two worlds.
|
||||||
- A tiny Rust savegame server (axum, Argon2 passwords) - optional; without it the game uses savegame files.
|
- A tiny Rust savegame server (axum, Argon2 passwords) - optional; without it the game uses savegame files.
|
||||||
- English and German, more languages are one text file away.
|
- 18 languages: German, English, Spanish, Portuguese, Dutch, Danish, Norwegian, Swedish, Finnish,
|
||||||
|
Russian, Croatian, Hungarian, Serbian, Romanian, Slovak, Czech, Italian and Greek – chosen from a
|
||||||
|
dropdown with pixel flags; another language is one text file away.
|
||||||
|
|
||||||
## Made with Claude Code
|
## Made with Claude Code
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Balance runs
|
||||||
|
|
||||||
|
Whole games played headless by the AI brain on every side (`src/balance.rs`), deterministic per seed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --release balance_ -- --ignored --nocapture --test-threads=3
|
||||||
|
BALANCE_SEEDS=16 cargo test --release balance_difficulty -- --ignored --nocapture # more seeds
|
||||||
|
BALANCE_PAIR=0,1 ... # one difficulty pairing only (0 easy, 1 medium, 2 hard)
|
||||||
|
BALANCE_TRACE=1 ... # wave runs: a status line every two minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
- **balance_difficulty** – duels of two AI masters (112x112, no hero waves, 45 min); the sides swap
|
||||||
|
every other seed. Undecided games count as draws.
|
||||||
|
- **balance_talents** – four AI masters (medium), each held to one talent branch, the branches moving
|
||||||
|
round the four starts; placement by survival and strength.
|
||||||
|
- **balance_waves** – one AI master alone against the hero waves (96x96, 40 min).
|
||||||
|
|
||||||
|
## Findings and changes (2026-11-06)
|
||||||
|
|
||||||
|
First runs showed:
|
||||||
|
- AI masters stopped growing after their build order (99 room tiles, 24 creatures from minute 10).
|
||||||
|
- They mined one vein at a time and only below 1500 gold.
|
||||||
|
- They fought hero waves piecemeal and never cast spells.
|
||||||
|
- "Hard" attacked even when weaker (boldness 0.9) and was no stronger than "easy".
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- AI masters keep building lairs and gardens up to 24 / 50 / 80 beds (easy / medium / hard), and a
|
||||||
|
bigger treasury when the old one is nearly full.
|
||||||
|
- They mine several veins (one per three imps) whenever gold is below 1.5 paydays + 1000.
|
||||||
|
- Defence: with intruders in the dungeon the rally flag goes on them, the master casts lightning on
|
||||||
|
them and heals his hurt creatures; an attack is called off to defend.
|
||||||
|
- Boldness (own army / enemy army needed to attack): easy 1.0, medium 1.2, hard 1.35.
|
||||||
|
- Builder talent: imps dig +15% and rooms cost -10% per level (was +10% / -6%).
|
||||||
|
|
||||||
|
Results after the changes:
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| one master vs waves: fallen of 8 (easy/medium/hard) | 2 / 3 / 4 | 2 / 2 / 2 |
|
||||||
|
| duels, 16 seeds: easy vs medium | 6 : 6 | 3 : 6 |
|
||||||
|
| medium vs hard | 5 : 6 (8 seeds 3 : 2) | 5 : 6 |
|
||||||
|
| easy vs hard | 3 : 7 | 3 : 7 |
|
||||||
|
| talents, average place (builder / warlord / warlock / merchant) | 3.3 / 1.9 / 3.0 / 1.9 | 3.1 / 2.1 / 2.5 / 2.3 |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The noise is about ±2 wins at 16 seeds; medium vs medium ended 5 : 7.
|
||||||
|
- The AI uses only lightning and heal, so Warlock is still worth more to a human player than the
|
||||||
|
tournament shows.
|
||||||
|
- Builder pays off where space, not gold, limits building; AI dungeons are bounded by their radius.
|
||||||
|
- One map seed (1333) never brings heroes to the master. It shows as "wave 11, heroes 0" in the
|
||||||
|
wave table and is a map outlier.
|
||||||
@@ -33,6 +33,7 @@ the file format in `src/campaign_doc.rs`, the scripts in `src/blocks.rs` and `sr
|
|||||||
|
|
||||||
- a `.dccamp` file (or drop the file onto the window),
|
- a `.dccamp` file (or drop the file onto the window),
|
||||||
- **Open the demo** – the demo campaign, which uses every trigger kind and every special script,
|
- **Open the demo** – the demo campaign, which uses every trigger kind and every special script,
|
||||||
|
- **League against the Lightwatch** – the built-in 20-mission co-op campaign, as a copy to change or learn from,
|
||||||
- **a mission of the built-in campaign as a copy** – its map as it starts, its texts and goals,
|
- **a mission of the built-in campaign as a copy** – its map as it starts, its texts and goals,
|
||||||
added to the current campaign (the original stays untouched),
|
added to the current campaign (the original stays untouched),
|
||||||
- **my campaigns (online)** and **published campaigns** of all players (needs a login on a server
|
- **my campaigns (online)** and **published campaigns** of all players (needs a login on a server
|
||||||
@@ -94,7 +95,7 @@ The map in the game's 3D view; tool bar on the left, options on top.
|
|||||||
| Towers | arrow, cannon, magic, rune forge tower; the dungeon's or the knights', level 1–3 |
|
| Towers | arrow, cannon, magic, rune forge tower; the dungeon's or the knights', level 1–3 |
|
||||||
| Traps | the seven traps and three doors, armed |
|
| Traps | the seven traps and three doors, armed |
|
||||||
| Forts | a knight fortress (with or without a shard), rival masters (Mossgrim, Lady Nightwell, rival 3); level = strength |
|
| Forts | a knight fortress (with or without a shard), rival masters (Mossgrim, Lady Nightwell, rival 3); level = strength |
|
||||||
| Zones | trigger zones: rectangle, circle (drag from the middle), painted tiles (paint onto the selected tiles zone), line (drag from start to end); name, rename, delete |
|
| Zones | trigger zones: rectangle, circle (drag from the middle), painted tiles (paint onto the selected tiles zone; *Remove tiles* takes them out again), line (drag from start to end); name, rename, delete (the "x"); the list scrolls with the wheel |
|
||||||
| Erase | removes units, towers, traps and doors on a tile |
|
| Erase | removes units, towers, traps and doors on a tile |
|
||||||
|
|
||||||
Options: brush size 1–5, rectangle, fill (connected area of the same ground), mirror (left/right,
|
Options: brush size 1–5, rectangle, fill (connected area of the same ground), mirror (left/right,
|
||||||
@@ -117,8 +118,12 @@ Ctrl+Z/Y undo/redo, Ctrl+C/V copy/paste the selected block, Delete removes it, C
|
|||||||
save; *Published* adds the campaign to the list every logged-in player sees and can open (as a
|
save; *Published* adds the campaign to the list every logged-in player sees and can open (as a
|
||||||
copy) and play. Server API: `/api/campaigns`, `/api/public/campaigns` (see `server/src/main.rs`).
|
copy) and play. Server API: `/api/campaigns`, `/api/public/campaigns` (see `server/src/main.rs`).
|
||||||
- **Co-op**: the campaign last opened in the editor can be played in a co-op match (Multiplayer,
|
- **Co-op**: the campaign last opened in the editor can be played in a co-op match (Multiplayer,
|
||||||
*Editor campaign*), one mission per match; missions need a map of at least 72x72 and a goal besides
|
*Editor campaign*); missions need a map of at least 72x72 and a goal besides rival masters. The run
|
||||||
rival masters. See `docs/MULTIPLAYER.md`.
|
goes on over several missions: after a win the host starts the next mission (the branch of the reached
|
||||||
|
goal, or *next mission*) for everybody, after a defeat the same one again. Every player takes his five
|
||||||
|
strongest creatures and a fifth of his gold along; the host keeps the progress (missions done, chosen
|
||||||
|
branches, run seed). The room list shows the campaign's name and the current mission. See
|
||||||
|
`docs/MULTIPLAYER.md`.
|
||||||
|
|
||||||
## File format (`src/campaign_doc.rs`)
|
## File format (`src/campaign_doc.rs`)
|
||||||
|
|
||||||
|
|||||||
@@ -58,10 +58,12 @@ creatures and heroes.
|
|||||||
| Units | Spawn | count, kind, level, side, where (the core, the trigger unit, a zone), optional name |
|
| Units | Spawn | count, kind, level, side, where (the core, the trigger unit, a zone), optional name |
|
||||||
| | Hero wave | count (sent at once) |
|
| | Hero wave | count (sent at once) |
|
||||||
| | Remove | side, zone (removes those units) |
|
| | Remove | side, zone (removes those units) |
|
||||||
| World | Change ground | zone → floor, earth, rock, water, lava, gold (the core, portals, towers and the gate stay) |
|
| World | Change ground | zone → floor, earth, rock, water, lava, gold, mushroom grove (the core, portals, towers and the gate stay) |
|
||||||
| | Reveal | zone (lifts the fog of war) |
|
| | Reveal | zone (lifts the fog of war) |
|
||||||
| | Show | zone (moves the camera there) |
|
| | Show | zone (moves the camera there) |
|
||||||
| | Gold + / Mana + | amount (negative takes) |
|
| | Gold + / Mana + | amount (negative takes) |
|
||||||
|
| | Weather above ground | fair, rain, fog or storm, for seconds (0: until a block changes it); it fades in softly. Only maps with land above ground have weather |
|
||||||
|
| | Talent experience + | amount (experience for the player's master talents) |
|
||||||
| Logic | If / else | condition; blocks inside |
|
| Logic | If / else | condition; blocks inside |
|
||||||
| | Repeat | times (0–100); blocks inside |
|
| | Repeat | times (0–100); blocks inside |
|
||||||
| | Repeat until | condition, checked **before** each run (already true: the inside never runs); blocks inside |
|
| | Repeat until | condition, checked **before** each run (already true: the inside never runs); blocks inside |
|
||||||
@@ -80,7 +82,7 @@ Every number field holds a **value**; the switch in front of it changes the valu
|
|||||||
side/kind), *Count* in a zone, *Random* (whole number between a and b, deterministic),
|
side/kind), *Count* in a zone, *Random* (whole number between a and b, deterministic),
|
||||||
*Calculation* (`+ - * /`, division by 0 gives 0),
|
*Calculation* (`+ - * /`, division by 0 gives 0),
|
||||||
- conditions (for *If*): *Comparison* (`< <= = != >= >`), *and*, *or*, *not*, *Yes/no*,
|
- conditions (for *If*): *Comparison* (`< <= = != >= >`), *and*, *or*, *not*, *Yes/no*,
|
||||||
*Trigger unit is a* (kind).
|
*Trigger unit is a* (kind), *Weather above ground is* (fair, rain, fog, storm).
|
||||||
|
|
||||||
Variables are numbers the mission remembers (saved with the game); a variable is known once some
|
Variables are numbers the mission remembers (saved with the game); a variable is known once some
|
||||||
script of the mission sets it.
|
script of the mission sets it.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ Multiplayer is only available for users of a server (not in the standalone file)
|
|||||||
- The match **pauses while a player is missing** (connection lost); a player who **leaves** is replaced by an AI.
|
- The match **pauses while a player is missing** (connection lost); a player who **leaves** is replaced by an AI.
|
||||||
- Before the start, the host manually marks a free seat as AI with `{"t":"set_ai","side":n,"ai":true}` and clears it again with `ai:false`; side 0 stays human.
|
- Before the start, the host manually marks a free seat as AI with `{"t":"set_ai","side":n,"ai":true}` and clears it again with `ai:false`; side 0 stays human.
|
||||||
- The humans (hero waves of the Order) as a third party (can be switched off), chat, alliances/teams.
|
- The humans (hero waves of the Order) as a third party (can be switched off), chat, alliances/teams.
|
||||||
|
Every wave marches on the strongest master still standing (creatures, towers, gold) – player or
|
||||||
|
AI rival – and is measured against him; the other players learn whom it attacks.
|
||||||
- Coop is "separate but helpful": own heart, gold, mana, research and creatures; allies never fight each
|
- Coop is "separate but helpful": own heart, gold, mana, research and creatures; allies never fight each
|
||||||
other, their creatures help each other, gold can be sent, allied ground can be walked; a fallen heart only
|
other, their creatures help each other, gold can be sent, allied ground can be walked; a fallen heart only
|
||||||
knocks out that player (who may keep watching); the allies win when all enemies have fallen.
|
knocks out that player (who may keep watching); the allies win when all enemies have fallen.
|
||||||
@@ -65,8 +67,10 @@ per turn. Fog of war, particles and the camera are local and not part of the sha
|
|||||||
- **Menu**: *Multiplayer* in the title menu with the match list, create (versus or co-op mission), join by code,
|
- **Menu**: *Multiplayer* in the title menu with the match list, create (versus or co-op mission), join by code,
|
||||||
seat choice, AI seat toggles for the host, ready/start, chat; the match keeps running in the background while
|
seat choice, AI seat toggles for the host, ready/start, chat; the match keeps running in the background while
|
||||||
menus are open.
|
menus are open.
|
||||||
- **AI seats** (`Game::sync_ai_sides`): empty seats marked by the host and seats of players who left are played
|
- **AI seats** (`Game::sync_ai_sides`): empty seats marked by the host and seats of players who left keep their
|
||||||
by the simplified rival AI on every client, so they stay deterministic.
|
master (dungeon, gold, creatures) and are played by the AI brain (`src/ai.rs`) on every client - the same
|
||||||
|
orders a player gives (dig, rooms, imps, towers, the attack flag, shields), from the shared state and the
|
||||||
|
shared random numbers, so they stay deterministic. Free-play and campaign rivals are such AI masters too.
|
||||||
- **Co-op** (`Game::coop`): the master sides are allies against the heroes; allied creatures never target each
|
- **Co-op** (`Game::coop`): the master sides are allies against the heroes; allied creatures never target each
|
||||||
other, allied ground does not block claims. Co-op campaign matches use `campaign::start_coop_mission`.
|
other, allied ground does not block claims. Co-op campaign matches use `campaign::start_coop_mission`.
|
||||||
- **Editor campaigns in co-op**: on the Match page the host can switch from the built-in campaign to the
|
- **Editor campaigns in co-op**: on the Match page the host can switch from the built-in campaign to the
|
||||||
@@ -76,8 +80,49 @@ per turn. Fog of war, particles and the camera are local and not part of the sha
|
|||||||
message); the relay keeps it in the room and adds it to the `start` message (room updates only show
|
message); the relay keeps it in the room and adds it to the `start` message (room updates only show
|
||||||
`"editor": true`). Every client builds the mission with `campaign::start_editor_coop_mission(doc, i, seed)` from
|
`"editor": true`). Every client builds the mission with `campaign::start_editor_coop_mission(doc, i, seed)` from
|
||||||
the room's shared seed (rival masters of the map and the Rivals goal are left out, like in built-in co-op),
|
the room's shared seed (rival masters of the map and the Rivals goal are left out, like in built-in co-op),
|
||||||
then the usual co-op setup. Block scripts run from side 0 in the fixed step. When the mission ends, the match
|
then the usual co-op setup. Block scripts run from side 0 in the fixed step.
|
||||||
ends and the players go back to the lobby; there is no branching to a next mission and no progress is stored.
|
- **Editor campaign runs over several missions**: when a mission ends, the host's end dialog offers *Next mission*
|
||||||
|
(won: the branch of the reached goal, `CampaignDoc::next_mission_choice` with `Game::match_goal_done`) or *Try
|
||||||
|
again* (lost: the same mission); the others see "waiting for the host" and may leave. The host sends
|
||||||
|
`{"t":"next","mission":i,"seed":run_seed,"carry":[[side,Carry],..]}`; the relay checks that he is side 0 and that
|
||||||
|
the mission exists, resets the turn clock, drops pending orders and sends `next` (seed, sides, teams, AI seats,
|
||||||
|
carry, no campaign) to every client. The clients keep the campaign of the `start`, drop turns of the old mission
|
||||||
|
and build the new one with `start_editor_coop_mission(doc, i, run_seed)`, then `campaign::apply_coop_carry`.
|
||||||
|
The carry (`campaign::coop_carry_from`, computed by the host from the shared final world) is per master: his
|
||||||
|
five strongest creatures and 20% of his gold, like the single-player `Carry`. The host stores the progress
|
||||||
|
(missions done, chosen branches, run seed) in local storage under `dc.coop_editor_progress` (campaign name ->
|
||||||
|
`CampaignProgress`). A run ends when no next mission fits co-op.
|
||||||
|
- **Resuming a run**: the host's progress also keeps where the run stands (`CampaignProgress::next`, set when a
|
||||||
|
mission starts and after a victory) and the carry into that mission. While a run is open the Match page shows
|
||||||
|
*Continue Mn*: with the relay it creates a room whose `create` carries `"seed"` (the run seed) and `"carry"`;
|
||||||
|
the relay's `start` then has that seed, the carry and `"resume":true`, and the clients treat it like `next`
|
||||||
|
(the progress is not reset). Without the relay the run goes on offline with the AI partner.
|
||||||
|
- **Co-op offline with an AI partner** (*AI partner* on the Match page, works without the server): the selected
|
||||||
|
co-op mission (built-in, the league or the editor campaign) starts like a match with the player on side 0 and
|
||||||
|
an AI seat on side 2 (`App::start_offline_coop`: `start_match` + `sync_ai_sides`), then `end_match` drops the
|
||||||
|
turn clock and the world runs on this computer like a single-player game. The partner (`Game::is_partner`) is
|
||||||
|
an allied rival-style master with his own dungeon: two of three healthy creatures follow the player's attack
|
||||||
|
flag (and attack the gate next to it), and every minute he sends 400 gold to an ally below 300. An editor
|
||||||
|
campaign run goes on locally: the next mission (or the same after a defeat) with the run seed and the carry.
|
||||||
|
- **Teams in versus**: on the Match page every seat has a team A-D (default: a team of its own), set with
|
||||||
|
`{"t":"set_team","team":n}` for the own seat; the host may add `"side"` for a free or AI seat. Only before the
|
||||||
|
start. Room updates carry `team` per seat, the room list `teams`, and `start` a `teams` list in the order of
|
||||||
|
`sides` (`Game::set_teams`). Masters of one team are allies exactly like co-op partners (`Game::allied`: no
|
||||||
|
attacks, their creatures help each other, a tower spares them, the fog shows the team mates' ground, creatures
|
||||||
|
and towers). AI seats take part in teams as well.
|
||||||
|
- **Fallen hearts**: a master whose soul core falls is out (`Game::out_sides`, shared state, in the checksum). His
|
||||||
|
creatures fight on without a heart (no economy: no mana, no new creatures, no payday), the creatures in his hand
|
||||||
|
are put down, the core tiles become floor and his orders are ignored. The player watches: no fog, free camera,
|
||||||
|
chat; the match goes on for the others (`Game::spectating`, local only).
|
||||||
|
- **End of a match**: decided only from shared state (`Game::check_match_end`), so every client stops in the same
|
||||||
|
step: a versus match is over when only one team has masters (or AI seats) left - that team wins, the others lose;
|
||||||
|
if all seats are one team, the humans' gate is the enemy. A mission (co-op) is lost when every heart fell, and
|
||||||
|
won by its goals (mana springs count for any master). The checksum only mixes in *whether* the world still runs.
|
||||||
|
- **Gold for allies**: `Act::SendGold { to, amount }` (100, 500, 1000 or all), in the creature overview and the
|
||||||
|
pause menu (the ally button switches between allies). It runs in the fixed step on every client, never sends
|
||||||
|
more than the sender has nor more than the receiver's treasury takes; the receiver gets a message and a sound.
|
||||||
|
- **Room list**: rooms show what they play - versus, the built-in co-op mission, or the editor campaign's name and
|
||||||
|
the title of its current mission (`campaign`, `title` in `rooms` and `room`).
|
||||||
- **Shared simulation side**: the shared part of a step (waves, units, towers, rivals, dwarves) runs with the
|
- **Shared simulation side**: the shared part of a step (waves, units, towers, rivals, dwarves) runs with the
|
||||||
first master switched in on every client; only the end check and the fog use the local master.
|
first master switched in on every client; only the end check and the fog use the local master.
|
||||||
|
|
||||||
@@ -89,12 +134,12 @@ per turn. Fog of war, particles and the camera are local and not part of the sha
|
|||||||
| B | All player actions as commands (`Command` + `Game::apply`), single player uses the same path; fixed time step | built |
|
| B | All player actions as commands (`Command` + `Game::apply`), single player uses the same path; fixed time step | built |
|
||||||
| C | Server: lobby and match relay over WebSocket (axum), turns, pause on disconnect, AI takeover on leave, chat, ping | built |
|
| C | Server: lobby and match relay over WebSocket (axum), turns, pause on disconnect, AI takeover on leave, chat, ping | built |
|
||||||
| D | Client: *Multiplayer* menu (lobby, create, join, seats, ready), WebSocket bridge in `dc_plugin.js`, chat, ping | built |
|
| D | Client: *Multiplayer* menu (lobby, create, join, seats, ready), WebSocket bridge in `dc_plugin.js`, chat, ping | built |
|
||||||
| E | Alliances and co-op rules, AI masters for free and abandoned seats | partly built: co-op alliance and AI seats work; free teams in versus and sending gold are missing |
|
| E | Alliances and co-op rules, AI masters for free and abandoned seats | built: co-op alliance, teams A-D in versus, sending gold, fallen hearts as spectators, AI seats |
|
||||||
| F | Co-op campaign: 20 missions with story (EN/DE) | partly built: co-op matches reuse the single player missions that fit the master areas (`coop_mission_indexes`); no own co-op missions or story yet |
|
| F | Co-op campaign: 20 missions with story (EN/DE) | built: "League against the Lightwatch" (`src/editor/coop_campaign.rs`), an editor campaign with painted maps, branches, raids and bosses; on the Match page the co-op source cycles built-in missions → the league → the editor's campaign |
|
||||||
|
|
||||||
Still open: teams/alliances in versus, sending gold to allies, own co-op campaign missions with story; still to be
|
| G | Editor campaigns in co-op over several missions (branches, carry, host progress), campaign names in the room list | built |
|
||||||
checked against the decisions above: a fallen heart only knocking out that player (who may keep watching), and how
|
|
||||||
savegames treat a running match.
|
Still open: own co-op campaign missions with story; how savegames treat a running match.
|
||||||
|
|
||||||
## Masters and sides
|
## Masters and sides
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
Agreed on 2026-11-05. Worked through in this order; every block is committed, pushed and deployed
|
||||||
|
on its own. Details not listed here are decided along the way.
|
||||||
|
|
||||||
|
## 1. Open items – done (v202611-00035)
|
||||||
|
|
||||||
|
1. **Heroes attack every master** – with rival masters or several players, each hero wave marches
|
||||||
|
to the heart of the *strongest* living master (most creatures and gold), not only the player's.
|
||||||
|
2. **Co-op campaign offline with an AI partner** – "League against the Lightwatch" playable
|
||||||
|
without the server: the partner is an allied AI master with its own dungeon (built like a
|
||||||
|
rival); its creatures join attacks and it sends gold when the player runs short.
|
||||||
|
3. **Resume editor co-op runs** – an interrupted multiplayer run of an editor campaign goes on at
|
||||||
|
the reached mission; the host keeps the progress.
|
||||||
|
4. **New screenshots** – title demo, language dropdown, co-op campaign.
|
||||||
|
|
||||||
|
## 2. Bridges – done (v202611-00036)
|
||||||
|
|
||||||
|
Stone bridges over lava exist already (75 gold per tile).
|
||||||
|
|
||||||
|
1. **Wooden bridges burn** – fire spells, fire traps and dragon fire set them alight; after a few
|
||||||
|
seconds they collapse and units on them fall into the water. Stone bridges are fireproof.
|
||||||
|
2. **Drawbridge** – an expensive bridge (about 150 gold per tile) with a per-bridge switch:
|
||||||
|
*manual* (a click raises or lowers the whole bridge) or *automatic* (rises when heroes come
|
||||||
|
near, lowers afterwards). Knights can only tear it down while it is lowered.
|
||||||
|
3. **Cooling lava** – frost spells and frost traps turn lava into walkable obsidian for a while.
|
||||||
|
4. **Repairing bridges** – imps repair damaged bridges; knights wear bridges down step by step
|
||||||
|
instead of in one blow.
|
||||||
|
|
||||||
|
Built as: `src/bridges.rs` (fire, wear, drawbridges, obsidian), the *Drawbridge* tool (click: down → up →
|
||||||
|
automatic) and the *Frost* spell (lava → obsidian for 40 s); the frost glow trap keeps lava next to it cooled.
|
||||||
|
|
||||||
|
## 3. The surface world – done (v202611-00038)
|
||||||
|
|
||||||
|
1. **Surface terrain** – grass, trees, paths, daylight and sky. Trees act like earth: imps fell
|
||||||
|
them.
|
||||||
|
2. **Wood** – a second resource. Above ground it comes from trees (plenty: a fortress is besieged
|
||||||
|
there), underground from giant mushrooms in caves and by the water (scarce, slowly growing back,
|
||||||
|
so few wooden doors down there). Wooden bridges, wooden doors, siege engines and room upgrades
|
||||||
|
cost wood; iron and magic doors and stone bridges still cost gold.
|
||||||
|
3. **Raising walls** – imps can raise a wall again on the player's own empty floor, for gold, so
|
||||||
|
tunnels can be closed and doors placed where they matter.
|
||||||
|
4. **Fortresses above ground** – walls, towers, a gate, a clearing with torches.
|
||||||
|
5. **Siege weapons** – heroes bring battering rams (against doors) and catapults (against towers);
|
||||||
|
the player builds his own siege engines in a workshop.
|
||||||
|
6. **Missions** – the last mission of the main campaign (M30) and of the co-op campaign (M20) lead
|
||||||
|
to the surface: the dungeon half lies in the rock (gold and more), the other half is a forest
|
||||||
|
with the High Marshal's fortress to destroy. Free play and multiplayer get a map option
|
||||||
|
"Surface". The map editor gets the new terrain.
|
||||||
|
|
||||||
|
Built as: `src/wood.rs` (wood, giant mushrooms), `src/walls.rs` (the wall tool), `src/surface.rs` (sky layer
|
||||||
|
`Map::sky`, forests, cliffs and cave mouths, clearings around fortresses), `src/siege.rs` (rams and catapults:
|
||||||
|
in hero waves from wave 4/6, buildable with a forge). The fortresses above ground are the knights' usual
|
||||||
|
fortresses (fortress.rs) in a clearing. Final missions: campaign M30 and co-op M20; free play, versus matches
|
||||||
|
and editor random maps have the option "half above ground".
|
||||||
|
|
||||||
|
## 3b. Music – done (v202611-00039)
|
||||||
|
|
||||||
|
Six more underground tracks and a block of six surface tracks: the same leitmotif brighter and in major, synthetic
|
||||||
|
birdsong and wind, flute leads and harp arpeggios. The music follows the camera (surface or underground) with a
|
||||||
|
crossfade.
|
||||||
|
|
||||||
|
Built in `tools/compose_music.py` (instruments flute, bird and wind; birds and wind on two extra channels only in
|
||||||
|
surface songs, so the older pieces stay byte for byte the same): build16-19, battle9-10, surface1-4,
|
||||||
|
surface_battle1-2; `audio::Category::Surface` / `SurfaceBattle`, chosen in `MusicDirector` when most of the
|
||||||
|
camera's surroundings lie above ground (4 s crossfade).
|
||||||
|
|
||||||
|
## 4. Additions – done (v202611-00047)
|
||||||
|
|
||||||
|
Status: creature abilities (personality.rs: an ability per creature at level 3-7, used by itself) and
|
||||||
|
creature relationships (likes, hates, brawls) existed already. Done in this block so far:
|
||||||
|
bosses (`src/bosses.rs`: four champions of the Order lead every fifth wave - Sir Aldric's shield wall,
|
||||||
|
Dame Seraphine's radiant burst, Brother Halvard's call to arms, Captain Rothgar's charge - with a health bar
|
||||||
|
and loot), statistics (`src/stats.rs`: counters and a chart at the end of a game) and achievements
|
||||||
|
(`src/achievements.rs`: 64 of them, kept in the browser and merged with `/api/profile` on the server after a
|
||||||
|
login; the menu page *Achievements*), room levels 2 and 3 (researched in the library once every tool is known:
|
||||||
|
+25% per level, brighter, gold trim) and random events (`src/events.rs`: earthquake, lava or water over the
|
||||||
|
banks, a new gold vein; every 8-12 minutes in free games and versus matches, switch in the game settings; the
|
||||||
|
host's choice goes to every client with `"events"` in the create message) and wild beasts (`src/beasts.rs`:
|
||||||
|
dens of cave bears and giant beetles in the caverns of bigger random maps; neutral like the dwarves; beaten while
|
||||||
|
a prison has room, a beast gives in and joins), traders (`src/traders.rs`: every ~10 minutes in single player a
|
||||||
|
merchant waits a minute at an own portal; a click with the hand buys rune plans, an ember wyrm, a chainbreaker,
|
||||||
|
wood or a mana potion), seasons (snow above ground in winter, pumpkins at Halloween, from the real date) and
|
||||||
|
creature equipment (`src/armory.rs`: without orders the forge makes iron, steel and rune gear for gold; each
|
||||||
|
piece goes to the strongest creature without one that good: +10/22/38% health and attack, a coloured mark at
|
||||||
|
the health bar). The dynamic music (calm and battle) existed already and now also follows the camera to the
|
||||||
|
surface. The tutorial (*Tutorial* in the title menu, `editor::templates::tutorial_campaign`): Crookfinger leads a
|
||||||
|
new master through digging, claiming, lair, mushroom garden, the first creatures, a slap and two knights - one
|
||||||
|
painted mission whose triggers switch each other on. **Block 4 is done.**
|
||||||
|
|
||||||
|
1. **Random events** – earthquake (tunnels cave in), lava eruption, flood, gold vein found. Rare
|
||||||
|
(every 8–12 minutes), free play and multiplayer only, can be switched off in the menu; in
|
||||||
|
campaigns only through new script blocks.
|
||||||
|
2. **Creature abilities** – from level 5 every creature has an active ability (ogre stomp, spider
|
||||||
|
web, mage chain lightning, ...), used automatically and wisely, with a cooldown.
|
||||||
|
3. **Bosses and elite heroes** – every 5th wave brings a boss hero with a health bar at the top,
|
||||||
|
an ability of its own and loot (gold, runes).
|
||||||
|
4. **Wild beasts** – beasts in caves (cave bears, giant beetles, ...) that can be tamed in the
|
||||||
|
prison and become creatures.
|
||||||
|
5. **Room upgrades** – rooms can be upgraded to level 2 and 3 for gold and wood (a library that
|
||||||
|
researches faster, ...), shown by ornaments and brighter light.
|
||||||
|
6. **Creature equipment** – the forge makes weapons and armour in tiers (iron, steel, rune);
|
||||||
|
creatures fetch the best fitting piece themselves, the strongest first; equipment is visible.
|
||||||
|
7. **Creature relationships** – creatures like or hate each other, quarrel, and work better with
|
||||||
|
friends; the lair shows who likes whom.
|
||||||
|
8. **Dungeon visitors** – neutral traders and smugglers come through the portal and sell rare
|
||||||
|
traps, runes or creatures for gold.
|
||||||
|
9. **Dynamic music** – procedural music that changes between calm and battle and swells when
|
||||||
|
heroes come.
|
||||||
|
10. **Tutorial mission** – an interactive introduction with Crookfinger (digging, rooms,
|
||||||
|
creatures, traps), built with the editor and script blocks.
|
||||||
|
11. **Statistics** – charts at the end of a game (gold over time, creatures, kills, buildings; for
|
||||||
|
every player in multiplayer) and lifetime totals in the profile.
|
||||||
|
12. **Achievements** – 50+ achievements with pixel badges, including hidden and humorous ones in
|
||||||
|
Crookfinger's style and progress bars, stored per user on the server.
|
||||||
|
13. **Seasonal events** – cosmetic touches by date (pumpkins at Halloween, snow on the surface in
|
||||||
|
winter, ...).
|
||||||
|
|
||||||
|
## 5. Next (agreed 2026-11-06)
|
||||||
|
|
||||||
|
Done: the pixel look (the world 360 rows on every device, 270/480 selectable; the interface on the same
|
||||||
|
grid), guard and attack markers (shields and swords), savegames with the game state only.
|
||||||
|
|
||||||
|
1. **Real AI masters** (done, v202611-00052) - rivals, AI seats and the co-op partner play by the player's rules: imps dig,
|
||||||
|
claim, mine and set torches; rooms by plan, traps, towers, research; the attack flag, shields and swords;
|
||||||
|
strength by the difficulty.
|
||||||
|
2. **Mouse cursor** (done, v202611-00053) - clearer hands for the hand tool (open, holding, slap, punch).
|
||||||
|
3. **New heroes** (done, v202611-00063, rogues.rs) - thief (sneaks to a treasury, steals up to 250 gold and runs
|
||||||
|
to the gate; slain, his loot is yours; from wave 3), monk (spells do not touch him, 5), ranger (shoots, lays
|
||||||
|
snares that hurt and stun a creature, 7), inquisitor (fragile, every 20 s turns a nearby kobold or spider to
|
||||||
|
the heroes, 9). In the campaign from missions 5, 9, 12 and 16 on.
|
||||||
|
4. **Master talents** (done, v202611-00064, talents.rs) - experience from slain heroes, digging, rooms, walls,
|
||||||
|
traps, towers, spells and gold; points get dearer (300, 450, 600 ...). Builder (imps dig +10%, rooms -6% per
|
||||||
|
level), Warlord (creatures +5% damage, -5% damage taken), Warlock (mana +15%, spells -6%), Merchant (wages
|
||||||
|
-6%, treasure +6%); 5 levels each. The "Master" button (number of free points, pulsing) opens the window.
|
||||||
|
AI masters spend their points themselves; per game, carried over in the campaign and co-op runs.
|
||||||
|
5. **Weather above ground** (done, v202611-00066, weather.rs) - about 60% fair, 20% rain, 12% fog, 8% storm,
|
||||||
|
minutes each with soft 20 s changes, only on maps with land above ground. Rain (and storms) put out burning
|
||||||
|
bridges and keep fire from catching; fog halves the sight of units outside; storms strike units (40 damage)
|
||||||
|
or trees with lightning every 6-20 s. Dimmed daylight, rain streaks, fog veils, flashes; a HUD label.
|
||||||
|
6. **Named creatures** (done, v202611-00068, names.rs) - every creature gets a name from its kind's list in the
|
||||||
|
language files (20 per kind, per language), a humorous epithet from level 5 and a grand one from level 8;
|
||||||
|
names in the hover info, the creature overview and the messages. A creature of level 10 that falls leaves a
|
||||||
|
memorial stone on its master's ground (hover: who lies there; at most 30 per map).
|
||||||
|
7. **Hall of the fallen** (done, v202611-00073) - every named creature of level 3+ that falls is remembered
|
||||||
|
with its level, heroes slain and how it fell (slain by a kind, lava, water, snare, lightning): in the game
|
||||||
|
(creature overview "Fallen", or a click with the hand on a memorial stone) and over all games on the
|
||||||
|
achievements page (kept in the profile, at most 200, merged with the server copy).
|
||||||
|
After Width: | Height: | Size: 769 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 685 KiB |
|
After Width: | Height: | Size: 853 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 966 KiB After Width: | Height: | Size: 938 KiB |
@@ -52,17 +52,29 @@ msg.creature_arrived = Ein {0} ist durch das Portal gekommen.
|
|||||||
msg.payday_partial = Zahltag! {0} Kreaturen bezahlt ({1} Gold), {2} gingen leer aus und sind wütend!
|
msg.payday_partial = Zahltag! {0} Kreaturen bezahlt ({1} Gold), {2} gingen leer aus und sind wütend!
|
||||||
msg.payday = Zahltag! {0} Kreaturen erhielten {1} Gold.
|
msg.payday = Zahltag! {0} Kreaturen erhielten {1} Gold.
|
||||||
msg.wave_boss = Welle {0}: Ein Lichtpaladin führt {1} Helden in dein Reich!
|
msg.wave_boss = Welle {0}: Ein Lichtpaladin führt {1} Helden in dein Reich!
|
||||||
|
boss.aldric = Sir Aldric der Unbeugsame
|
||||||
|
boss.seraphine = Dame Seraphine von der Morgenröte
|
||||||
|
boss.halvard = Bruder Halvard der Fromme
|
||||||
|
boss.rothgar = Hauptmann Rothgar Eisenflanke
|
||||||
|
msg.boss_comes = Ein Champion des Ordens führt die Welle an: {0}!
|
||||||
|
msg.boss_slain = {0} ist gefallen! Beute: {1} Gold und die Pläne eines Runenschmiede-Turms.
|
||||||
msg.wave = Welle {0}: {1} Helden dringen durch das Tor!
|
msg.wave = Welle {0}: {1} Helden dringen durch das Tor!
|
||||||
msg.gate_boss = Das Tor wankt! Der Torwächter erscheint mit {0} Verteidigern!
|
msg.gate_boss = Das Tor wankt! Der Torwächter erscheint mit {0} Verteidigern!
|
||||||
msg.gate_defenders = Das Tor ruft {0} Verteidiger herbei!
|
msg.gate_defenders = Das Tor ruft {0} Verteidiger herbei!
|
||||||
msg.level_up = {0} erreicht Stufe {1}.
|
msg.level_up = {0} erreicht Stufe {1}.
|
||||||
msg.hero_slain = {0} besiegt! Beute: {1} Gold.
|
msg.hero_slain = {0} besiegt! Beute: {1} Gold.
|
||||||
|
msg.thief_in_treasury = Ein Dieb ist in deiner Schatzkammer!
|
||||||
|
msg.thief_escaped = Ein Dieb ist mit {0} Gold entkommen!
|
||||||
|
msg.snare_caught = In eine Falle getreten: {0}!
|
||||||
|
msg.converted = Der Inquisitor hat eine deiner Kreaturen bekehrt: {0}!
|
||||||
msg.creature_died = Dein {0} ist gefallen.
|
msg.creature_died = Dein {0} ist gefallen.
|
||||||
msg.portal_claimed = Das Portal gehört nun dir! Kreaturen werden davon angelockt.
|
msg.portal_claimed = Das Portal gehört nun dir! Kreaturen werden davon angelockt.
|
||||||
msg.flag_removed = Angriffsflagge entfernt. Deine Kreaturen kehren zurück.
|
msg.flag_removed = Angriffsflagge entfernt. Deine Kreaturen kehren zurück.
|
||||||
msg.flag_set = Angriffsflagge gesetzt! Kampfbereite Kreaturen sammeln sich dort.
|
msg.flag_set = Angriffsflagge gesetzt! Kampfbereite Kreaturen sammeln sich dort.
|
||||||
msg.creature_leaves = Ein unzufriedener {0} verlässt dein Reich!
|
msg.creature_leaves = Ein unzufriedener {0} verlässt dein Reich!
|
||||||
msg.no_gold = Nicht genug Gold!
|
msg.no_gold = Nicht genug Gold!
|
||||||
|
msg.no_wood = Nicht genug Holz! Fälle Riesenpilze (oder Bäume) mit der Spitzhacke.
|
||||||
|
msg.siege_built = Ein {0} ist bereit – setze die Angriffsflagge, um ihn loszuschicken.
|
||||||
msg.sold = Räume verkauft: +{0} Gold.
|
msg.sold = Räume verkauft: +{0} Gold.
|
||||||
msg.no_mana_bolt = Nicht genug Mana für den Blitz.
|
msg.no_mana_bolt = Nicht genug Mana für den Blitz.
|
||||||
msg.no_mana_heal = Nicht genug Mana für die Heilung.
|
msg.no_mana_heal = Nicht genug Mana für die Heilung.
|
||||||
@@ -72,6 +84,7 @@ msg.no_mana_cost = Nicht genug Mana ({0} benötigt).
|
|||||||
hint.portal_no_lair = Das Portal ist offen, aber ohne Quartier kommen keine Kreaturen.
|
hint.portal_no_lair = Das Portal ist offen, aber ohne Quartier kommen keine Kreaturen.
|
||||||
hint.no_farm = Kreaturen wollen essen: Baue einen Pilzgarten.
|
hint.no_farm = Kreaturen wollen essen: Baue einen Pilzgarten.
|
||||||
hint.lair_full = Alle Quartierplätze sind belegt. Vergrößere dein Quartier.
|
hint.lair_full = Alle Quartierplätze sind belegt. Vergrößere dein Quartier.
|
||||||
|
hint.creature_max = Dein Kerker beherbergt 100 Kreaturen - mehr kommen nicht durch das Portal.
|
||||||
hint.gate_attack = Deine Kreaturen greifen das Heldentor an!
|
hint.gate_attack = Deine Kreaturen greifen das Heldentor an!
|
||||||
hint.treasury_full = Deine Schatzkammern sind voll! Baue mehr Schatzkammer-Felder.
|
hint.treasury_full = Deine Schatzkammern sind voll! Baue mehr Schatzkammer-Felder.
|
||||||
hint.flag_unreachable = Die Angriffsflagge ist für deine Kreaturen nicht erreichbar.
|
hint.flag_unreachable = Die Angriffsflagge ist für deine Kreaturen nicht erreichbar.
|
||||||
@@ -123,6 +136,7 @@ ui.key = Taste: {0}
|
|||||||
|
|
||||||
# ---- Anzeige
|
# ---- Anzeige
|
||||||
hud.gold = Gold
|
hud.gold = Gold
|
||||||
|
hud.wood = Holz
|
||||||
hud.mana = Mana
|
hud.mana = Mana
|
||||||
hud.core = Kern
|
hud.core = Kern
|
||||||
hud.gate = Tor
|
hud.gate = Tor
|
||||||
@@ -144,6 +158,7 @@ help.k_right = Rechtsklick Einheit absetzen (Gefangene auch in den Kerker)
|
|||||||
help.k_slap = Shift+Links Hand: Rückhand für eigene, Faust für Helden auf eigenem Gebiet
|
help.k_slap = Shift+Links Hand: Rückhand für eigene, Faust für Helden auf eigenem Gebiet
|
||||||
help.k_pan = Rechts ziehen Karte verschieben (auch WASD/Pfeile, Bildrand)
|
help.k_pan = Rechts ziehen Karte verschieben (auch WASD/Pfeile, Bildrand)
|
||||||
help.k_rot = Mitte ziehen Kamera drehen und neigen (auch Q/E, R/F)
|
help.k_rot = Mitte ziehen Kamera drehen und neigen (auch Q/E, R/F)
|
||||||
|
help.k_reset = Mitte 2x klicken Blickwinkel und Drehung zurücksetzen (Ort bleibt)
|
||||||
help.k_zoom = Mausrad Zoomen
|
help.k_zoom = Mausrad Zoomen
|
||||||
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Werkzeug
|
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Werkzeug
|
||||||
help.k_misc = P / Leertaste Pause M Ton an/aus +/- Lautstärke
|
help.k_misc = P / Leertaste Pause M Ton an/aus +/- Lautstärke
|
||||||
@@ -236,6 +251,7 @@ set.lights = Lichtquellen
|
|||||||
set.ui_scale = Oberflächengröße
|
set.ui_scale = Oberflächengröße
|
||||||
set.game_speed = Spieltempo
|
set.game_speed = Spieltempo
|
||||||
set.hints = Hinweise
|
set.hints = Hinweise
|
||||||
|
set.events = Zufallsereignisse
|
||||||
set.keys_hint = Aktion anklicken und Taste drücken. Bereits belegte Tasten werden getauscht.
|
set.keys_hint = Aktion anklicken und Taste drücken. Bereits belegte Tasten werden getauscht.
|
||||||
set.press_key = Taste drücken für: {0}
|
set.press_key = Taste drücken für: {0}
|
||||||
set.esc_cancel = Esc bricht ab
|
set.esc_cancel = Esc bricht ab
|
||||||
@@ -247,6 +263,8 @@ val.medium = Mittel
|
|||||||
val.high = Hoch
|
val.high = Hoch
|
||||||
val.normal = Normal
|
val.normal = Normal
|
||||||
val.large = Groß
|
val.large = Groß
|
||||||
|
val.small = Klein
|
||||||
|
val.auto_plain = Automatisch
|
||||||
|
|
||||||
# ---- Namen der Tastenbelegung
|
# ---- Namen der Tastenbelegung
|
||||||
key.cam_forward = Kamera vor
|
key.cam_forward = Kamera vor
|
||||||
@@ -312,12 +330,30 @@ hint.tower_max = Dieser Turm ist voll ausgebaut.
|
|||||||
hint.tower_where = Türme können nur auf eroberten, leeren Bodenfeldern gebaut werden.
|
hint.tower_where = Türme können nur auf eroberten, leeren Bodenfeldern gebaut werden.
|
||||||
hint.tower_mana = Deinen Magietürmen ist das Mana ausgegangen.
|
hint.tower_mana = Deinen Magietürmen ist das Mana ausgegangen.
|
||||||
job.repair = Repariert einen Turm
|
job.repair = Repariert einen Turm
|
||||||
|
job.mend_bridge = Flickt eine Brücke
|
||||||
|
job.raise_wall = Mauert eine Wand
|
||||||
|
job.march = Marschiert zu einem Schwert
|
||||||
job.attack_tower = Greift einen Turm an
|
job.attack_tower = Greift einen Turm an
|
||||||
|
|
||||||
unit.jailer = Kerkermeister
|
unit.jailer = Kerkermeister
|
||||||
unit.priestess = Flammenpriesterin
|
unit.priestess = Flammenpriesterin
|
||||||
msg.hero_captured = {0} bewusstlos geschlagen und gefangen genommen! Beute: {1} Gold.
|
msg.hero_captured = {0} bewusstlos geschlagen und gefangen genommen! Beute: {1} Gold.
|
||||||
msg.defected = {0} ist zu dir übergelaufen!
|
msg.defected = {0} ist zu dir übergelaufen!
|
||||||
|
msg.beast_tamed = Ein wilder {0} ergibt sich und schließt sich deinem Kerker an!
|
||||||
|
gear.1 = Eisenausrüstung
|
||||||
|
gear.2 = Stahlausrüstung
|
||||||
|
gear.3 = Runenausrüstung
|
||||||
|
msg.gear_given = Die Schmiede übergibt {0} an: {1}.
|
||||||
|
msg.trader_arrives = Ein Händler kam durch dein Portal: {0} für {1} Gold. Klick ihn mit der Hand an, um zu kaufen – er wartet eine Minute.
|
||||||
|
msg.trader_sold = Gekauft: {0}. Ein Vergnügen, sagt der Händler.
|
||||||
|
msg.trader_left = Der Händler zuckt mit den Schultern und geht durchs Portal.
|
||||||
|
trader.info = Bietet {0} für {1} Gold (mit der Hand anklicken)
|
||||||
|
trader.left = Geht in {0} s
|
||||||
|
trader.rune = die Pläne eines Runenschmiede-Turms
|
||||||
|
trader.wyrm = einen Glutwurm
|
||||||
|
trader.chainbreaker = einen Kettenbrecher
|
||||||
|
trader.wood = 30 Holz
|
||||||
|
trader.mana = einen Trank, der dein Mana füllt
|
||||||
msg.prisoner_died = {0} ist unter der Folter gestorben.
|
msg.prisoner_died = {0} ist unter der Folter gestorben.
|
||||||
job.carry = Trägt einen Gefangenen
|
job.carry = Trägt einen Gefangenen
|
||||||
job.pray = Betet
|
job.pray = Betet
|
||||||
@@ -328,6 +364,10 @@ info.tortured = wird gefoltert
|
|||||||
info.prison = Gefangene: {0}/{1}
|
info.prison = Gefangene: {0}/{1}
|
||||||
|
|
||||||
msg.wave_warn_dir = Späher melden: Welle {0} erreicht in 30 Sekunden das Tor! Die Helden wollen von {1} einfallen.
|
msg.wave_warn_dir = Späher melden: Welle {0} erreicht in 30 Sekunden das Tor! Die Helden wollen von {1} einfallen.
|
||||||
|
msg.event_quake = Erdbeben! Im {0} sind Gänge eingestürzt.
|
||||||
|
msg.event_lava = Im {0} tritt die Lava über die Ufer!
|
||||||
|
msg.event_flood = Hochwasser! Im {0} tritt der Fluss über die Ufer.
|
||||||
|
msg.event_vein = Die Imps melden eine neue Goldader im {0}!
|
||||||
msg.wave_warn_two = Späher melden: Welle {0} erreicht in 30 Sekunden das Tor! Die Helden teilen sich auf und fallen von {1} und von {2} ein.
|
msg.wave_warn_two = Späher melden: Welle {0} erreicht in 30 Sekunden das Tor! Die Helden teilen sich auf und fallen von {1} und von {2} ein.
|
||||||
dir.n = Norden
|
dir.n = Norden
|
||||||
dir.ne = Nordosten
|
dir.ne = Nordosten
|
||||||
@@ -339,7 +379,8 @@ dir.w = Westen
|
|||||||
dir.nw = Nordwesten
|
dir.nw = Nordwesten
|
||||||
|
|
||||||
val.auto = Auto ({0}x)
|
val.auto = Auto ({0}x)
|
||||||
set.render_scale = 3D-Auflösung
|
set.render_scale = Pixel-Look
|
||||||
|
set.pixel_rows_val = {0} Zeilen
|
||||||
set.debug_fps = FPS anzeigen
|
set.debug_fps = FPS anzeigen
|
||||||
set.debug_frame_time = Framezeit anzeigen
|
set.debug_frame_time = Framezeit anzeigen
|
||||||
set.debug_ping = Ping anzeigen
|
set.debug_ping = Ping anzeigen
|
||||||
@@ -397,8 +438,25 @@ help.start_touch = Tippen zum Starten
|
|||||||
tool.bridge.label = Brücke
|
tool.bridge.label = Brücke
|
||||||
tool.bridge.name = Brücke bauen
|
tool.bridge.name = Brücke bauen
|
||||||
tool.bridge.desc = Baut eine Brücke über Wasser oder Lava. Ziehe von deinem Gebiet aus über den Fluss; die Brücke wächst Feld für Feld von deinem Reich aus.
|
tool.bridge.desc = Baut eine Brücke über Wasser oder Lava. Ziehe von deinem Gebiet aus über den Fluss; die Brücke wächst Feld für Feld von deinem Reich aus.
|
||||||
cost.bridge = {0} Gold/Feld über Wasser, {1} über Lava
|
tool.drawbridge.label = Zugbrücke
|
||||||
|
tool.drawbridge.name = Zugbrücke bauen
|
||||||
|
tool.drawbridge.desc = Eine Brücke, die du hochziehen kannst: Ziehe über den Fluss, um sie zu bauen, klicke sie an zum Umschalten: unten, oben, automatisch (hebt sich, wenn Feinde nahen). Holz über Wasser brennt, Stein über Lava nicht.
|
||||||
|
tool.wall.label = Wand
|
||||||
|
tool.wall.name = Wand aufrichten
|
||||||
|
tool.wall.desc = Plane Wände auf deinem eigenen leeren Boden (nochmal ziehen hebt den Plan auf): Imps mauern sie als verstärkte Erde. Schließe Gänge und setze Türen dorthin, wo sie zählen.
|
||||||
|
tool.ram.label = Ramme
|
||||||
|
tool.ram.name = Rammbock bauen
|
||||||
|
tool.ram.desc = Ein Rammbock rollt aus deiner Schmiede (Gold und Holz). Er folgt deiner Angriffsflagge und zertrümmert Tor und Türme der Ritter.
|
||||||
|
tool.catapult.label = Katapult
|
||||||
|
tool.catapult.name = Katapult bauen
|
||||||
|
tool.catapult.desc = Ein Katapult rollt aus deiner Schmiede (Gold und Holz). Es beschießt die Türme der Ritter aus der Ferne und folgt deiner Angriffsflagge zu ihrem Tor.
|
||||||
|
cost.bridge = {0} Gold + {1} Holz/Kachel über Wasser, {2} Gold über Lava
|
||||||
|
cost.drawbridge = {0} Gold + {1} Holz/Kachel über Wasser, {2} Gold über Lava
|
||||||
|
cost.gold_wood = {0} Gold + {1} Holz
|
||||||
hint.bridge_where = Brücken müssen an deinem Gebiet beginnen: Ziehe von deinem Reich aus über den Fluss.
|
hint.bridge_where = Brücken müssen an deinem Gebiet beginnen: Ziehe von deinem Reich aus über den Fluss.
|
||||||
|
hint.bridge_fire = Holzbrücken brennen: Halte Feuerfallen, Feueratem und Blitze von ihnen fern – Steinbrücken über Lava sind feuerfest.
|
||||||
|
hint.wall_where = Wände lassen sich nur auf deinem eigenen leeren Boden aufrichten (kein Raum, keine Falle, keine Tür).
|
||||||
|
hint.siege_where = Belagerungsgerät rollt auf deinem eigenen freien Boden aus – und du brauchst eine Schmiede.
|
||||||
hint.bridge_river = Brücken lassen sich nur über Wasser oder Lava bauen.
|
hint.bridge_river = Brücken lassen sich nur über Wasser oder Lava bauen.
|
||||||
msg.hero_burned = Ein {0} ist in die Lava gefallen! +{1} Gold.
|
msg.hero_burned = Ein {0} ist in die Lava gefallen! +{1} Gold.
|
||||||
msg.creature_burned = Dein {0} ist in die Lava gefallen.
|
msg.creature_burned = Dein {0} ist in die Lava gefallen.
|
||||||
@@ -408,6 +466,22 @@ tile.lava = Lava
|
|||||||
tile.lava_info = Was hineinfällt, verbrennt. Steinbrücken führen hinüber.
|
tile.lava_info = Was hineinfällt, verbrennt. Steinbrücken führen hinüber.
|
||||||
tile.bridge = Holzbrücke
|
tile.bridge = Holzbrücke
|
||||||
tile.lavabridge = Steinbrücke
|
tile.lavabridge = Steinbrücke
|
||||||
|
tile.mushroom = Riesenpilz
|
||||||
|
tile.mushroom_grown = Ausgewachsen: bereit zum Fällen.
|
||||||
|
tile.mushroom_growing = Wächst: {0}% - erst ausgewachsen kann er gefällt werden.
|
||||||
|
tile.grove = Pilzhain
|
||||||
|
tile.grove_info = Dieses Moos kann niemand erobern. Hier wachsen Riesenpilze nach.
|
||||||
|
tile.tree = Baum
|
||||||
|
tile.plant_info = Mit der Spitzhacke markieren: Ein Imp fällt ihn für {0} Holz.
|
||||||
|
tile.drawbridge = Zugbrücke
|
||||||
|
tile.drawbridge_up = Hochgezogen
|
||||||
|
tile.drawbridge_down = Herabgelassen
|
||||||
|
tile.drawbridge_auto = Automatisch (unten)
|
||||||
|
tile.drawbridge_auto_up = Automatisch (oben)
|
||||||
|
tile.obsidian = Obsidian
|
||||||
|
tile.obsidian_info = Erkaltete Lava – schmilzt in {0} s wieder
|
||||||
|
tile.bridge_burning = Brennt!
|
||||||
|
tile.bridge_worn = Beschädigt {0}%
|
||||||
tile.shard = Splitter deines Seelenkerns
|
tile.shard = Splitter deines Seelenkerns
|
||||||
tile.shard_info = Erobere diese Kammer, um den Splitter zurückzuholen.
|
tile.shard_info = Erobere diese Kammer, um den Splitter zurückzuholen.
|
||||||
|
|
||||||
@@ -429,6 +503,11 @@ msg.enemy_tower_destroyed = Ein Ritter-{0} ist gefallen!
|
|||||||
msg.vault_taken = Ritterschatz erobert: Schätze im Wert von {0} gehören dir.
|
msg.vault_taken = Ritterschatz erobert: Schätze im Wert von {0} gehören dir.
|
||||||
msg.shard_taken = Ein Splitter deines Seelenkerns ist wieder in deiner Hand!
|
msg.shard_taken = Ein Splitter deines Seelenkerns ist wieder in deiner Hand!
|
||||||
msg.bridge_torn = Die Ritter haben eine deiner Brücken eingerissen!
|
msg.bridge_torn = Die Ritter haben eine deiner Brücken eingerissen!
|
||||||
|
msg.bridge_fire = Eine deiner Brücken brennt!
|
||||||
|
msg.drawbridge_up = Die Zugbrücke ist hochgezogen.
|
||||||
|
msg.drawbridge_down = Die Zugbrücke ist herabgelassen.
|
||||||
|
msg.drawbridge_auto = Die Zugbrücke hebt sich von selbst, wenn Feinde nahen.
|
||||||
|
msg.drawbridge_rises = Feinde nahen: Eine Zugbrücke hebt sich!
|
||||||
job.tear_bridge = Reißt eine Brücke ein
|
job.tear_bridge = Reißt eine Brücke ein
|
||||||
job.patrol = Auf Patrouille
|
job.patrol = Auf Patrouille
|
||||||
job.guard = Hält Wache
|
job.guard = Hält Wache
|
||||||
@@ -532,7 +611,7 @@ camp.m29.story = Lavainseln, Meister, mit kochenden Manaquellen in der Mitte, un
|
|||||||
camp.m20.title = Der Großmeister
|
camp.m20.title = Der Großmeister
|
||||||
camp.m20.story = Großmeister Valerian Hellschild erwartet dich in seiner Zitadelle jenseits des großen Flusses, mit dem letzten Splitter und dem Tor des Ordens. Hundert Jahre lang hat er gebetet, poliert und sich vorbereitet. | Zerstöre sein Tor und hol dir den Splitter. Dann ist dein Kern ganz - und jeder Meister, der ihn je haben wollte, wird auf einmal danach greifen. Eins nach dem anderen.
|
camp.m20.story = Großmeister Valerian Hellschild erwartet dich in seiner Zitadelle jenseits des großen Flusses, mit dem letzten Splitter und dem Tor des Ordens. Hundert Jahre lang hat er gebetet, poliert und sich vorbereitet. | Zerstöre sein Tor und hol dir den Splitter. Dann ist dein Kern ganz - und jeder Meister, der ihn je haben wollte, wird auf einmal danach greifen. Eins nach dem anderen.
|
||||||
camp.m30.title = Der Thron der Tiefe
|
camp.m30.title = Der Thron der Tiefe
|
||||||
camp.m30.story = Es kam, wie ich gesagt habe: Dein Kern ist ganz, und jetzt wollen ihn alle. Mossgrim und Lady Nightwell marschieren gemeinsam, und was vom Orden übrig ist, öffnet ein letztes Mal sein Tor. | Zerstöre beide Meisterkerne und das Tor des Ordens. Danach hat die Tiefe genau einen Meister. Rate, wen. Kleiner Tipp: nicht mich, ich bin der Teemann.
|
camp.m30.story = Es kam, wie ich sagte: Dein Kern ist ganz, und jetzt will ihn jeder. Mossgrim und Lady Nightwell haben sich bis an die Oberfläche gegraben, und der Hochmarschall wartet in seiner Festung im Wald – Tageslicht, Vögel, das volle Programm. Widerlich. | Steig aus dem Fels, zerstöre beide Meisterkerne und das Tor des Ordens in der Festung. Danach hat die Welt genau einen Meister. Rate mal, wen. Tipp: nicht mich, ich bin der Teemann.
|
||||||
camp.epilogue = Der grüne Kern zerbröselt, der blaue erlischt, und das Tor des Ordens verstummt. Irgendwo dort oben schreibt die Lichtwacht einen sehr langen Bericht, und Lady Nightwells Briefe bleiben aus. | Krummfinger hat Tee gekocht. Du bist wieder ganz, Meister. Das Unterreich gehört dir.
|
camp.epilogue = Der grüne Kern zerbröselt, der blaue erlischt, und das Tor des Ordens verstummt. Irgendwo dort oben schreibt die Lichtwacht einen sehr langen Bericht, und Lady Nightwells Briefe bleiben aus. | Krummfinger hat Tee gekocht. Du bist wieder ganz, Meister. Das Unterreich gehört dir.
|
||||||
lair.arrivals = {0} freie Plätze: Neue Kreaturen kommen mit {1}% Tempo
|
lair.arrivals = {0} freie Plätze: Neue Kreaturen kommen mit {1}% Tempo
|
||||||
job.help = Hilft mit
|
job.help = Hilft mit
|
||||||
@@ -656,10 +735,15 @@ net.error = {0}
|
|||||||
net.rooms = Offene Partien
|
net.rooms = Offene Partien
|
||||||
net.no_rooms = Keine offenen Partien.
|
net.no_rooms = Keine offenen Partien.
|
||||||
net.create = Partie erstellen
|
net.create = Partie erstellen
|
||||||
|
net.map_size = Karte {0}x{1} · {2} Spieler
|
||||||
net.coop_mission = Koop-Kampagne: Mission {0} ({1})
|
net.coop_mission = Koop-Kampagne: Mission {0} ({1})
|
||||||
net.coop_create = Koop-Kampagne
|
net.coop_create = Koop-Kampagne
|
||||||
net.coop_editor = Editor-Kampagne
|
net.coop_editor = Editor-Kampagne
|
||||||
net.coop_builtin = Eingebaute Kampagne
|
net.coop_builtin = Eingebaute Kampagne
|
||||||
|
net.coop_league = Bund gegen die Lichtwacht
|
||||||
|
net.coop_solo = KI-Partner
|
||||||
|
net.coop_solo_started = Koop ohne Server: Dein KI-Partner baut einen eigenen Kerker, folgt deiner Angriffsflagge und hilft mit Gold aus.
|
||||||
|
net.coop_resume = Weiter: M{0}
|
||||||
net.coop_editor_mission = Editor-Kampagne {0}: Mission {1} ({2})
|
net.coop_editor_mission = Editor-Kampagne {0}: Mission {1} ({2})
|
||||||
net.coop_editor_none = Keine Mission der Editor-Kampagne passt für Koop (Karte mindestens 72x72, ein Ziel außer Rivalen-Meistern)
|
net.coop_editor_none = Keine Mission der Editor-Kampagne passt für Koop (Karte mindestens 72x72, ein Ziel außer Rivalen-Meistern)
|
||||||
net.join = Beitreten
|
net.join = Beitreten
|
||||||
@@ -690,6 +774,9 @@ net.ai_off = Aus
|
|||||||
|
|
||||||
# rival masters (free game)
|
# rival masters (free game)
|
||||||
newgame.difficulty = Gegner
|
newgame.difficulty = Gegner
|
||||||
|
newgame.land = Gelände
|
||||||
|
newgame.land0 = Unterirdisch
|
||||||
|
newgame.land1 = Halb über der Erde
|
||||||
diff.0 = Leicht
|
diff.0 = Leicht
|
||||||
diff.1 = Mittel
|
diff.1 = Mittel
|
||||||
diff.2 = Schwer
|
diff.2 = Schwer
|
||||||
@@ -702,9 +789,14 @@ msg.rival1_attack = Mossgrim, der grüne Meister, schickt {0} Kreaturen gegen di
|
|||||||
msg.rival2_attack = Lady Nightwell, die blaue Meisterin, schickt {0} Kreaturen gegen dich!
|
msg.rival2_attack = Lady Nightwell, die blaue Meisterin, schickt {0} Kreaturen gegen dich!
|
||||||
msg.rival1_out = Mossgrim ist besiegt - sein Seelenkern ist zersplittert!
|
msg.rival1_out = Mossgrim ist besiegt - sein Seelenkern ist zersplittert!
|
||||||
msg.rival2_out = Lady Nightwell ist besiegt - ihr Seelenkern ist zersplittert!
|
msg.rival2_out = Lady Nightwell ist besiegt - ihr Seelenkern ist zersplittert!
|
||||||
|
msg.rival3_attack = Saffrax, der gelbe Meister, schickt {0} Kreaturen gegen dich!
|
||||||
|
msg.rival3_out = Saffrax ist besiegt - sein Seelenkern ist zersplittert!
|
||||||
msg.rival_unit_slain = Ein {0} eines Rivalen fällt! +{1} Gold.
|
msg.rival_unit_slain = Ein {0} eines Rivalen fällt! +{1} Gold.
|
||||||
msg.wave_on_rival1 = Welle {0}: {1} Helden ziehen gegen den grünen Meister.
|
msg.wave_on_rival1 = Welle {0}: {1} Helden ziehen gegen den grünen Meister.
|
||||||
msg.wave_on_rival2 = Welle {0}: {1} Helden ziehen gegen den blauen Meister.
|
msg.wave_on_rival2 = Welle {0}: {1} Helden ziehen gegen den blauen Meister.
|
||||||
|
msg.wave_warn_on_master = Späher melden: Heldenwelle {0} kommt in 30 Sekunden – sie zieht gegen Meister {1}.
|
||||||
|
msg.wave_on_master = Welle {0}: {1} Helden ziehen gegen Meister {2}.
|
||||||
|
msg.partner_out = Das Herz deines KI-Partners ist gefallen – kämpfe allein weiter!
|
||||||
hud.rival1 = Grün
|
hud.rival1 = Grün
|
||||||
hud.rival2 = Blau
|
hud.rival2 = Blau
|
||||||
msg.creature_freed = Ein {0} sprengt seine Ketten und schließt sich dir an!
|
msg.creature_freed = Ein {0} sprengt seine Ketten und schließt sich dir an!
|
||||||
@@ -793,6 +885,8 @@ best.b.torture.text = Gefangene Helden landen hier. Mit der Zeit und einem Kerke
|
|||||||
best.b.temple.text = Gebete heilen den Seelenkern und heben die Stimmung. Die Flammenpriesterin kommt für einen Tempel.
|
best.b.temple.text = Gebete heilen den Seelenkern und heben die Stimmung. Die Flammenpriesterin kommt für einen Tempel.
|
||||||
best.b.aerie.text = Sitzstangen und Nester für Flieger. Fledermäuse, Gargoyles, Eulen und sogar der Glutlindwurm brauchen hier Platz.
|
best.b.aerie.text = Sitzstangen und Nester für Flieger. Fledermäuse, Gargoyles, Eulen und sogar der Glutlindwurm brauchen hier Platz.
|
||||||
best.b.bridge.text = Holz über Wasser, Stein über Lava. Die Ritter reißen deine Brücken ein - nimm dir ihre.
|
best.b.bridge.text = Holz über Wasser, Stein über Lava. Die Ritter reißen deine Brücken ein - nimm dir ihre.
|
||||||
|
best.b.drawbridge.text = Hochgezogen ist der Fluss wieder eine Mauer. Auf Automatik hebt sie sich, sobald Feinde nahen – und senkt sich für deine eigenen Kreaturen.
|
||||||
|
best.b.wall.text = Ein gegrabener Gang ist nicht für immer: Mauere ihn wieder zu und lass den Helden nur einen Weg hinein – durch deine Tür.
|
||||||
best.b.steel.text = Genietete Eisenplatten auf einer verstärkten Mauer. Da gräbt sich keiner schnell durch, nicht einmal Zwerge.
|
best.b.steel.text = Genietete Eisenplatten auf einer verstärkten Mauer. Da gräbt sich keiner schnell durch, nicht einmal Zwerge.
|
||||||
best.b.arrow.text = Der Pfeil durchbohrt bis zu zwei weitere Feinde hinter dem Ziel. Billig und zuverlässig.
|
best.b.arrow.text = Der Pfeil durchbohrt bis zu zwei weitere Feinde hinter dem Ziel. Billig und zuverlässig.
|
||||||
best.b.cannon.text = Flächenschaden und Rückstoß - schieb die Ritter in die Lava.
|
best.b.cannon.text = Flächenschaden und Rückstoß - schieb die Ritter in die Lava.
|
||||||
@@ -804,7 +898,7 @@ room.forge = Schmiede
|
|||||||
tool.forge.label = Schmiede
|
tool.forge.label = Schmiede
|
||||||
tool.forge.desc = Kreaturen fertigen hier Fallen und Türen - Arbeit, kein Gold. Fertige Stücke warten in Kisten, bis ein Imp sie einbaut. Coglinge kommen für eine Schmiede ab 6 Feldern.
|
tool.forge.desc = Kreaturen fertigen hier Fallen und Türen - Arbeit, kein Gold. Fertige Stücke warten in Kisten, bis ein Imp sie einbaut. Coglinge kommen für eine Schmiede ab 6 Feldern.
|
||||||
unit.cogling = Cogling
|
unit.cogling = Cogling
|
||||||
cat.traps = Fallen
|
cat.traps = Bauen
|
||||||
cost.work = {0} Arbeit in der Schmiede
|
cost.work = {0} Arbeit in der Schmiede
|
||||||
job.forge = Schmiedet
|
job.forge = Schmiedet
|
||||||
job.install = Baut eine Falle ein
|
job.install = Baut eine Falle ein
|
||||||
@@ -881,6 +975,7 @@ research.now = Erforscht: {0} ({1}%)
|
|||||||
research.next = danach: {0}
|
research.next = danach: {0}
|
||||||
research.done = Hier gibt es nichts mehr zu erforschen.
|
research.done = Hier gibt es nichts mehr zu erforschen.
|
||||||
msg.researched = Erforscht: {0}! Jetzt im Baumenü.
|
msg.researched = Erforscht: {0}! Jetzt im Baumenü.
|
||||||
|
msg.room_upgraded = Forschung: {0} erreicht Stufe {1} – arbeitet ein Viertel besser, und man sieht es.
|
||||||
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 = {0}
|
||||||
@@ -918,6 +1013,9 @@ tool.plague.desc = Eine Krankheit, die an den Feinden rund um die Stelle zehrt u
|
|||||||
tool.quake.label = Beben
|
tool.quake.label = Beben
|
||||||
tool.quake.name = Beben
|
tool.quake.name = Beben
|
||||||
tool.quake.desc = Der Boden bebt: Feinde rund um die Stelle werden verletzt und zwei Sekunden betäubt. Deine Kreaturen bleiben standfest.
|
tool.quake.desc = Der Boden bebt: Feinde rund um die Stelle werden verletzt und zwei Sekunden betäubt. Deine Kreaturen bleiben standfest.
|
||||||
|
tool.freeze.label = Frost
|
||||||
|
tool.freeze.name = Frost
|
||||||
|
tool.freeze.desc = Eisige Kälte: Lava rund um die Stelle erkaltet für 40 Sekunden zu begehbarem Obsidian, Feinde werden unterkühlt und verlangsamt.
|
||||||
tile.unexplored = Unerforschte Finsternis
|
tile.unexplored = Unerforschte Finsternis
|
||||||
|
|
||||||
# personality
|
# personality
|
||||||
@@ -970,6 +1068,9 @@ tool.arena.desc = Kreaturen kämpfen paarweise zur Übung: doppelt so viel Erfah
|
|||||||
tool.altar.label = Altar
|
tool.altar.label = Altar
|
||||||
tool.altar.desc = Lass eine Kreatur auf den Altar fallen, um sie zu opfern. Bestimmte Opferpaare bringen eine seltene Kreatur, sonst wird das Verlies gesegnet: bessere Laune und Mana.
|
tool.altar.desc = Lass eine Kreatur auf den Altar fallen, um sie zu opfern. Bestimmte Opferpaare bringen eine seltene Kreatur, sonst wird das Verlies gesegnet: bessere Laune und Mana.
|
||||||
unit.revenant = Wiedergänger
|
unit.revenant = Wiedergänger
|
||||||
|
unit.cavebear = Höhlenbär
|
||||||
|
unit.beetle = Riesenkäfer
|
||||||
|
unit.trader = Händler
|
||||||
job.guard_post = Hält Wache
|
job.guard_post = Hält Wache
|
||||||
job.spar = Übungskampf
|
job.spar = Übungskampf
|
||||||
msg.revenant_rises = Ein Wiedergänger erhebt sich vom Friedhof und dient dir!
|
msg.revenant_rises = Ein Wiedergänger erhebt sich vom Friedhof und dient dir!
|
||||||
@@ -978,6 +1079,10 @@ msg.altar_gift = Der Altar nimmt das Paar an: ein {0} erscheint!
|
|||||||
msg.altar_blessing = {0} wird geopfert. Das Verlies ist gesegnet.
|
msg.altar_blessing = {0} wird geopfert. Das Verlies ist gesegnet.
|
||||||
best.revenant.lure = erhebt sich vom Friedhof
|
best.revenant.lure = erhebt sich vom Friedhof
|
||||||
best.revenant.text = Ein Wiedergänger isst nicht, schläft nicht und beschwert sich nicht - der perfekte Angestellte, wenn man den Geruch aushält. | Mit etwas Erfahrung trinkt er die Kraft derer, die er trifft. Mit der Priesterin versteht er sich aus naheliegenden Gründen nicht.
|
best.revenant.text = Ein Wiedergänger isst nicht, schläft nicht und beschwert sich nicht - der perfekte Angestellte, wenn man den Geruch aushält. | Mit etwas Erfahrung trinkt er die Kraft derer, die er trifft. Mit der Priesterin versteht er sich aus naheliegenden Gründen nicht.
|
||||||
|
best.cavebear.lure = in seinem Bau besiegen, solange du ein Gefängnis hast
|
||||||
|
best.cavebear.text = Ein Bär, der nie die Sonne gesehen hat und sie nicht vermisst. Zottelig, stark und bis zum Frühstück schlecht gelaunt. | Besiege ihn in seinem Bau, solange ein Gefängnis Platz hat, und er ergibt sich und schließt sich dir an. Er mag den Pilzgarten – und Spinnen, als Snack.
|
||||||
|
best.beetle.lure = in seinem Bau besiegen, solange du ein Gefängnis hast
|
||||||
|
best.beetle.text = Ein Käfer so groß wie ein Fass, gepanzert wie ein Ritter und doppelt so höflich. | Besiege ihn in seinem Bau, solange ein Gefängnis Platz hat, und er schließt sich dir an. Er schläft im Lager und kann Fledermäuse nicht ausstehen.
|
||||||
best.b.prison.text = Zellenreihen. Helden verrotten hier in Ruhe, bis jemand entscheidet, was mit ihnen geschieht.
|
best.b.prison.text = Zellenreihen. Helden verrotten hier in Ruhe, bis jemand entscheidet, was mit ihnen geschieht.
|
||||||
best.b.graveyard.text = Moos, Steine und frische Erde. Hier unten bleibt niemand lange tot.
|
best.b.graveyard.text = Moos, Steine und frische Erde. Hier unten bleibt niemand lange tot.
|
||||||
best.b.guardpost.text = Ein Feuerbecken, ein Banner und eine gelangweilte Kreatur mit scharfen Augen.
|
best.b.guardpost.text = Ein Feuerbecken, ein Banner und eine gelangweilte Kreatur mit scharfen Augen.
|
||||||
@@ -990,23 +1095,39 @@ ability.drain.desc = heilt sich um ein Drittel des Schadens, den er austeilt.
|
|||||||
unit.giant = Riese
|
unit.giant = Riese
|
||||||
unit.fairy = Feenspion
|
unit.fairy = Feenspion
|
||||||
unit.battlemage = Kampfmagier
|
unit.battlemage = Kampfmagier
|
||||||
|
unit.thief = Dieb
|
||||||
|
unit.monk = Mönch
|
||||||
|
unit.ranger = Waldläufer
|
||||||
|
unit.inquisitor = Inquisitor
|
||||||
|
unit.ram = Rammbock
|
||||||
|
unit.catapult = Katapult
|
||||||
msg.trap_wrecked = Die Helden haben eine Falle zerstört: {0}.
|
msg.trap_wrecked = Die Helden haben eine Falle zerstört: {0}.
|
||||||
best.giant.text = Ein Riese ist, was passiert, wenn ein Ritter dreihundert Jahre lang sein Gemüse isst. Langsam, gewaltig, und Türen hält er für einen Vorschlag. | Fallen zertrümmert er, indem er einfach danebensteht. Widerliche Manieren. Bearbeite ihn mit allem, am besten aus der Ferne.
|
best.giant.text = Ein Riese ist, was passiert, wenn ein Ritter dreihundert Jahre lang sein Gemüse isst. Langsam, gewaltig, und Türen hält er für einen Vorschlag. | Fallen zertrümmert er, indem er einfach danebensteht. Widerliche Manieren. Bearbeite ihn mit allem, am besten aus der Ferne.
|
||||||
best.fairy.text = Der kleine Spitzel des Ordens: eine leuchtende Mücke mit Flügeln, vorausgeschickt, um deine Fallen zu finden und es allen zu erzählen. Hat sie eine Falle gesehen, schlagen die Helden sie kaputt. | Außerdem streut sie Heilstaub auf die Helden. Erschlag sie zuerst. Zweimal, sicherheitshalber.
|
best.fairy.text = Der kleine Spitzel des Ordens: eine leuchtende Mücke mit Flügeln, vorausgeschickt, um deine Fallen zu finden und es allen zu erzählen. Hat sie eine Falle gesehen, schlagen die Helden sie kaputt. | Außerdem streut sie Heilstaub auf die Helden. Erschlag sie zuerst. Zweimal, sicherheitshalber.
|
||||||
best.battlemage.text = Ein Magier in der Robe des Ordens, der Mauern als persönliche Beleidigung empfindet. Verstärkte Wände zerplatzen unter seinen Feuerbällen, und er hört nicht auf, davon zu reden. | Aus der Nähe zerbrechlich. Stahlwände ärgern ihn am meisten - gut so.
|
best.battlemage.text = Ein Magier in der Robe des Ordens, der Mauern als persönliche Beleidigung empfindet. Verstärkte Wände zerplatzen unter seinen Feuerbällen, und er hört nicht auf, davon zu reden. | Aus der Nähe zerbrechlich. Stahlwände ärgern ihn am meisten - gut so.
|
||||||
|
best.thief.text = Flinke Finger, noch flinkere Füße. Er kommt nicht zum Kämpfen: Er schleicht in deine Schatzkammer, füllt seinen Sack und rennt zurück zum Tor. | Erwisch ihn, bevor er entkommt, und das Gold gehört wieder dir - mit Zinsen.
|
||||||
|
best.monk.text = Jahre des Fastens und Betens haben ihn taub für deine Magie gemacht: Blitz, Seuche und Frost perlen an ihm ab wie Regen am Dach. | Fäuste tun auch weh. Begegne ihm mit Stahl, Klauen und Zähnen.
|
||||||
|
best.ranger.text = Ein Waldmann des Ordens, der aus der Ferne schießt und eiserne Fallen in deinen Gängen auslegt. Wer hineintritt, wird verletzt und steckt eine Weile fest. | Die Fallen bleiben, bis jemand hineintritt. Lieber ein Kobold als dein Oger.
|
||||||
|
best.inquisitor.text = Gebrechlich, aber seine Predigten sind gefährlich: Alle zwanzig Sekunden redet er eine deiner schwächsten Kreaturen - einen Kobold oder eine Spinne - auf die Seite der Helden. | Starke Kreaturen lachen ihn nur aus. Schlag zuerst zu.
|
||||||
|
best.ram.text = Ein Baumstamm mit Eisenkappe und einem Dach gegen Pfeile. Deine Kreaturen sind ihm egal – nur deine Türen, Wände und Türme nicht. | Halte ihn auf, bevor er die Tür erreicht, oder besorg dir eine neue.
|
||||||
|
best.catapult.text = Ein Holzarm, ein Eimer Steine und ein Ritter mit zu viel Zeit. Es schießt weiter als deine Türme und nimmt sie zuerst ins Visier. | Stürm drauf los: aus der Nähe tut es keiner Fliege etwas.
|
||||||
|
|
||||||
# orders
|
# orders
|
||||||
tool.order_attack.label = Angriff
|
tool.order_attack.label = Angriff
|
||||||
tool.order_attack.name = Angriff!
|
tool.order_attack.name = Angriff!
|
||||||
tool.order_attack.desc = Klicke einen Feind oder einen feindlichen Turm an: Alle kampfbereiten Kreaturen im Umkreis von 18 Feldern gehen darauf los. Rechtsklick mit einem Befehlswerkzeug hebt die Wachbefehle auf.
|
tool.order_attack.desc = Klicke einen Feind oder einen feindlichen Turm an: Alle kampfbereiten Kreaturen im Umkreis von 18 Feldern gehen darauf los. Klicke freien Boden an: ein Schwert-Marker – die kampfbereiten Kreaturen verteilen sich auf alle Schwerter und kämpfen dort. Rechtsklick auf einen Marker entfernt ihn.
|
||||||
tool.order_guard.label = Bewachen
|
tool.order_guard.label = Bewachen
|
||||||
tool.order_guard.name = Raum bewachen
|
tool.order_guard.name = Raum bewachen
|
||||||
tool.order_guard.desc = Klicke einen Raum an: Bis zu drei Kreaturen in der Nähe beziehen dort für zwei Minuten Stellung und greifen alles an, was in Sicht kommt. Rechtsklick hebt auf.
|
tool.order_guard.desc = Klicke deinen eigenen Boden an: ein Schild-Marker, den drei Kreaturen halten und verteidigen. Bis zu acht Schilde; Rechtsklick auf einen Schild entfernt ihn.
|
||||||
hint.order_attack = Klicke einen Feind oder einen feindlichen Turm an.
|
hint.order_attack = Klicke einen Feind oder einen feindlichen Turm an.
|
||||||
hint.order_guard = Klicke einen deiner Räume (oder deinen Boden) an.
|
hint.order_guard = Klicke einen deiner Räume (oder deinen Boden) an.
|
||||||
hint.order_nobody = Keine Kreatur ist für diesen Befehl frei.
|
hint.order_nobody = Keine Kreatur ist für diesen Befehl frei.
|
||||||
msg.order_attack = {0} Kreaturen greifen an!
|
msg.order_attack = {0} Kreaturen greifen an!
|
||||||
msg.order_guard = {0} Kreaturen beziehen Stellung.
|
msg.order_guard = {0} Kreaturen beziehen Stellung.
|
||||||
|
msg.mark_attack = Ein Schwert markiert die Stelle: Deine kampfbereiten Kreaturen marschieren hin.
|
||||||
|
msg.mark_guard = Ein Schild markiert die Stelle: Drei Kreaturen halten sie.
|
||||||
|
msg.mark_removed = Marker entfernt – seine Kreaturen sind wieder frei.
|
||||||
|
hint.marks_full = Mehr als acht Marker dieser Art gehen nicht. Entferne einen mit Rechtsklick.
|
||||||
msg.orders_cancelled = Wachbefehle aufgehoben.
|
msg.orders_cancelled = Wachbefehle aufgehoben.
|
||||||
|
|
||||||
# creature overview
|
# creature overview
|
||||||
@@ -1016,6 +1137,8 @@ ov.summary = {0} Kreaturen - Lohn {1} Gold pro Zahltag - Laune {2}%
|
|||||||
ov.none = Noch keine Kreaturen. Baue ein Quartier und einen Pilzgarten und nimm ein Portal ein.
|
ov.none = Noch keine Kreaturen. Baue ein Quartier und einen Pilzgarten und nimm ein Portal ein.
|
||||||
ov.hint = St., Laune, Lohn, Tätigkeit, Leben - Klick: hingehen
|
ov.hint = St., Laune, Lohn, Tätigkeit, Leben - Klick: hingehen
|
||||||
set.voice = Krummfingers Stimme
|
set.voice = Krummfingers Stimme
|
||||||
|
set.track = Musikstück
|
||||||
|
val.track_auto = Automatisch
|
||||||
|
|
||||||
# ---- campaign editor, block scripts, demo campaign
|
# ---- campaign editor, block scripts, demo campaign
|
||||||
menu.back_editor = Zurück zum Editor
|
menu.back_editor = Zurück zum Editor
|
||||||
@@ -1088,6 +1211,7 @@ ed.forts = Ritterfestungen
|
|||||||
ed.rivals = Rivalen-Meister
|
ed.rivals = Rivalen-Meister
|
||||||
ed.difficulty = Schwierigkeit
|
ed.difficulty = Schwierigkeit
|
||||||
ed.portals = Portale
|
ed.portals = Portale
|
||||||
|
ed.surface = Oberwelt (Osten)
|
||||||
ed.edit_map = Karte bearbeiten
|
ed.edit_map = Karte bearbeiten
|
||||||
ed.make_random = Zur Zufallskarte machen
|
ed.make_random = Zur Zufallskarte machen
|
||||||
ed.paint_map = Karte erzeugen und malen
|
ed.paint_map = Karte erzeugen und malen
|
||||||
@@ -1167,6 +1291,10 @@ ed.terrain.silver = Silberader
|
|||||||
ed.terrain.ruby = Rubinader
|
ed.terrain.ruby = Rubinader
|
||||||
ed.terrain.diamond = Diamantader
|
ed.terrain.diamond = Diamantader
|
||||||
ed.terrain.steel = Stahlwand
|
ed.terrain.steel = Stahlwand
|
||||||
|
ed.terrain.mushroom = Riesenpilz
|
||||||
|
ed.terrain.grove = Pilzhain
|
||||||
|
ed.terrain.grass = Gras (Oberwelt)
|
||||||
|
ed.terrain.tree = Baum
|
||||||
ed.place.core = Seelenkern
|
ed.place.core = Seelenkern
|
||||||
ed.place.portal = Portal
|
ed.place.portal = Portal
|
||||||
ed.place.gate = Heldentor
|
ed.place.gate = Heldentor
|
||||||
@@ -1197,6 +1325,7 @@ ed.level = Stufe
|
|||||||
ed.zone_shape = Form einer neuen Zone
|
ed.zone_shape = Form einer neuen Zone
|
||||||
ed.zone_new_name = Name einer neuen Zone
|
ed.zone_new_name = Name einer neuen Zone
|
||||||
ed.zones = Zonen
|
ed.zones = Zonen
|
||||||
|
ed.zone_erase = Kacheln entfernen
|
||||||
ed.shape.rect = Rechteck
|
ed.shape.rect = Rechteck
|
||||||
ed.shape.circle = Kreis
|
ed.shape.circle = Kreis
|
||||||
ed.shape.tiles = Kacheln
|
ed.shape.tiles = Kacheln
|
||||||
@@ -1246,6 +1375,8 @@ blk.reveal = Aufdecken
|
|||||||
blk.camera = Zeigen
|
blk.camera = Zeigen
|
||||||
blk.gold = Gold +
|
blk.gold = Gold +
|
||||||
blk.mana = Mana +
|
blk.mana = Mana +
|
||||||
|
blk.weather = Wetter über der Erde
|
||||||
|
blk.talentxp = Talent-Erfahrung +
|
||||||
blk.if = Wenn
|
blk.if = Wenn
|
||||||
blk.repeat = Wiederhole
|
blk.repeat = Wiederhole
|
||||||
blk.trigger = Trigger schalten
|
blk.trigger = Trigger schalten
|
||||||
@@ -1265,6 +1396,8 @@ blk.help.reveal = Hebt den Nebel über einer Zone.
|
|||||||
blk.help.camera = Bewegt die Kamera zu einer Zone.
|
blk.help.camera = Bewegt die Kamera zu einer Zone.
|
||||||
blk.help.gold = Gibt (oder nimmt) Gold.
|
blk.help.gold = Gibt (oder nimmt) Gold.
|
||||||
blk.help.mana = Gibt (oder nimmt) Mana.
|
blk.help.mana = Gibt (oder nimmt) Mana.
|
||||||
|
blk.help.weather = Setzt das Wetter über der Erde (heiter, Regen, Nebel oder Sturm) für so viele Sekunden; 0 hält es, bis ein anderer Block es ändert.
|
||||||
|
blk.help.talentxp = Gibt dem Spieler Erfahrung für die Meister-Talente.
|
||||||
blk.help.if = Führt die Blöcke darin nur aus, wenn die Bedingung gilt, sonst die unter "sonst".
|
blk.help.if = Führt die Blöcke darin nur aus, wenn die Bedingung gilt, sonst die unter "sonst".
|
||||||
blk.help.repeat = Führt die Blöcke darin mehrmals aus.
|
blk.help.repeat = Führt die Blöcke darin mehrmals aus.
|
||||||
blk.help.trigger = Schaltet einen Trigger ein (er darf erneut auslösen) oder aus.
|
blk.help.trigger = Schaltet einen Trigger ein (er darf erneut auslösen) oder aus.
|
||||||
@@ -1287,6 +1420,7 @@ blk.v.or = oder
|
|||||||
blk.v.not = nicht
|
blk.v.not = nicht
|
||||||
blk.v.bool = Ja/Nein
|
blk.v.bool = Ja/Nein
|
||||||
blk.v.eventkind = Auslöser ist ein
|
blk.v.eventkind = Auslöser ist ein
|
||||||
|
blk.v.weatheris = Wetter über der Erde ist
|
||||||
blk.w_at = bei
|
blk.w_at = bei
|
||||||
blk.w_by = um
|
blk.w_by = um
|
||||||
blk.w_in = in
|
blk.w_in = in
|
||||||
@@ -1335,6 +1469,301 @@ blk.until = Wiederhole bis
|
|||||||
blk.dountil = Wiederhole, prüfe danach: bis
|
blk.dountil = Wiederhole, prüfe danach: bis
|
||||||
blk.wait = Warte
|
blk.wait = Warte
|
||||||
blk.w_seconds = Sekunden
|
blk.w_seconds = Sekunden
|
||||||
|
blk.w_for = für
|
||||||
blk.help.until = Führt die Blöcke darin immer wieder aus, bis die Bedingung gilt - geprüft vor jedem Durchlauf; gilt sie schon, läuft der Inhalt gar nicht.
|
blk.help.until = Führt die Blöcke darin immer wieder aus, bis die Bedingung gilt - geprüft vor jedem Durchlauf; gilt sie schon, läuft der Inhalt gar nicht.
|
||||||
blk.help.dountil = Führt die Blöcke darin aus, prüft dann die Bedingung und wiederholt, bis sie gilt - der Inhalt läuft mindestens einmal.
|
blk.help.dountil = Führt die Blöcke darin aus, prüft dann die Bedingung und wiederholt, bis sie gilt - der Inhalt läuft mindestens einmal.
|
||||||
blk.help.wait = Hält dieses Skript einige Sekunden an, danach geht es mit dem nächsten Block weiter (auch in Schleifen).
|
blk.help.wait = Hält dieses Skript einige Sekunden an, danach geht es mit dem nächsten Block weiter (auch in Schleifen).
|
||||||
|
msg.master_out = {0} hat den Seelenkern verloren - die Kreaturen kämpfen ohne Herz weiter.
|
||||||
|
msg.master_out_you = Dein Seelenkern ist zerbrochen. Du bist ausgeschieden - sieh dem Rest der Partie zu.
|
||||||
|
msg.match_won = Dein Team hat die Partie gewonnen!
|
||||||
|
msg.match_lost = Die Partie ist verloren - ein anderes Team hat gewonnen.
|
||||||
|
msg.gold_received = {1} schickt dir {0} Gold.
|
||||||
|
msg.gold_sent = {0} Gold an {1} geschickt.
|
||||||
|
msg.gold_none = Du hast kein Gold zum Verschicken.
|
||||||
|
msg.gold_full = Die Schatzkammer von {0} ist voll.
|
||||||
|
gold.send_to = Gold an {0}
|
||||||
|
gold.all = Alles
|
||||||
|
net.spectating = Dein Herz ist gefallen - du schaust der Partie zu (Chat mit Enter).
|
||||||
|
net.team = Team {0}
|
||||||
|
net.versus = Gegeneinander
|
||||||
|
net.coop_wait_host = Warte, bis der Gastgeber die nächste Mission startet...
|
||||||
|
net.coop_run_over = Zurück in die Lobby
|
||||||
|
|
||||||
|
# ---- co-op campaign "League against the Lightwatch"
|
||||||
|
coop.name = Bund gegen die Lichtwacht
|
||||||
|
coop.raid = Mossgrims Kreaturen kriechen aus der Dunkelheit!
|
||||||
|
coop.m01.title = Zwei Herzen, ein Krummfinger
|
||||||
|
coop.m01.story = Meister, Mehrzahl. Ich konnte es auch kaum glauben. Der Orden der Lichtwacht hat einen Pakt mit Mossgrim geschlossen, diesem moosigen Überläufer, um jeden Kerker unter den Hügeln auszuräuchern.\n\nAllein würdet ihr geräuchert. Zusammen seid ihr vielleicht der Rauch. Grabt euch Seite an Seite ein und zeigt der Patrouille nebenan, was ein gemeinsamer Keller bedeutet.
|
||||||
|
coop.m01.start = Zwei Meister, ein Berater. Mein Lohn bleibt übrigens gleich.
|
||||||
|
coop.m01.goal = Besiegt alle Ritter.
|
||||||
|
coop.m01.t1 = Teilt euch die Arbeit: einer gräbt, einer baut. Oder beide graben. Ich bin flexibel.
|
||||||
|
coop.m01.t2 = Die Patrouille wird neugierig. Gut. Neugier ist ein feiner Happen.
|
||||||
|
coop.m02.title = Der erste Posten
|
||||||
|
coop.m02.story = Jenseits des Flusses hat die Lichtwacht einen Posten mit glänzenden Türmen gebaut. Glänzendes muss kaputt - das ist die älteste Regel der Unterwelt.\n\nÜberbrückt das Wasser, reißt die Türme ein, und das gemeinsam, bevor sie ihre Freunde rufen.
|
||||||
|
coop.m02.start = Türme. Poliert, spitz, beleidigend. Das richten wir.
|
||||||
|
coop.m02.goal = Schleift die Rittertürme.
|
||||||
|
coop.m02.t1 = Brücken sind wie Freundschaften, Meister: erst bauen, dann eine Armee drüberschicken.
|
||||||
|
coop.m02.t2 = Ein Turm weg ist ein Anfang. Alle Türme weg ist ein Fest.
|
||||||
|
coop.m03.title = Spuren im Moos
|
||||||
|
coop.m03.story = Etwas Grünes, Nasses lauert in den Tunneln. Mossgrim hat den Rittern seine Kreaturen geliehen, und sie kommen in Wellen durchs Heldentor.\n\nHaltet durch. Haltet zusammen. Haltet euch die Nase zu.
|
||||||
|
coop.m03.start = Mossgrims Oger riechen nach nassem Teppich. Ihr merkt es, wenn sie kommen.
|
||||||
|
coop.m03.goal = Übersteht drei Wellen.
|
||||||
|
coop.m03.t1 = Die ersten Oger sind da. Mossgrim konnte seine Haustiere noch nie zu Hause halten.
|
||||||
|
coop.m03.t2 = Haltet eure Herzen auseinander, Meister - kein Oger sollte beide erreichen.
|
||||||
|
coop.m04.title = Die gestohlenen Quellen
|
||||||
|
coop.m04.story = Zwei Manaquellen sprudeln auf Inseln im See, und die Ritter haben sie zu ihrem Weihwasser erklärt. Weihwasser, also wirklich.\n\nNehmt beide Quellen. Und fällt euch vorher der Tresor der Ritter in die Hände, kennt Krummfinger eine Abkürzung.
|
||||||
|
coop.m04.start = Mana schmeckt besser, wenn es vorher jemand anderem gehört hat.
|
||||||
|
coop.m04.goal = Nehmt zwei Manaquellen.
|
||||||
|
coop.m04.branch = Reich genug für die Abkürzung
|
||||||
|
coop.m04.t1 = Inseln brauchen Brücken. Oder Flügel. Fledermäuse sind Flügel mit Zähnen.
|
||||||
|
coop.m04.t2 = Der Tresor sieht schwer aus. Schweres sollte man nach Hause tragen.
|
||||||
|
coop.m05.title = Ketten im Fels
|
||||||
|
coop.m05.story = Die Lichtwacht hält Kreaturen in Ketten, in versiegelten Höhlen - Kreaturen, die sich nicht beugen wollten. Ein Oger und ein Magier, beide wütend, beide zu haben.\n\nGrabt zu ihnen, erobert ihre Höhlen, und die Ketten fallen.
|
||||||
|
coop.m05.start = Gefangene! Bald unsere. Befreite, meine ich. Frei, für euch zu arbeiten.
|
||||||
|
coop.m05.goal = Befreit zwei angekettete Kreaturen.
|
||||||
|
coop.m05.t1 = Hört ihr den Oger fluchen? Das ist Dankbarkeit im Voraus.
|
||||||
|
coop.m05.t2 = Wieder Spinnen aus dem Moos. Mossgrims Kinderstube hat Hochbetrieb.
|
||||||
|
coop.m06.title = Brücke der Wacht
|
||||||
|
coop.m06.story = Zwei Flüsse, eine Festung und die besten Schützen der Lichtwacht auf den Mauern. Sie nennen es die Brücke der Wacht. Wir nennen es die Brücke, die mal war.\n\nBrecht ihre Türme. Lasst die Brücke stehen - wir brauchen sie vielleicht.
|
||||||
|
coop.m06.start = Schützen auf Mauern sind wie Tauben auf Dächern: laut und leicht zu entfernen.
|
||||||
|
coop.m06.goal = Schleift die Rittertürme.
|
||||||
|
coop.m06.t1 = Zwei Flüsse heißt zwei Brücken. Oder eine sehr nasse Armee.
|
||||||
|
coop.m06.t2 = Die Ritter polieren ihre Rüstungen. Geben wir ihnen einen Grund.
|
||||||
|
coop.m07.title = Die Lavaschmiede
|
||||||
|
coop.m07.story = Zwischen zwei Lavaflüssen schmieden die Ritter ihre gesegneten Klingen. Ihr Tresor quillt über vor Gold, bezahlt von jedem Kerker, den sie geleert haben.\n\nWir holen es uns zurück. Alles. Und ein bisschen mehr.
|
||||||
|
coop.m07.start = Warme Füße, kalte Herzen, volle Taschen: mein Abend.
|
||||||
|
coop.m07.goal = Erbeutet Gold von den Rittern.
|
||||||
|
coop.m07.t1 = Nicht zu nah an die Lava. Eure Kreaturen, meine ich. Ihr seid sicher.
|
||||||
|
coop.m07.t2 = Mehr Ritter aus dem Osten. Die lieben ihr Gold wirklich.
|
||||||
|
coop.m08.title = Das Lazarett
|
||||||
|
coop.m08.story = Die Lichtwacht hat in den Höhlen ein Lazarett errichtet, und ihre Priester flicken jeden Ritter, den ihr umhaut.\n\nBeendet die Behandlung. Endgültig.
|
||||||
|
coop.m08.start = Priester. Sie heilen. Wir tun das Gegenteil. Eine ausgeglichene Beziehung.
|
||||||
|
coop.m08.goal = Besiegt alle Ritter.
|
||||||
|
coop.m08.t1 = Jede Minute kommt ein neuer Priester. Schneller, Meister.
|
||||||
|
coop.m08.t2 = Ein Kerker ohne Arztrechnungen. Das nenne ich wirtschaftlich.
|
||||||
|
coop.m09.title = Mossgrims Botschafter
|
||||||
|
coop.m09.story = Mossgrim hat den Rittern einen Botschafter geschickt: einen Paladin in grünfleckiger Rüstung, der die Versprechen seines Herrn überbringt.\n\nVersprechen sollten nie ankommen. Findet den Botschafter und bringt ihn zum Schweigen.
|
||||||
|
coop.m09.start = Ein Botschafter. Ein Bote mit schickem Hut. Hüte fallen.
|
||||||
|
coop.m09.goal = Besiegt Mossgrims Botschafter.
|
||||||
|
coop.m09.boss = Der Botschafter ist gefallen. Seine Botschaft: unzustellbar.
|
||||||
|
coop.m09.t1 = Der Botschafter wartet im Osten, umgeben von Ogern ohne Humor.
|
||||||
|
coop.m09.t2 = Irgendwo schreibt Mossgrim schon den nächsten Brief. Machen wir ihn kurz.
|
||||||
|
coop.m10.title = Die Zwillingstürme
|
||||||
|
coop.m10.story = Zwei Festungen bewachen den Weg nach Norden, jede deckt der anderen den Rücken. Greift eine an, antwortet die andere.\n\nGreift beide an. Gleichzeitig. Dafür gibt es zwei Meister.
|
||||||
|
coop.m10.start = Zwei Festungen, zwei Meister. Gerecht ist gerecht.
|
||||||
|
coop.m10.goal = Schleift die Rittertürme.
|
||||||
|
coop.m10.t1 = Teilt euch auf, Meister - jedem eine Festung. Ich jubele für beide.
|
||||||
|
coop.m10.t2 = Fallen beide zugleich, wissen die Ritter nicht einmal, wem sie die Schuld geben sollen.
|
||||||
|
coop.m11.title = Die Belagerung
|
||||||
|
coop.m11.story = Die Lichtwacht hat eure Herzen gefunden und bringt alles: fünf Wellen Ritter und Mossgrims Wasserspeier von den Klippen.\n\nDiesmal keine Raffinesse. Nur Mauern, Fallen, Türme - und einander.
|
||||||
|
coop.m11.start = Sie kommen. Alle. Na ja, die meisten.
|
||||||
|
coop.m11.goal = Übersteht fünf Wellen.
|
||||||
|
coop.m11.t1 = Türen und Fallen, Meister. Eine Belagerung sind nur Gäste, die nicht klingeln wollen.
|
||||||
|
coop.m11.t2 = Wasserspeier aus dem Süden. Stein mit Flügeln - das hat niemand bestellt.
|
||||||
|
coop.m12.title = Das Tor
|
||||||
|
coop.m12.story = Durch das Heldentor dieser Höhle erreichen die Ritter die tiefen Kerker. Schließt es, und ihr Nachschub muss den langen Weg nehmen.\n\nBrecht das Tor, und die Lichtwacht spürt es bis hinauf zum Hochmarschall.
|
||||||
|
coop.m12.start = Ein Tor. Tore sind nur Mauern mit Ambitionen.
|
||||||
|
coop.m12.goal = Zerstört das Heldentor.
|
||||||
|
coop.m12.t1 = Es kommen immer neue Wellen heraus. Lästig, wie ein tropfender Hahn.
|
||||||
|
coop.m12.t2 = Drückt, Meister! Das Tor bekommt Risse. Meine Geduld auch.
|
||||||
|
coop.m13.title = Moosige Tunnel
|
||||||
|
coop.m13.story = Wir sind jetzt in Mossgrims Hinterhof. Seine Tunnel sind weich und feucht, und zwei seiner Horte liegen in versiegelten Höhlen.\n\nGraben, nehmen, gehen. Mossgrim wird es merken. Genau darum geht es.
|
||||||
|
coop.m13.start = Es riecht nach Pilzen und Verrat. Mossgrim muss nah sein.
|
||||||
|
coop.m13.goal = Erbeutet Gold.
|
||||||
|
coop.m13.t1 = Spinnen, schon wieder. Mossgrim züchtet sie wie andere Leute Tomaten.
|
||||||
|
coop.m13.t2 = Seine Horte sind größer als gedacht. Unsere Taschen sind noch größer.
|
||||||
|
coop.m14.title = Gift im Wasser
|
||||||
|
coop.m14.story = Mossgrim hat seine Toten zu Wiedergängern gemacht und schickt sie von zwei Seiten. Die Ritter nennen das heiliges Werk. Typisch.\n\nBesiegt jeden Ritter und jede wandelnde Leiche. Danach Hände waschen.
|
||||||
|
coop.m14.start = Wiedergänger. Unser Trick, geklaut. Ich bin gekränkt.
|
||||||
|
coop.m14.goal = Besiegt alle Feinde.
|
||||||
|
coop.m14.t1 = Sie kommen von Norden und Süden. Rücken an Rücken, Meister.
|
||||||
|
coop.m14.t2 = Ein Toter, der läuft, ist ein Toter, der seine Arbeit noch nicht erledigt hat.
|
||||||
|
coop.m15.title = Der Kettenbrecher
|
||||||
|
coop.m15.story = Tief im Fels halten die Ritter den Kettenbrecher, eine Kreatur, die selbst sie fürchten. Befreit ihn, und er kämpft für den, der seine Zelle geöffnet hat.\n\nOder schnappt euch genug Gold für den Umweg - Krummfinger kennt einen Schmuggler.
|
||||||
|
coop.m15.start = Der Kettenbrecher. Groß, wütend, missverstanden. Ein bisschen wie ich.
|
||||||
|
coop.m15.goal = Befreit den Kettenbrecher und schleift die Türme.
|
||||||
|
coop.m15.branch = Die Karte des Schmugglers gekauft
|
||||||
|
coop.m15.t1 = Die Zelle liegt im Nordwesten. Vorsicht, er hat Temperament.
|
||||||
|
coop.m15.t2 = Der Schmuggler wartet. Schmuggler hassen Warten. Und lieben Gold.
|
||||||
|
coop.m16.title = Die brennende Quelle
|
||||||
|
coop.m16.story = Mitten in einem Lavasee liegt eine Insel mit einer Manaquelle, die nie versiegt. Mossgrims Magier bewachen sie.\n\nNehmt die Quelle, besiegt die Wächter. Dann setzt euch auf die Insel und genießt die Wärme.
|
||||||
|
coop.m16.start = Eine Quelle in der Lava. Wer sich das ausgedacht hat, hat Stil. Schade, dass es Mossgrim ist.
|
||||||
|
coop.m16.goal = Nehmt die Quelle und besiegt alle Feinde.
|
||||||
|
coop.m16.t1 = Magier aus dem Osten. Ihre Zauber sind grün und ihre Zähne auch.
|
||||||
|
coop.m16.t2 = Gehört die Quelle uns, gibt es Mana für alle. Schön teilen.
|
||||||
|
coop.m17.title = Mossgrims Garten
|
||||||
|
coop.m17.story = Hier ist es: Mossgrims Garten, eine Höhle voller Pilze und Kreaturen, die ihm treu sind. Oger im Osten, ein Glutwurm im Norden.\n\nZertrampelt den Garten. Jeden Pilz.
|
||||||
|
coop.m17.start = All die Pilze. Schade um den Besitzer.
|
||||||
|
coop.m17.goal = Besiegt alle Feinde.
|
||||||
|
coop.m17.t1 = Ein Glutwurm! Dafür muss Mossgrim jahrelang gespart haben.
|
||||||
|
coop.m17.t2 = Der Garten welkt. Gärtnern ist nicht eure Stärke, Meister. Das ist ein Kompliment.
|
||||||
|
coop.m18.title = Die letzte Festung
|
||||||
|
coop.m18.story = Drei Festungen bewachen die Treppe hinab zur Halle des Hochmarschalls. Sie sind das Letzte zwischen euch und dem Ende des Kreuzzugs der Lichtwacht.\n\nSchleift sie alle. Dann gehört die Treppe euch.
|
||||||
|
coop.m18.start = Drei Festungen. Das Beste kommt zum Schluss, wie man sieht.
|
||||||
|
coop.m18.goal = Schleift die Rittertürme.
|
||||||
|
coop.m18.t1 = Jeder Meister eine Festung, die dritte gemeinsam. Ich zähle mit.
|
||||||
|
coop.m18.t2 = Ich sehe die Treppe schon. Staubig. Hier kommt niemand freiwillig herunter.
|
||||||
|
coop.m19.title = Der Hochmarschall
|
||||||
|
coop.m19.story = Der Hochmarschall der Lichtwacht persönlich, in einer seit hundert Jahren polierten Rüstung. Seine Garde kommt aus Norden und Süden.\n\nBringt ihn zu Fall, und der Orden hat niemanden mehr, der Befehle gibt.
|
||||||
|
coop.m19.start = Der Hochmarschall. Davon habe ich geträumt. Vor allem von dem Teil, wo er umfällt.
|
||||||
|
coop.m19.goal = Besiegt den Hochmarschall.
|
||||||
|
coop.m19.boss = Der Hochmarschall ist gefallen! Seine Rüstung scheppert wie eine umgekippte Besteckschublade.
|
||||||
|
coop.m19.t1 = Er wartet im Osten. Lasst ihn warten. Erst die Garde.
|
||||||
|
coop.m19.t2 = Wieder Ritter und Schützen. Dem Orden gehen die Ideen aus.
|
||||||
|
coop.m20.title = Mossgrims Fall
|
||||||
|
coop.m20.story = Mit dem Hochmarschall ist Mossgrim allein – oben im Wald, in der Sonne, ausgerechnet. Er hat die Gestalt eines Glutwurms angenommen und jede Kreatur versammelt, die noch auf ihn hört.\n\nSteigt aus dem Fels und beendet den Verräter. Beendet sein Heer. Und dann, Meister – Tee.
|
||||||
|
coop.m20.start = Mossgrim, du alter Pilz. Der Bund lässt grüßen.
|
||||||
|
coop.m20.goal = Besiegt Mossgrim und alle seine Kreaturen.
|
||||||
|
coop.m20.boss = Mossgrim ist nicht mehr. Das Moos wächst nach. Er nicht.
|
||||||
|
coop.m20.t1 = Der Wurm im Osten ist Mossgrim. Ja, er hat sich gehen lassen.
|
||||||
|
coop.m20.t2 = Jede Kreatur, die ihr besiegt, kann diese Geschichte nicht mehr falsch erzählen.
|
||||||
|
|
||||||
|
# achievements and statistics
|
||||||
|
menu.achievements = Erfolge
|
||||||
|
ach.count = {0} von {1} Erfolgen freigeschaltet · {2} Minuten gespielt
|
||||||
|
ach.hidden = Ein Geheimnis. Crookfinger verrät nichts.
|
||||||
|
msg.achievement = Erfolg freigeschaltet: {0}!
|
||||||
|
end.stats2 = Helden {0} · Kreaturen verloren {1} · Kacheln gegraben {2} · Gold {3} · Holz {4}
|
||||||
|
ach.heroes.name = Heldenschreck {0}
|
||||||
|
ach.heroes.desc = Besiege {0} Helden.
|
||||||
|
ach.bosses.name = Championbrecher {0}
|
||||||
|
ach.bosses.desc = Bring {0} Champions des Ordens zu Fall.
|
||||||
|
ach.dug.name = Tiefgräber {0}
|
||||||
|
ach.dug.desc = Grabe {0} Kacheln aus.
|
||||||
|
ach.gold.name = Hortmeister {0}
|
||||||
|
ach.gold.desc = Verdiene {0} Gold.
|
||||||
|
ach.wood.name = Holzfürst {0}
|
||||||
|
ach.wood.desc = Fälle {0} Holz.
|
||||||
|
ach.walls.name = Maurer {0}
|
||||||
|
ach.walls.desc = Richte {0} Wände auf.
|
||||||
|
ach.rooms.name = Architekt der Schatten {0}
|
||||||
|
ach.rooms.desc = Baue {0} Raumkacheln.
|
||||||
|
ach.spells.name = Dunkler Zauberer {0}
|
||||||
|
ach.spells.desc = Wirke {0} Zauber.
|
||||||
|
ach.slaps.name = Strenge Hand {0}
|
||||||
|
ach.slaps.desc = Gib deinen Kreaturen {0} Klapse. Sie lieben es. Vermutlich.
|
||||||
|
ach.traps.name = Fallenschmied {0}
|
||||||
|
ach.traps.desc = Gib {0} Fallen und Türen in der Schmiede in Auftrag.
|
||||||
|
ach.towers.name = Turmherr {0}
|
||||||
|
ach.towers.desc = Baue {0} Türme.
|
||||||
|
ach.drowned.name = Schwimmkurs {0}
|
||||||
|
ach.drowned.desc = Lass {0} Helden in Wasser oder Lava stürzen.
|
||||||
|
ach.burnt.name = Brückenbrenner {0}
|
||||||
|
ach.burnt.desc = Brenne {0} Brückenplanken nieder.
|
||||||
|
ach.siege.name = Belagerungsmeister {0}
|
||||||
|
ach.siege.desc = Baue {0} Belagerungsgeräte.
|
||||||
|
ach.captured.name = Kerkermeister {0}
|
||||||
|
ach.captured.desc = Nimm {0} Helden gefangen.
|
||||||
|
ach.drawbridge.name = Hoch damit {0}
|
||||||
|
ach.drawbridge.desc = Ziehe {0}-mal Zugbrücken hoch.
|
||||||
|
ach.wins.name = Eroberer {0}
|
||||||
|
ach.wins.desc = Gewinne {0} freie Spiele.
|
||||||
|
ach.missions.name = Splittersucher {0}
|
||||||
|
ach.missions.desc = Gewinne {0} Kampagnenmissionen.
|
||||||
|
ach.coop.name = Bundesmitglied {0}
|
||||||
|
ach.coop.desc = Gewinne {0} Koop-Missionen.
|
||||||
|
ach.minutes.name = Nachteule {0}
|
||||||
|
ach.minutes.desc = Spiele {0} Minuten lang.
|
||||||
|
ach.flawless.name = Kein Kratzer
|
||||||
|
ach.flawless.desc = Gewinne, ohne eine einzige Kreatur zu verlieren.
|
||||||
|
ach.speedrun.name = Bevor der Tee kalt wird
|
||||||
|
ach.speedrun.desc = Gewinne innerhalb von fünfzehn Minuten.
|
||||||
|
ach.campaign.name = Der eine Meister
|
||||||
|
ach.campaign.desc = Gewinne die letzte Mission der Kampagne.
|
||||||
|
ach.surface.name = Sonnenbrand
|
||||||
|
ach.surface.desc = Gewinne ein Spiel, das bis an die Oberfläche reicht.
|
||||||
|
ach.defeat.name = Tee und Mitgefühl
|
||||||
|
ach.defeat.desc = Verliere ein Spiel. Crookfinger setzt Wasser auf.
|
||||||
|
ach.legend.name = Lebende Legende
|
||||||
|
ach.legend.desc = Eine Kreatur erreicht Stufe zehn.
|
||||||
|
ach.wyrm.name = Hier seien Drachen
|
||||||
|
ach.wyrm.desc = Ein Glutwurm schließt sich deinem Kerker an.
|
||||||
|
ach.fullhand.name = Alle Hände voll
|
||||||
|
ach.fullhand.desc = Halte acht Einheiten gleichzeitig in der Hand.
|
||||||
|
|
||||||
|
# tutorial
|
||||||
|
menu.tutorial = Einführung
|
||||||
|
tut.name = Erste Schritte
|
||||||
|
tut.title = Crookfinger zeigt dir, wie es geht
|
||||||
|
tut.story = Du bist also der neue Meister. Klein, aber der Kern schlägt noch, und das zählt.\n\nIch zeige dir das Wichtigste: Graben, Räume, Kreaturen und was man mit Rittern macht. Tu, was ich sage, und niemandem passiert etwas. Außer den Rittern.
|
||||||
|
tut.start = Crookfinger: Erste Lektion – Graben. Nimm die Spitzhacke (Taste 1), zieh über die Erde östlich des Herzens, und deine Imps graben sie aus.
|
||||||
|
tut.obj1 = Grabe den Gang östlich des Herzens aus.
|
||||||
|
tut.dug = Crookfinger: Sieh an, eine Halle dahinter! Deine Imps beanspruchen den Boden von selbst. Öffne jetzt Räume und baue ein Lager auf deinem Boden – dort schlafen deine Kreaturen.
|
||||||
|
tut.obj2 = Baue ein Lager (Räume).
|
||||||
|
tut.lair = Crookfinger: Betten, erledigt. Kreaturen essen auch: Baue daneben einen Pilzgarten.
|
||||||
|
tut.obj3 = Baue einen Pilzgarten (Räume).
|
||||||
|
tut.farm = Crookfinger: Kost und Logis – jetzt schickt dir das Portal Kreaturen. Geduld, sie kommen von selbst. Warte auf zwei.
|
||||||
|
tut.obj4 = Warte, bis zwei Kreaturen durchs Portal gekommen sind.
|
||||||
|
tut.creatures = Crookfinger: Da sind sie! Ein wenig Ansporn schadet nie: Nimm die Hand (Taste 2) und klick eine Kreatur mit gedrückter Umschalttaste an (am Touchscreen: zweimal antippen), um ihr einen Klaps zu geben. Eine Weile arbeitet sie schneller.
|
||||||
|
tut.obj5 = Gib einer deiner Kreaturen einen Klaps (Hand, Umschalt + Klick).
|
||||||
|
tut.slap = Crookfinger: Ha! Hast du das gehört? Aus dem Osten kommt etwas. Mach dich bereit: Der Blitz (Zauber) trifft hart, und deine Kreaturen kämpfen von selbst.
|
||||||
|
tut.obj6 = Besiege die beiden Ritter.
|
||||||
|
tut.knights = Crookfinger: Zwei Ritter! Schick deine Kreaturen mit der Angriffsflagge (Befehle) oder triff sie mit dem Blitz.
|
||||||
|
tut.win = Crookfinger: Gut gemacht, Meister! Mehr ist es nicht. Na ja – fast. Den Rest erzählt die Kampagne. Ich setze Wasser auf.
|
||||||
|
|
||||||
|
# master talents
|
||||||
|
cat.master = Meister
|
||||||
|
talent.title = Meister-Talente
|
||||||
|
talent.points = Freie Talentpunkte: {0}
|
||||||
|
talent.next = {0}/{1} Erfahrung bis zum nächsten Punkt
|
||||||
|
talent.all = Alle Talente sind gelernt.
|
||||||
|
talent.builder = Baumeister
|
||||||
|
talent.warlord = Kriegsherr
|
||||||
|
talent.warlock = Hexer
|
||||||
|
talent.merchant = Händler
|
||||||
|
talent.builder.desc = Imps graben {0}% schneller, Räume kosten {1}% weniger.
|
||||||
|
talent.warlord.desc = Kreaturen schlagen {0}% härter zu und nehmen {1}% weniger Schaden.
|
||||||
|
talent.warlock.desc = Mana fließt {0}% schneller, Zauber kosten {1}% weniger.
|
||||||
|
talent.merchant.desc = Löhne sind {0}% niedriger, gelieferte Schätze sind {1}% mehr wert.
|
||||||
|
talent.none = Noch nicht gelernt.
|
||||||
|
talent.then = Nächste Stufe: {0}
|
||||||
|
msg.talent_point = Ein neuer Talentpunkt! Verteile ihn im Meister-Fenster.
|
||||||
|
msg.talent_learned = {0} Stufe {1} gelernt.
|
||||||
|
|
||||||
|
# weather above ground
|
||||||
|
weather.fair = Heiter
|
||||||
|
weather.rain = Regen
|
||||||
|
weather.fog = Nebel
|
||||||
|
weather.storm = Sturm
|
||||||
|
msg.weather_clear = Über der Erde klart es auf.
|
||||||
|
msg.weather_rain = Über der Erde beginnt es zu regnen - Feuer fängt nicht.
|
||||||
|
msg.weather_fog = Nebel zieht über das Land - draußen sieht jeder weniger.
|
||||||
|
msg.weather_storm = Über der Erde zieht ein Sturm auf! Blitze treffen Einheiten und Bäume.
|
||||||
|
msg.lightning_unit = Vom Blitz getroffen: {0}!
|
||||||
|
# creature names
|
||||||
|
names.imp = Krümel, Schnodder, Buddel, Matsch, Fussel, Knirps, Wühli, Schmutzi, Popel, Kratzer, Grummel, Bröckel, Zappel, Schlammi, Pfriem, Knubbel, Grabi, Rotzi, Wusel, Schaufel
|
||||||
|
names.kobold = Kläff, Zwick, Raffzahn, Krätz, Schnapp, Rostig, Zack, Beißer, Klau, Ratz, Fetz, Zausel, Kralle, Stänker, Wurz, Fiesling, Rempel, Zinken, Knick, Schrammi
|
||||||
|
names.spider = Krabbel, Wollbein, Spinnchen, Kitzel, Netti, Achtfuß, Fädchen, Gruselchen, Tüdel, Seidi, Webbi, Huschel, Krümelbein, Tekla, Spindel, Fangi, Zwirn, Klebi, Wuschel, Nadelchen
|
||||||
|
names.mage = Bartholomäus, Ignatius, Leopold, Theobald, Adalbert, Kunibert, Wendelin, Gottlieb, Hieronymus, Ottokar, Florian, Engelbert, Pankratius, Ruprecht, Konstantin, Eusebius, Sebaldus, Cornelius, Archibald, Waldemar
|
||||||
|
names.ogre = Grunz, Klotz, Bums, Brocken, Rumms, Dumpf, Wumme, Klopper, Trampel, Holzkopf, Plumps, Mops, Bolle, Wanst, Knüppel, Ochs, Rumpel, Batzen, Hauruck, Gorbo
|
||||||
|
names.jailer = Grimm, Riegel, Kettler, Schloss, Kerker, Eisenbart, Fessel, Bolzen, Streng, Gitter, Mürrisch, Haken, Strick, Schlüssel, Finster, Klirr, Stockmann, Wächter, Zwinger, Bitter
|
||||||
|
names.priestess = Glut, Flamma, Asche, Seraphine, Lodera, Feuria, Morwen, Brunhild, Ignia, Kriemhild, Scharlach, Funkia, Rabea, Lilith, Zunder, Isolde, Brennhild, Nyx, Vesper, Glimma
|
||||||
|
names.chainbreaker = Kettenbann, Ulfgar, Eisenseele, Brannok, Kragmor, Valdrek, Dorn, Gorath, Ungebunden, Rurik, Torvald, Hagen, Durnok, Sprengel, Brand, Wulfrich, Korrun, Arvak, Sigbert, Halvard
|
||||||
|
names.bat = Piepsi, Flatter, Fiepe, Schwirr, Echo, Flapp, Dämmer, Zischi, Zirp, Zähnchen, Wirbel, Flederli, Motti, Piep, Sausi, Baumel, Samtchen, Huschi, Quiek, Blitzi
|
||||||
|
names.gargoyle = Granit, Schiefer, Basalt, Grimmbert, Mörtel, Kiesel, Schotter, Feuerstein, Fuge, Geröll, Quarz, Bimsstein, Sims, Knorz, Steinbart, Grantig, Giebel, Wasserspeier, Meißel, Sockel
|
||||||
|
names.owl = Uhuwald, Weisling, Kiel, Bartholdo, Minerva, Tobias, Huhu, Eulalia, Federich, Hauch, Grübel, Älteste, Kauzi, Noctua, Eulbert, Pergament, Dämmer, Waldkauz, Professor, Plusch
|
||||||
|
names.wyrm = Vermithrax, Aschenmaul, Sengbart, Calderon, Ignivar, Glimmhorn, Drakkar, Pyrrhus, Obsidian, Glutmaul, Vulkanor, Brennzahn, Malgrath, Tyrax, Inferno, Schwefel, Kaldor, Fafnir, Ragnor, Zinderos
|
||||||
|
names.cogling = Rädchen, Zahnrad, Schräubchen, Ratsche, Bolzen, Getriebe, Tüftel, Dingsbums, Schlüssel, Ritzel, Klimper, Fummel, Niete, Surr, Kurbel, Drehwurm, Flansch, Klackerle, Ticktack, Nippel
|
||||||
|
names.revenant = Morgen, Gram, Aschgrau, Klage, Hohl, Trauer, Bleich, Kummer, Schemen, Moder, Grabmann, Dämmer, Gruft, Wehmut, Wache, Hügel, Leichentuch, Elegie, Neige, Öde
|
||||||
|
names.beast = Zottel, Brummbär, Bruno, Struppi, Mampf, Moosrücken, Brummel, Ursa, Knorr, Knacks, Klicker, Krabbelbert, Borste, Moos, Rumpel, Dornbusch, Zirpel, Grummler, Wuchtel, Schnuffel
|
||||||
|
names.human = Gerhard, Roland, Konrad, Albrecht, Tristan, Eckhart, Gawan, Lienhard, Parzival, Hugo, Bertram, Gottfried, Ortwin, Balduin, Reinhold, Wilfried, Iwein, Gilbert, Ruprecht, Dietrich
|
||||||
|
epithets.mid = Knochenbrecher, Schlammfuß, Rattenbeißer, Dreckgesicht, Kieselkauer, Schädelklopfer, Stinkefaust, Rostknöchel, Bauchboxer, Schienbeintreter, Schimmelbart, Dreckkralle, Rotzschleuder, Ohrenkauer, Kniebrecher, Wurmfresser
|
||||||
|
epithets.high = Königsschreck, Unheilsfaust, Schreckenszahn, Weltenbrecher, Heldenbann, Sturmfaust, Nachtgrauen, Seelenschnitter, Eisenherz, Burgenbrecher, Drachenbann, Höllenwanderer, Schattenfürst, Blutkrone, Grimmflut, Sternbrecher
|
||||||
|
name.epithet = {0} {1}
|
||||||
|
msg.creature_fallen = {0} ist gefallen. Ein Gedenkstein bewahrt den Namen.
|
||||||
|
tile.memorial = Gedenkstein
|
||||||
|
tile.memorial_info = Hier ruht {0} ({1}, Stufe {2}).
|
||||||
|
|
||||||
|
# hall of the fallen
|
||||||
|
hall.title = Halle der Gefallenen
|
||||||
|
hall.count = {0} gefallene Kreaturen in Erinnerung
|
||||||
|
hall.empty = Noch ist niemand gefallen. Möge es so bleiben.
|
||||||
|
hall.kills = {0} Helden erschlagen
|
||||||
|
ov.fallen = Gefallene ({0})
|
||||||
|
ov.living = Lebende
|
||||||
|
cause.slain = erschlagen von: {0}
|
||||||
|
cause.lava = in Lava verbrannt
|
||||||
|
cause.water = ertrunken
|
||||||
|
cause.snare = in eine Falle getreten
|
||||||
|
cause.lightning = vom Blitz getroffen
|
||||||
|
cause.fell = gefallen
|
||||||
@@ -53,17 +53,29 @@ msg.creature_arrived = A {0} came through the portal.
|
|||||||
msg.payday_partial = Payday! {0} creatures paid ({1} gold), {2} got nothing and are angry!
|
msg.payday_partial = Payday! {0} creatures paid ({1} gold), {2} got nothing and are angry!
|
||||||
msg.payday = Payday! {0} creatures received {1} gold.
|
msg.payday = Payday! {0} creatures received {1} gold.
|
||||||
msg.wave_boss = Wave {0}: a Paladin of Light leads {1} heroes into your realm!
|
msg.wave_boss = Wave {0}: a Paladin of Light leads {1} heroes into your realm!
|
||||||
|
boss.aldric = Sir Aldric the Unbending
|
||||||
|
boss.seraphine = Dame Seraphine of the Dawn
|
||||||
|
boss.halvard = Brother Halvard the Pious
|
||||||
|
boss.rothgar = Captain Rothgar Ironside
|
||||||
|
msg.boss_comes = A champion of the Order leads the wave: {0}!
|
||||||
|
msg.boss_slain = {0} has fallen! Loot: {1} gold and the plans of a rune forge tower.
|
||||||
msg.wave = Wave {0}: {1} heroes break through the gate!
|
msg.wave = Wave {0}: {1} heroes break through the gate!
|
||||||
msg.gate_boss = The gate is crumbling! The Gate Warden appears with {0} defenders!
|
msg.gate_boss = The gate is crumbling! The Gate Warden appears with {0} defenders!
|
||||||
msg.gate_defenders = The gate summons {0} defenders!
|
msg.gate_defenders = The gate summons {0} defenders!
|
||||||
msg.level_up = {0} reaches level {1}.
|
msg.level_up = {0} reaches level {1}.
|
||||||
msg.hero_slain = {0} defeated! Loot: {1} gold.
|
msg.hero_slain = {0} defeated! Loot: {1} gold.
|
||||||
|
msg.thief_in_treasury = A thief is in your treasury!
|
||||||
|
msg.thief_escaped = A thief escaped with {0} gold!
|
||||||
|
msg.snare_caught = Your {0} stepped into a snare!
|
||||||
|
msg.converted = The inquisitor turned your {0} against you!
|
||||||
msg.creature_died = Your {0} has fallen.
|
msg.creature_died = Your {0} has fallen.
|
||||||
msg.portal_claimed = The portal is yours! It attracts creatures.
|
msg.portal_claimed = The portal is yours! It attracts creatures.
|
||||||
msg.flag_removed = Attack flag removed. Your creatures return.
|
msg.flag_removed = Attack flag removed. Your creatures return.
|
||||||
msg.flag_set = Attack flag set! Battle-ready creatures gather there.
|
msg.flag_set = Attack flag set! Battle-ready creatures gather there.
|
||||||
msg.creature_leaves = An unhappy {0} leaves your realm!
|
msg.creature_leaves = An unhappy {0} leaves your realm!
|
||||||
msg.no_gold = Not enough gold!
|
msg.no_gold = Not enough gold!
|
||||||
|
msg.no_wood = Not enough wood! Fell giant mushrooms (or trees) with the pick.
|
||||||
|
msg.siege_built = A {0} is ready – set the attack flag to send it.
|
||||||
msg.sold = Rooms sold: +{0} gold.
|
msg.sold = Rooms sold: +{0} gold.
|
||||||
msg.no_mana_bolt = Not enough mana for Lightning.
|
msg.no_mana_bolt = Not enough mana for Lightning.
|
||||||
msg.no_mana_heal = Not enough mana for Heal.
|
msg.no_mana_heal = Not enough mana for Heal.
|
||||||
@@ -73,6 +85,7 @@ msg.no_mana_cost = Not enough mana ({0} needed).
|
|||||||
hint.portal_no_lair = The portal is open, but without a lair no creatures will come.
|
hint.portal_no_lair = The portal is open, but without a lair no creatures will come.
|
||||||
hint.no_farm = Creatures want to eat: build a mushroom garden.
|
hint.no_farm = Creatures want to eat: build a mushroom garden.
|
||||||
hint.lair_full = All beds in the lair are taken. Enlarge your lair.
|
hint.lair_full = All beds in the lair are taken. Enlarge your lair.
|
||||||
|
hint.creature_max = Your dungeon holds 100 creatures - no more come through the portal.
|
||||||
hint.gate_attack = Your creatures are attacking the Hero Gate!
|
hint.gate_attack = Your creatures are attacking the Hero Gate!
|
||||||
hint.treasury_full = Your treasuries are full! Build more treasury tiles.
|
hint.treasury_full = Your treasuries are full! Build more treasury tiles.
|
||||||
hint.flag_unreachable = Your creatures cannot reach the attack flag.
|
hint.flag_unreachable = Your creatures cannot reach the attack flag.
|
||||||
@@ -124,6 +137,7 @@ ui.key = Key: {0}
|
|||||||
|
|
||||||
# ---- HUD
|
# ---- HUD
|
||||||
hud.gold = Gold
|
hud.gold = Gold
|
||||||
|
hud.wood = Wood
|
||||||
hud.mana = Mana
|
hud.mana = Mana
|
||||||
hud.core = Core
|
hud.core = Core
|
||||||
hud.gate = Gate
|
hud.gate = Gate
|
||||||
@@ -145,6 +159,7 @@ help.k_right = Right click Drop a unit (prisoners: also into the torture cha
|
|||||||
help.k_slap = Shift+Left Hand: backhand own units, punch heroes on your ground
|
help.k_slap = Shift+Left Hand: backhand own units, punch heroes on your ground
|
||||||
help.k_pan = Right drag Move the map (also WASD/arrows, screen edge)
|
help.k_pan = Right drag Move the map (also WASD/arrows, screen edge)
|
||||||
help.k_rot = Middle drag Rotate and tilt the camera (also Q/E, R/F)
|
help.k_rot = Middle drag Rotate and tilt the camera (also Q/E, R/F)
|
||||||
|
help.k_reset = Middle 2x click Reset angle and turn (the place stays)
|
||||||
help.k_zoom = Mouse wheel Zoom
|
help.k_zoom = Mouse wheel Zoom
|
||||||
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Select tool
|
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Select tool
|
||||||
help.k_misc = P / Space Pause M Sound on/off +/- Volume
|
help.k_misc = P / Space Pause M Sound on/off +/- Volume
|
||||||
@@ -237,6 +252,7 @@ set.lights = Light sources
|
|||||||
set.ui_scale = Interface size
|
set.ui_scale = Interface size
|
||||||
set.game_speed = Game speed
|
set.game_speed = Game speed
|
||||||
set.hints = Hints
|
set.hints = Hints
|
||||||
|
set.events = Random events
|
||||||
set.keys_hint = Click an action and press a key. Keys already in use are swapped.
|
set.keys_hint = Click an action and press a key. Keys already in use are swapped.
|
||||||
set.press_key = Press a key for: {0}
|
set.press_key = Press a key for: {0}
|
||||||
set.esc_cancel = Esc cancels
|
set.esc_cancel = Esc cancels
|
||||||
@@ -248,6 +264,8 @@ val.medium = Medium
|
|||||||
val.high = High
|
val.high = High
|
||||||
val.normal = Normal
|
val.normal = Normal
|
||||||
val.large = Large
|
val.large = Large
|
||||||
|
val.small = Small
|
||||||
|
val.auto_plain = Auto
|
||||||
|
|
||||||
# ---- key binding names
|
# ---- key binding names
|
||||||
key.cam_forward = Camera forward
|
key.cam_forward = Camera forward
|
||||||
@@ -313,12 +331,30 @@ hint.tower_max = This tower is fully upgraded.
|
|||||||
hint.tower_where = Towers can only be built on claimed, empty floor tiles.
|
hint.tower_where = Towers can only be built on claimed, empty floor tiles.
|
||||||
hint.tower_mana = Your magic towers have run out of mana.
|
hint.tower_mana = Your magic towers have run out of mana.
|
||||||
job.repair = Repairing a tower
|
job.repair = Repairing a tower
|
||||||
|
job.mend_bridge = Mending a bridge
|
||||||
|
job.raise_wall = Raising a wall
|
||||||
|
job.march = Marching to a sword
|
||||||
job.attack_tower = Attacking a tower
|
job.attack_tower = Attacking a tower
|
||||||
|
|
||||||
unit.jailer = Jailer
|
unit.jailer = Jailer
|
||||||
unit.priestess = Flame Priestess
|
unit.priestess = Flame Priestess
|
||||||
msg.hero_captured = {0} knocked out and taken prisoner! Loot: {1} gold.
|
msg.hero_captured = {0} knocked out and taken prisoner! Loot: {1} gold.
|
||||||
msg.defected = {0} has defected to your side!
|
msg.defected = {0} has defected to your side!
|
||||||
|
msg.beast_tamed = A wild {0} gives in and joins your dungeon!
|
||||||
|
gear.1 = iron gear
|
||||||
|
gear.2 = steel gear
|
||||||
|
gear.3 = rune gear
|
||||||
|
msg.gear_given = The forge hands {0} to a {1}.
|
||||||
|
msg.trader_arrives = A trader came through your portal: {0} for {1} gold. Click him with the hand to buy - he waits a minute.
|
||||||
|
msg.trader_sold = Bought: {0}. Pleasure doing business, says the trader.
|
||||||
|
msg.trader_left = The trader shrugs and leaves through the portal.
|
||||||
|
trader.info = Offers {0} for {1} gold (click with the hand)
|
||||||
|
trader.left = Leaves in {0} s
|
||||||
|
trader.rune = the plans of a rune forge tower
|
||||||
|
trader.wyrm = an ember wyrm
|
||||||
|
trader.chainbreaker = a chainbreaker
|
||||||
|
trader.wood = 30 wood
|
||||||
|
trader.mana = a potion that fills your mana
|
||||||
msg.prisoner_died = {0} died under torture.
|
msg.prisoner_died = {0} died under torture.
|
||||||
job.carry = Carrying a prisoner
|
job.carry = Carrying a prisoner
|
||||||
job.pray = Praying
|
job.pray = Praying
|
||||||
@@ -329,6 +365,10 @@ info.tortured = being tortured
|
|||||||
info.prison = Prisoners: {0}/{1}
|
info.prison = Prisoners: {0}/{1}
|
||||||
|
|
||||||
msg.wave_warn_dir = Scouts report: hero wave {0} reaches the gate in 30 seconds! They plan to break in from the {1}.
|
msg.wave_warn_dir = Scouts report: hero wave {0} reaches the gate in 30 seconds! They plan to break in from the {1}.
|
||||||
|
msg.event_quake = Earthquake! Tunnels in the {0} have caved in.
|
||||||
|
msg.event_lava = The lava overflows its banks in the {0}!
|
||||||
|
msg.event_flood = Flood! The river overflows its banks in the {0}.
|
||||||
|
msg.event_vein = The imps report a new gold vein in the {0}!
|
||||||
msg.wave_warn_two = Scouts report: hero wave {0} reaches the gate in 30 seconds! They split up and break in from the {1} and the {2}.
|
msg.wave_warn_two = Scouts report: hero wave {0} reaches the gate in 30 seconds! They split up and break in from the {1} and the {2}.
|
||||||
dir.n = north
|
dir.n = north
|
||||||
dir.ne = north-east
|
dir.ne = north-east
|
||||||
@@ -340,7 +380,8 @@ dir.w = west
|
|||||||
dir.nw = north-west
|
dir.nw = north-west
|
||||||
|
|
||||||
val.auto = Auto ({0}x)
|
val.auto = Auto ({0}x)
|
||||||
set.render_scale = 3D resolution
|
set.render_scale = Pixel look
|
||||||
|
set.pixel_rows_val = {0} rows
|
||||||
set.debug_fps = Show FPS
|
set.debug_fps = Show FPS
|
||||||
set.debug_frame_time = Show frame time
|
set.debug_frame_time = Show frame time
|
||||||
set.debug_ping = Show ping
|
set.debug_ping = Show ping
|
||||||
@@ -398,8 +439,25 @@ help.start_touch = Tap to start
|
|||||||
tool.bridge.label = Bridge
|
tool.bridge.label = Bridge
|
||||||
tool.bridge.name = Build Bridge
|
tool.bridge.name = Build Bridge
|
||||||
tool.bridge.desc = Builds a bridge over water or lava. Drag from your own ground across the river; the bridge grows tile by tile from your territory.
|
tool.bridge.desc = Builds a bridge over water or lava. Drag from your own ground across the river; the bridge grows tile by tile from your territory.
|
||||||
cost.bridge = {0} gold/tile over water, {1} over lava
|
tool.drawbridge.label = Drawbridge
|
||||||
|
tool.drawbridge.name = Build Drawbridge
|
||||||
|
tool.drawbridge.desc = A bridge you can raise: drag across the river to build it, click it to switch: down, up, automatic (rises when enemies come near). Wood over water burns, stone over lava does not.
|
||||||
|
tool.wall.label = Wall
|
||||||
|
tool.wall.name = Raise Wall
|
||||||
|
tool.wall.desc = Plan walls on your own empty floor (drag again to cancel): imps raise them as reinforced earth. Close tunnels, then put doors where they matter.
|
||||||
|
tool.ram.label = Ram
|
||||||
|
tool.ram.name = Build Battering Ram
|
||||||
|
tool.ram.desc = A battering ram rolls out of your forge (gold and wood). It follows your attack flag and smashes the knights' gate and towers.
|
||||||
|
tool.catapult.label = Catapult
|
||||||
|
tool.catapult.name = Build Catapult
|
||||||
|
tool.catapult.desc = A catapult rolls out of your forge (gold and wood). It shoots the knights' towers from afar and follows your attack flag to their gate.
|
||||||
|
cost.bridge = {0} gold + {1} wood/tile over water, {2} gold over lava
|
||||||
|
cost.drawbridge = {0} gold + {1} wood/tile over water, {2} gold over lava
|
||||||
|
cost.gold_wood = {0} gold + {1} wood
|
||||||
hint.bridge_where = Bridges must start at your own ground: drag from your territory across the river.
|
hint.bridge_where = Bridges must start at your own ground: drag from your territory across the river.
|
||||||
|
hint.bridge_fire = Wooden bridges burn: keep fire traps, fire breath and lightning away from them – stone bridges over lava are fireproof.
|
||||||
|
hint.wall_where = Walls can only be raised on your own empty floor (no room, trap or door).
|
||||||
|
hint.siege_where = Siege engines roll out on your own open ground – and you need a forge.
|
||||||
hint.bridge_river = Bridges can only be built over water or lava.
|
hint.bridge_river = Bridges can only be built over water or lava.
|
||||||
msg.hero_burned = A {0} fell into the lava! +{1} gold.
|
msg.hero_burned = A {0} fell into the lava! +{1} gold.
|
||||||
msg.creature_burned = Your {0} fell into the lava.
|
msg.creature_burned = Your {0} fell into the lava.
|
||||||
@@ -409,6 +467,22 @@ tile.lava = Lava
|
|||||||
tile.lava_info = Whatever falls in burns. Stone bridges cross it.
|
tile.lava_info = Whatever falls in burns. Stone bridges cross it.
|
||||||
tile.bridge = Wooden bridge
|
tile.bridge = Wooden bridge
|
||||||
tile.lavabridge = Stone bridge
|
tile.lavabridge = Stone bridge
|
||||||
|
tile.mushroom = Giant mushroom
|
||||||
|
tile.mushroom_grown = Fully grown: ready for felling.
|
||||||
|
tile.mushroom_growing = Growing: {0}% - it can only be felled when fully grown.
|
||||||
|
tile.grove = Mushroom grove
|
||||||
|
tile.grove_info = Nobody can claim this moss. Giant mushrooms grow back here.
|
||||||
|
tile.tree = Tree
|
||||||
|
tile.plant_info = Mark it with the pick: an imp fells it for {0} wood.
|
||||||
|
tile.drawbridge = Drawbridge
|
||||||
|
tile.drawbridge_up = Raised
|
||||||
|
tile.drawbridge_down = Lowered
|
||||||
|
tile.drawbridge_auto = Automatic (down)
|
||||||
|
tile.drawbridge_auto_up = Automatic (up)
|
||||||
|
tile.obsidian = Obsidian
|
||||||
|
tile.obsidian_info = Cooled lava – melts again in {0} s
|
||||||
|
tile.bridge_burning = Burning!
|
||||||
|
tile.bridge_worn = Worn {0}%
|
||||||
tile.shard = Shard of your soul core
|
tile.shard = Shard of your soul core
|
||||||
tile.shard_info = Claim this chamber to take the shard back.
|
tile.shard_info = Claim this chamber to take the shard back.
|
||||||
|
|
||||||
@@ -430,6 +504,11 @@ msg.enemy_tower_destroyed = A knight {0} has fallen!
|
|||||||
msg.vault_taken = Knight vault taken: {0} worth of treasure is yours.
|
msg.vault_taken = Knight vault taken: {0} worth of treasure is yours.
|
||||||
msg.shard_taken = A shard of your soul core is back in your hands!
|
msg.shard_taken = A shard of your soul core is back in your hands!
|
||||||
msg.bridge_torn = The knights have torn down one of your bridges!
|
msg.bridge_torn = The knights have torn down one of your bridges!
|
||||||
|
msg.bridge_fire = One of your bridges is on fire!
|
||||||
|
msg.drawbridge_up = The drawbridge is raised.
|
||||||
|
msg.drawbridge_down = The drawbridge is lowered.
|
||||||
|
msg.drawbridge_auto = The drawbridge rises by itself when enemies come near.
|
||||||
|
msg.drawbridge_rises = Enemies near: a drawbridge rises!
|
||||||
job.tear_bridge = Tearing down a bridge
|
job.tear_bridge = Tearing down a bridge
|
||||||
job.patrol = On patrol
|
job.patrol = On patrol
|
||||||
job.guard = On guard
|
job.guard = On guard
|
||||||
@@ -533,7 +612,7 @@ camp.m29.story = Lava islands, master, with springs of mana boiling in the middl
|
|||||||
camp.m20.title = The Grand Master
|
camp.m20.title = The Grand Master
|
||||||
camp.m20.story = Grand Master Valerian Brightshield waits in his citadel beyond the great river, with the last shard and the gate of the order. He has prayed, polished and prepared for a hundred years. | Destroy his gate and take back the shard. Then your core is whole - and every master who ever wanted it will come for it at once. One thing at a time.
|
camp.m20.story = Grand Master Valerian Brightshield waits in his citadel beyond the great river, with the last shard and the gate of the order. He has prayed, polished and prepared for a hundred years. | Destroy his gate and take back the shard. Then your core is whole - and every master who ever wanted it will come for it at once. One thing at a time.
|
||||||
camp.m30.title = The Throne of the Deep
|
camp.m30.title = The Throne of the Deep
|
||||||
camp.m30.story = It happened as I said: your core is whole, and now everybody wants it. Mossgrim and Lady Nightwell march together, and what is left of the Order opens its gate one last time. | Destroy both master cores and the gate of the order. After this the deep has exactly one master. Guess who. Hint: not me, I am the tea man.
|
camp.m30.story = It happened as I said: your core is whole, and now everybody wants it. Mossgrim and Lady Nightwell have dug their way up to the surface, and the High Marshal waits in his fortress in the forest – daylight, birds, the works. Disgusting. | Climb out of the rock, destroy both master cores and the gate of the order inside the fortress. After this the world has exactly one master. Guess who. Hint: not me, I am the tea man.
|
||||||
camp.epilogue = The green core crumbles, the blue one goes dark, and the gate of the order falls silent. Somewhere above, the Lightwatch is writing a very long report, and Lady Nightwell's letters have stopped coming. | Crookfinger has made tea. You are whole, master. The underworld is yours.
|
camp.epilogue = The green core crumbles, the blue one goes dark, and the gate of the order falls silent. Somewhere above, the Lightwatch is writing a very long report, and Lady Nightwell's letters have stopped coming. | Crookfinger has made tea. You are whole, master. The underworld is yours.
|
||||||
lair.arrivals = {0} free beds: new creatures arrive at {1}% speed
|
lair.arrivals = {0} free beds: new creatures arrive at {1}% speed
|
||||||
job.help = Helping
|
job.help = Helping
|
||||||
@@ -657,10 +736,15 @@ net.error = {0}
|
|||||||
net.rooms = Open matches
|
net.rooms = Open matches
|
||||||
net.no_rooms = No open matches.
|
net.no_rooms = No open matches.
|
||||||
net.create = Create match
|
net.create = Create match
|
||||||
|
net.map_size = Map {0}x{1} · {2} players
|
||||||
net.coop_mission = Co-op campaign: mission {0} ({1})
|
net.coop_mission = Co-op campaign: mission {0} ({1})
|
||||||
net.coop_create = Co-op campaign
|
net.coop_create = Co-op campaign
|
||||||
net.coop_editor = Editor campaign
|
net.coop_editor = Editor campaign
|
||||||
net.coop_builtin = Built-in campaign
|
net.coop_builtin = Built-in campaign
|
||||||
|
net.coop_league = League against the Lightwatch
|
||||||
|
net.coop_solo = AI partner
|
||||||
|
net.coop_solo_started = Co-op without the server: your AI partner builds its own dungeon, follows your attack flag and helps out with gold.
|
||||||
|
net.coop_resume = Continue M{0}
|
||||||
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2})
|
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2})
|
||||||
net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters)
|
net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters)
|
||||||
net.join = Join
|
net.join = Join
|
||||||
@@ -691,6 +775,9 @@ net.ai_off = Off
|
|||||||
|
|
||||||
# rival masters (free game)
|
# rival masters (free game)
|
||||||
newgame.difficulty = Opponents
|
newgame.difficulty = Opponents
|
||||||
|
newgame.land = Land
|
||||||
|
newgame.land0 = Underground
|
||||||
|
newgame.land1 = Half above ground
|
||||||
diff.0 = Easy
|
diff.0 = Easy
|
||||||
diff.1 = Medium
|
diff.1 = Medium
|
||||||
diff.2 = Hard
|
diff.2 = Hard
|
||||||
@@ -703,9 +790,14 @@ msg.rival1_attack = Mossgrim, the green master, sends {0} creatures against you!
|
|||||||
msg.rival2_attack = Lady Nightwell, the blue master, sends {0} creatures against you!
|
msg.rival2_attack = Lady Nightwell, the blue master, sends {0} creatures against you!
|
||||||
msg.rival1_out = Mossgrim is defeated - his soul core has shattered!
|
msg.rival1_out = Mossgrim is defeated - his soul core has shattered!
|
||||||
msg.rival2_out = Lady Nightwell is defeated - her soul core has shattered!
|
msg.rival2_out = Lady Nightwell is defeated - her soul core has shattered!
|
||||||
|
msg.rival3_attack = Saffrax, the yellow master, sends {0} creatures against you!
|
||||||
|
msg.rival3_out = Saffrax is defeated - his soul core has shattered!
|
||||||
msg.rival_unit_slain = A {0} of a rival master falls! +{1} gold.
|
msg.rival_unit_slain = A {0} of a rival master falls! +{1} gold.
|
||||||
msg.wave_on_rival1 = Wave {0}: {1} heroes march on the green master.
|
msg.wave_on_rival1 = Wave {0}: {1} heroes march on the green master.
|
||||||
msg.wave_on_rival2 = Wave {0}: {1} heroes march on the blue master.
|
msg.wave_on_rival2 = Wave {0}: {1} heroes march on the blue master.
|
||||||
|
msg.wave_warn_on_master = Scouts report: hero wave {0} comes in 30 seconds – it heads for master {1}.
|
||||||
|
msg.wave_on_master = Wave {0}: {1} heroes march on master {2}.
|
||||||
|
msg.partner_out = Your AI partner's heart has fallen – fight on alone!
|
||||||
hud.rival1 = Green
|
hud.rival1 = Green
|
||||||
hud.rival2 = Blue
|
hud.rival2 = Blue
|
||||||
msg.creature_freed = A {0} breaks its chains and joins you!
|
msg.creature_freed = A {0} breaks its chains and joins you!
|
||||||
@@ -794,6 +886,8 @@ best.b.torture.text = Captured heroes end up here. With time and a jailer, some
|
|||||||
best.b.temple.text = Prayers heal the soul core and keep spirits high. The flame priestess comes for a temple.
|
best.b.temple.text = Prayers heal the soul core and keep spirits high. The flame priestess comes for a temple.
|
||||||
best.b.aerie.text = Perches and nests for flying creatures. Bats, gargoyles, owls and even the ember wyrm need room here.
|
best.b.aerie.text = Perches and nests for flying creatures. Bats, gargoyles, owls and even the ember wyrm need room here.
|
||||||
best.b.bridge.text = Wood over water, stone over lava. The knights tear down your bridges - claim theirs.
|
best.b.bridge.text = Wood over water, stone over lava. The knights tear down your bridges - claim theirs.
|
||||||
|
best.b.drawbridge.text = Pull it up and the river is a wall again. Set to automatic, it rises whenever enemies come close – and comes down for your own creatures.
|
||||||
|
best.b.wall.text = A tunnel you dug is not forever: wall it up again and leave the heroes one way in – through your door.
|
||||||
best.b.steel.text = Riveted iron plates on a reinforced wall. Nobody digs through quickly, not even dwarves.
|
best.b.steel.text = Riveted iron plates on a reinforced wall. Nobody digs through quickly, not even dwarves.
|
||||||
best.b.arrow.text = The arrow pierces up to two more enemies behind the target. Cheap and reliable.
|
best.b.arrow.text = The arrow pierces up to two more enemies behind the target. Cheap and reliable.
|
||||||
best.b.cannon.text = Splash damage and knockback - push the knights into the lava.
|
best.b.cannon.text = Splash damage and knockback - push the knights into the lava.
|
||||||
@@ -805,7 +899,7 @@ room.forge = Forge
|
|||||||
tool.forge.label = Forge
|
tool.forge.label = Forge
|
||||||
tool.forge.desc = Creatures manufacture traps and doors here - work, no gold. Finished pieces wait in crates until an imp installs them. Coglings come for a forge of 6+ tiles.
|
tool.forge.desc = Creatures manufacture traps and doors here - work, no gold. Finished pieces wait in crates until an imp installs them. Coglings come for a forge of 6+ tiles.
|
||||||
unit.cogling = Cogling
|
unit.cogling = Cogling
|
||||||
cat.traps = Traps
|
cat.traps = Build
|
||||||
cost.work = {0} work in the forge
|
cost.work = {0} work in the forge
|
||||||
job.forge = Forging
|
job.forge = Forging
|
||||||
job.install = Installing a trap
|
job.install = Installing a trap
|
||||||
@@ -882,6 +976,7 @@ research.now = Researching: {0} ({1}%)
|
|||||||
research.next = then: {0}
|
research.next = then: {0}
|
||||||
research.done = Nothing left to research here.
|
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.room_upgraded = Research: {0} reaches level {1} – it works a quarter better, and it shows.
|
||||||
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 = {0}
|
||||||
@@ -919,6 +1014,9 @@ tool.plague.desc = A sickness that eats at the enemies around the spot and jumps
|
|||||||
tool.quake.label = Quake
|
tool.quake.label = Quake
|
||||||
tool.quake.name = Quake
|
tool.quake.name = Quake
|
||||||
tool.quake.desc = The ground shakes: enemies around the spot are hurt and stunned for two seconds. Your creatures keep their footing.
|
tool.quake.desc = The ground shakes: enemies around the spot are hurt and stunned for two seconds. Your creatures keep their footing.
|
||||||
|
tool.freeze.label = Frost
|
||||||
|
tool.freeze.name = Frost
|
||||||
|
tool.freeze.desc = Icy cold: lava around the spot cools into walkable obsidian for 40 seconds, enemies are chilled and slowed.
|
||||||
tile.unexplored = Unexplored darkness
|
tile.unexplored = Unexplored darkness
|
||||||
|
|
||||||
# personality
|
# personality
|
||||||
@@ -971,6 +1069,9 @@ tool.arena.desc = Creatures spar in pairs: twice as much experience as training
|
|||||||
tool.altar.label = Altar
|
tool.altar.label = Altar
|
||||||
tool.altar.desc = Drop a creature on the altar to sacrifice it. Certain pairs of sacrifices bring a rare creature, otherwise the dungeon is blessed: better mood and mana.
|
tool.altar.desc = Drop a creature on the altar to sacrifice it. Certain pairs of sacrifices bring a rare creature, otherwise the dungeon is blessed: better mood and mana.
|
||||||
unit.revenant = Revenant
|
unit.revenant = Revenant
|
||||||
|
unit.cavebear = Cave bear
|
||||||
|
unit.beetle = Giant beetle
|
||||||
|
unit.trader = Trader
|
||||||
job.guard_post = On guard
|
job.guard_post = On guard
|
||||||
job.spar = Sparring
|
job.spar = Sparring
|
||||||
msg.revenant_rises = A revenant rises from the graveyard and serves you!
|
msg.revenant_rises = A revenant rises from the graveyard and serves you!
|
||||||
@@ -979,6 +1080,10 @@ msg.altar_gift = The altar accepts the pair: a {0} appears!
|
|||||||
msg.altar_blessing = The {0} is sacrificed. The dungeon is blessed.
|
msg.altar_blessing = The {0} is sacrificed. The dungeon is blessed.
|
||||||
best.revenant.lure = rises from the graveyard
|
best.revenant.lure = rises from the graveyard
|
||||||
best.revenant.text = A revenant does not eat, does not sleep and does not complain - the perfect employee, if you can stand the smell. | It drinks the strength of those it strikes once it has some experience. The priestess and it do not get along, for obvious reasons.
|
best.revenant.text = A revenant does not eat, does not sleep and does not complain - the perfect employee, if you can stand the smell. | It drinks the strength of those it strikes once it has some experience. The priestess and it do not get along, for obvious reasons.
|
||||||
|
best.cavebear.lure = beat it in its den while you have a prison
|
||||||
|
best.cavebear.text = A bear that never saw the sun and does not miss it. Shaggy, strong and grumpy until breakfast. | Beat it in its den while a prison has room, and it gives in and joins you. It likes the mushroom garden, and spiders - as a snack.
|
||||||
|
best.beetle.lure = beat it in its den while you have a prison
|
||||||
|
best.beetle.text = A beetle the size of a barrel, armoured like a knight and twice as polite. | Beat it in its den while a prison has room, and it joins you. It sleeps in the lair and cannot stand bats.
|
||||||
best.b.prison.text = Rows of cells. Heroes rot here quietly until somebody decides what to do with them.
|
best.b.prison.text = Rows of cells. Heroes rot here quietly until somebody decides what to do with them.
|
||||||
best.b.graveyard.text = Moss, stones and fresh earth. Nobody stays dead for long down here.
|
best.b.graveyard.text = Moss, stones and fresh earth. Nobody stays dead for long down here.
|
||||||
best.b.guardpost.text = A brazier, a banner and a bored creature with sharp eyes.
|
best.b.guardpost.text = A brazier, a banner and a bored creature with sharp eyes.
|
||||||
@@ -991,23 +1096,39 @@ ability.drain.desc = heals itself by a third of the damage it deals.
|
|||||||
unit.giant = Giant
|
unit.giant = Giant
|
||||||
unit.fairy = Fairy Spy
|
unit.fairy = Fairy Spy
|
||||||
unit.battlemage = Battle Mage
|
unit.battlemage = Battle Mage
|
||||||
|
unit.thief = Thief
|
||||||
|
unit.monk = Monk
|
||||||
|
unit.ranger = Ranger
|
||||||
|
unit.inquisitor = Inquisitor
|
||||||
|
unit.ram = Battering ram
|
||||||
|
unit.catapult = Catapult
|
||||||
msg.trap_wrecked = The heroes wrecked a trap: {0}.
|
msg.trap_wrecked = The heroes wrecked a trap: {0}.
|
||||||
best.giant.text = A giant is what happens when a knight eats his vegetables for three hundred years. Slow, enormous, and it treats doors as a suggestion. | It wrecks traps simply by standing next to them. Revolting manners. Hit it with everything, preferably from afar.
|
best.giant.text = A giant is what happens when a knight eats his vegetables for three hundred years. Slow, enormous, and it treats doors as a suggestion. | It wrecks traps simply by standing next to them. Revolting manners. Hit it with everything, preferably from afar.
|
||||||
best.fairy.text = The Order's little informer: a glowing gnat with wings, sent ahead to find your traps and tell everybody. Once it has seen a trap, the heroes smash it. | It also sprinkles healing dust on the heroes. Swat it first. Twice, to be sure.
|
best.fairy.text = The Order's little informer: a glowing gnat with wings, sent ahead to find your traps and tell everybody. Once it has seen a trap, the heroes smash it. | It also sprinkles healing dust on the heroes. Swat it first. Twice, to be sure.
|
||||||
best.battlemage.text = A mage in the Order's robes who thinks walls are a personal insult. Reinforced walls burst under his fireballs, and he never stops talking about it. | Fragile up close. Steel walls annoy him most - good.
|
best.battlemage.text = A mage in the Order's robes who thinks walls are a personal insult. Reinforced walls burst under his fireballs, and he never stops talking about it. | Fragile up close. Steel walls annoy him most - good.
|
||||||
|
best.thief.text = Quick fingers, quicker feet. He does not come to fight: he sneaks into your treasury, fills his sack and runs back to the gate. | Kill him before he gets away and the gold is yours again - with interest.
|
||||||
|
best.monk.text = Years of fasting and praying have made him deaf to your magic: lightning, plague and frost slide off him like rain off a roof. | Fists hurt too. Meet him with steel, claws and teeth.
|
||||||
|
best.ranger.text = A woodsman of the Order who shoots from afar and leaves iron snares in your corridors. A creature that steps into one is hurt and stuck for a while. | Snares stay until something steps in. Better a kobold than your ogre.
|
||||||
|
best.inquisitor.text = Frail, but his sermons are dangerous: every twenty seconds he talks one of your weakest creatures - a kobold or a spider - into joining the heroes. | Strong creatures just laugh at him. Hit him first.
|
||||||
|
best.ram.text = A tree trunk with an iron cap and a roof against arrows. It does not care for your creatures – only for your doors, walls and towers. | Stop it before it reaches the door, or get a new door.
|
||||||
|
best.catapult.text = A wooden arm, a bucket of rocks and a knight with too much spare time. It outranges your towers and aims for them first. | Rush it: up close it cannot hurt a fly.
|
||||||
|
|
||||||
# orders
|
# orders
|
||||||
tool.order_attack.label = Attack
|
tool.order_attack.label = Attack
|
||||||
tool.order_attack.name = Attack!
|
tool.order_attack.name = Attack!
|
||||||
tool.order_attack.desc = Click an enemy or an enemy tower: all fighting creatures within 18 tiles go for it. Right click with an order tool cancels the guard orders.
|
tool.order_attack.desc = Click an enemy or an enemy tower: all fighting creatures within 18 tiles go for it. Click open ground: a sword marker - the ready creatures share out over all swords and fight there. Right click on a marker removes it.
|
||||||
tool.order_guard.label = Guard
|
tool.order_guard.label = Guard
|
||||||
tool.order_guard.name = Guard this room
|
tool.order_guard.name = Guard this room
|
||||||
tool.order_guard.desc = Click a room: up to three creatures nearby take positions there for two minutes and attack whatever comes into view. Right click cancels.
|
tool.order_guard.desc = Click your own ground: a shield marker that three creatures hold and defend. Up to eight shields; right click on a shield removes it.
|
||||||
hint.order_attack = Click an enemy or an enemy tower.
|
hint.order_attack = Click an enemy or an enemy tower.
|
||||||
hint.order_guard = Click one of your rooms (or your own floor).
|
hint.order_guard = Click one of your rooms (or your own floor).
|
||||||
hint.order_nobody = No creature is free for that order.
|
hint.order_nobody = No creature is free for that order.
|
||||||
msg.order_attack = {0} creatures attack!
|
msg.order_attack = {0} creatures attack!
|
||||||
msg.order_guard = {0} creatures take their positions.
|
msg.order_guard = {0} creatures take their positions.
|
||||||
|
msg.mark_attack = A sword marks the spot: your ready creatures march there.
|
||||||
|
msg.mark_guard = A shield marks the spot: three creatures will hold it.
|
||||||
|
msg.mark_removed = Marker removed - its creatures are free again.
|
||||||
|
hint.marks_full = Eight markers of that kind are the most. Remove one with a right click.
|
||||||
msg.orders_cancelled = Guard orders cancelled.
|
msg.orders_cancelled = Guard orders cancelled.
|
||||||
|
|
||||||
# creature overview
|
# creature overview
|
||||||
@@ -1017,6 +1138,8 @@ ov.summary = {0} creatures - wages {1} gold per payday - mood {2}%
|
|||||||
ov.none = No creatures yet. Build a lair and a mushroom garden and claim a portal.
|
ov.none = No creatures yet. Build a lair and a mushroom garden and claim a portal.
|
||||||
ov.hint = Lv, mood, wage, job, health - click: go there
|
ov.hint = Lv, mood, wage, job, health - click: go there
|
||||||
set.voice = Crookfinger's voice
|
set.voice = Crookfinger's voice
|
||||||
|
set.track = Music track
|
||||||
|
val.track_auto = Automatic
|
||||||
|
|
||||||
# ---- campaign editor, block scripts, demo campaign
|
# ---- campaign editor, block scripts, demo campaign
|
||||||
menu.back_editor = Back to the editor
|
menu.back_editor = Back to the editor
|
||||||
@@ -1089,6 +1212,7 @@ ed.forts = Knight fortresses
|
|||||||
ed.rivals = Rival masters
|
ed.rivals = Rival masters
|
||||||
ed.difficulty = Difficulty
|
ed.difficulty = Difficulty
|
||||||
ed.portals = Portals
|
ed.portals = Portals
|
||||||
|
ed.surface = Above ground (east)
|
||||||
ed.edit_map = Edit the map
|
ed.edit_map = Edit the map
|
||||||
ed.make_random = Make it a random map
|
ed.make_random = Make it a random map
|
||||||
ed.paint_map = Generate and paint the map
|
ed.paint_map = Generate and paint the map
|
||||||
@@ -1168,6 +1292,10 @@ ed.terrain.silver = Silver vein
|
|||||||
ed.terrain.ruby = Ruby vein
|
ed.terrain.ruby = Ruby vein
|
||||||
ed.terrain.diamond = Diamond vein
|
ed.terrain.diamond = Diamond vein
|
||||||
ed.terrain.steel = Steel wall
|
ed.terrain.steel = Steel wall
|
||||||
|
ed.terrain.mushroom = Giant mushroom
|
||||||
|
ed.terrain.grove = Mushroom grove
|
||||||
|
ed.terrain.grass = Grass (above ground)
|
||||||
|
ed.terrain.tree = Tree
|
||||||
ed.place.core = Soul core
|
ed.place.core = Soul core
|
||||||
ed.place.portal = Portal
|
ed.place.portal = Portal
|
||||||
ed.place.gate = Hero gate
|
ed.place.gate = Hero gate
|
||||||
@@ -1198,6 +1326,7 @@ ed.level = Level
|
|||||||
ed.zone_shape = Shape of a new zone
|
ed.zone_shape = Shape of a new zone
|
||||||
ed.zone_new_name = Name of a new zone
|
ed.zone_new_name = Name of a new zone
|
||||||
ed.zones = Zones
|
ed.zones = Zones
|
||||||
|
ed.zone_erase = Remove tiles
|
||||||
ed.shape.rect = Rectangle
|
ed.shape.rect = Rectangle
|
||||||
ed.shape.circle = Circle
|
ed.shape.circle = Circle
|
||||||
ed.shape.tiles = Tiles
|
ed.shape.tiles = Tiles
|
||||||
@@ -1247,6 +1376,8 @@ blk.reveal = Reveal
|
|||||||
blk.camera = Show
|
blk.camera = Show
|
||||||
blk.gold = Gold +
|
blk.gold = Gold +
|
||||||
blk.mana = Mana +
|
blk.mana = Mana +
|
||||||
|
blk.weather = Weather above ground
|
||||||
|
blk.talentxp = Talent experience +
|
||||||
blk.if = If
|
blk.if = If
|
||||||
blk.repeat = Repeat
|
blk.repeat = Repeat
|
||||||
blk.trigger = Switch trigger
|
blk.trigger = Switch trigger
|
||||||
@@ -1266,6 +1397,8 @@ blk.help.reveal = Lifts the fog of war over a zone.
|
|||||||
blk.help.camera = Moves the camera to a zone.
|
blk.help.camera = Moves the camera to a zone.
|
||||||
blk.help.gold = Gives (or takes) gold.
|
blk.help.gold = Gives (or takes) gold.
|
||||||
blk.help.mana = Gives (or takes) mana.
|
blk.help.mana = Gives (or takes) mana.
|
||||||
|
blk.help.weather = Sets the weather above ground (fair, rain, fog or storm) for so many seconds; 0 keeps it until another block changes it.
|
||||||
|
blk.help.talentxp = Gives the player experience for the master talents.
|
||||||
blk.help.if = Runs the blocks inside only if the condition holds, otherwise the ones under "else".
|
blk.help.if = Runs the blocks inside only if the condition holds, otherwise the ones under "else".
|
||||||
blk.help.repeat = Runs the blocks inside several times.
|
blk.help.repeat = Runs the blocks inside several times.
|
||||||
blk.help.trigger = Switches a trigger on (it may fire again) or off.
|
blk.help.trigger = Switches a trigger on (it may fire again) or off.
|
||||||
@@ -1288,6 +1421,7 @@ blk.v.or = or
|
|||||||
blk.v.not = not
|
blk.v.not = not
|
||||||
blk.v.bool = Yes/no
|
blk.v.bool = Yes/no
|
||||||
blk.v.eventkind = Trigger unit is a
|
blk.v.eventkind = Trigger unit is a
|
||||||
|
blk.v.weatheris = Weather above ground is
|
||||||
blk.w_at = at
|
blk.w_at = at
|
||||||
blk.w_by = by
|
blk.w_by = by
|
||||||
blk.w_in = in
|
blk.w_in = in
|
||||||
@@ -1336,6 +1470,301 @@ blk.until = Repeat until
|
|||||||
blk.dountil = Repeat, then check: until
|
blk.dountil = Repeat, then check: until
|
||||||
blk.wait = Wait
|
blk.wait = Wait
|
||||||
blk.w_seconds = seconds
|
blk.w_seconds = seconds
|
||||||
|
blk.w_for = for
|
||||||
blk.help.until = Runs the blocks inside again and again until the condition holds - checked before each run, so if it already holds the inside does not run at all.
|
blk.help.until = Runs the blocks inside again and again until the condition holds - checked before each run, so if it already holds the inside does not run at all.
|
||||||
blk.help.dountil = Runs the blocks inside, then checks the condition and repeats until it holds - the inside always runs at least once.
|
blk.help.dountil = Runs the blocks inside, then checks the condition and repeats until it holds - the inside always runs at least once.
|
||||||
blk.help.wait = Pauses this script for some seconds, then it goes on with the next block (also inside loops).
|
blk.help.wait = Pauses this script for some seconds, then it goes on with the next block (also inside loops).
|
||||||
|
msg.master_out = {0} has lost the soul core - the creatures fight on without a heart.
|
||||||
|
msg.master_out_you = Your soul core has shattered. You are out - watch the rest of the match.
|
||||||
|
msg.match_won = Your team has won the match!
|
||||||
|
msg.match_lost = The match is lost - another team has won.
|
||||||
|
msg.gold_received = {1} sends you {0} gold.
|
||||||
|
msg.gold_sent = {0} gold sent to {1}.
|
||||||
|
msg.gold_none = You have no gold to send.
|
||||||
|
msg.gold_full = The treasury of {0} is full.
|
||||||
|
gold.send_to = Gold to {0}
|
||||||
|
gold.all = All
|
||||||
|
net.spectating = Your heart has fallen - you are watching the match (chat with Enter).
|
||||||
|
net.team = Team {0}
|
||||||
|
net.versus = Versus
|
||||||
|
net.coop_wait_host = Waiting for the host to start the next mission...
|
||||||
|
net.coop_run_over = Back to the lobby
|
||||||
|
|
||||||
|
# ---- co-op campaign "League against the Lightwatch"
|
||||||
|
coop.name = League against the Lightwatch
|
||||||
|
coop.raid = Mossgrim's creatures crawl out of the dark!
|
||||||
|
coop.m01.title = Two Hearts, One Crookfinger
|
||||||
|
coop.m01.story = Masters, plural. I could hardly believe it either. The Order of the Lightwatch has made a pact with Mossgrim, that mossy turncoat, to smoke out every dungeon under the hills.\n\nAlone, you would be smoked. Together you might just be the smoke. Dig in side by side and show the patrol nearby what a shared basement means.
|
||||||
|
coop.m01.start = Two masters, one advisor. My wages, by the way, stay the same.
|
||||||
|
coop.m01.goal = Defeat every knight.
|
||||||
|
coop.m01.t1 = Share the work: one digs, one builds. Or both dig. I am flexible.
|
||||||
|
coop.m01.t2 = The patrol is getting curious. Good. Curiosity is a lovely snack.
|
||||||
|
coop.m02.title = The First Outpost
|
||||||
|
coop.m02.story = Across the river the Lightwatch has built an outpost with shiny towers. Shiny things must be broken - that is the oldest rule of the underworld.\n\nBridge the water, bring the towers down, and do it together before they call their friends.
|
||||||
|
coop.m02.start = Towers. Polished, pointy, insulting. Let us fix that.
|
||||||
|
coop.m02.goal = Raze the knight towers.
|
||||||
|
coop.m02.t1 = Bridges are like friendships, masters: build them, then march an army over them.
|
||||||
|
coop.m02.t2 = One tower down is a start. All towers down is a party.
|
||||||
|
coop.m03.title = Tracks in the Moss
|
||||||
|
coop.m03.story = Something green and wet is lurking in the tunnels. Mossgrim has lent the knights his creatures, and they come through the hero gate in waves.\n\nHold out. Hold together. Hold your noses.
|
||||||
|
coop.m03.start = Mossgrim's ogres smell of wet carpet. You will know when they come.
|
||||||
|
coop.m03.goal = Survive three waves.
|
||||||
|
coop.m03.t1 = The first ogres are here. Mossgrim never could keep his pets at home.
|
||||||
|
coop.m03.t2 = Keep your hearts apart, masters - one ogre should never reach both of them.
|
||||||
|
coop.m04.title = The Stolen Springs
|
||||||
|
coop.m04.story = Two mana springs bubble on islands in the lake, and the knights have claimed them as their holy water. Holy water, honestly.\n\nTake both springs. And if the knights' vault happens to fall into your hands first, Crookfinger knows a shortcut.
|
||||||
|
coop.m04.start = Mana tastes better when it was someone else's.
|
||||||
|
coop.m04.goal = Claim two mana springs.
|
||||||
|
coop.m04.branch = Rich enough for the shortcut
|
||||||
|
coop.m04.t1 = Islands need bridges. Or wings. Bats are wings with teeth.
|
||||||
|
coop.m04.t2 = That vault looks heavy. Heavy things should be carried home.
|
||||||
|
coop.m05.title = Chains in the Rock
|
||||||
|
coop.m05.story = The Lightwatch keeps creatures in chains in sealed caves - creatures that refused to bow. An ogre and a mage, both furious, both available.\n\nDig to them, claim their caves, and the chains will fall.
|
||||||
|
coop.m05.start = Prisoners! Ours, soon. Free ones, I mean. Free to work for you.
|
||||||
|
coop.m05.goal = Free two chained creatures.
|
||||||
|
coop.m05.t1 = Listen. Do you hear the ogre swearing? That is gratitude in advance.
|
||||||
|
coop.m05.t2 = Spiders from the moss again. Mossgrim's nursery is busy.
|
||||||
|
coop.m06.title = Bridge of the Watch
|
||||||
|
coop.m06.story = Two rivers, one fortress, and the Lightwatch's finest archers on the walls. They call it the Bridge of the Watch. We will call it the bridge that was.\n\nBreak their towers. Leave the bridge - we might need it.
|
||||||
|
coop.m06.start = Archers on walls are like pigeons on roofs: loud and easy to remove.
|
||||||
|
coop.m06.goal = Raze the knight towers.
|
||||||
|
coop.m06.t1 = Two rivers mean two bridges. Or one very wet army.
|
||||||
|
coop.m06.t2 = The knights are polishing their armour. Let us give them a reason.
|
||||||
|
coop.m07.title = The Lava Smithy
|
||||||
|
coop.m07.story = Between two rivers of lava the knights forge their blessed blades. Their vault overflows with gold paid for by every dungeon they emptied.\n\nWe are taking it back. All of it. And then some.
|
||||||
|
coop.m07.start = Warm feet, cold hearts, full pockets: my kind of evening.
|
||||||
|
coop.m07.goal = Loot gold from the knights.
|
||||||
|
coop.m07.t1 = Do not stand too close to the lava. Your creatures, I mean. You are fine.
|
||||||
|
coop.m07.t2 = More knights from the east. They really love their gold.
|
||||||
|
coop.m08.title = The Field Hospital
|
||||||
|
coop.m08.story = The Lightwatch has set up a field hospital in the caves, and its priests patch up every knight you knock down.\n\nEnd the treatment. Permanently.
|
||||||
|
coop.m08.start = Priests. They heal. We do the opposite. It is a balanced relationship.
|
||||||
|
coop.m08.goal = Defeat every knight.
|
||||||
|
coop.m08.t1 = Every minute a new priest arrives. Kill faster, masters.
|
||||||
|
coop.m08.t2 = A dungeon without hospital bills. That is what I call efficient.
|
||||||
|
coop.m09.title = Mossgrim's Ambassador
|
||||||
|
coop.m09.story = Mossgrim has sent an ambassador to the knights: a paladin in green-stained armour who carries his master's promises.\n\nPromises should never arrive. Find the ambassador and silence him.
|
||||||
|
coop.m09.start = An ambassador. A messenger with a fancy hat. Hats come off.
|
||||||
|
coop.m09.goal = Defeat Mossgrim's ambassador.
|
||||||
|
coop.m09.boss = The ambassador has fallen. His message: undelivered.
|
||||||
|
coop.m09.t1 = The ambassador waits in the east, surrounded by ogres who lost their sense of humour.
|
||||||
|
coop.m09.t2 = Somewhere Mossgrim is sending another letter. Let us make it a short one.
|
||||||
|
coop.m10.title = The Twin Towers
|
||||||
|
coop.m10.story = Two fortresses guard the way north, each watching the other's back. Strike one and the other answers.\n\nStrike both. At once. That is what two masters are for.
|
||||||
|
coop.m10.start = Two fortresses, two masters. Fair is fair.
|
||||||
|
coop.m10.goal = Raze the knight towers.
|
||||||
|
coop.m10.t1 = Split up, masters - one fortress each. I will cheer for both.
|
||||||
|
coop.m10.t2 = When both fall at once, the knights will not even know whom to blame.
|
||||||
|
coop.m11.title = The Siege
|
||||||
|
coop.m11.story = The Lightwatch has found your hearts and brings everything: five waves of knights and Mossgrim's gargoyles from the cliffs.\n\nNo cleverness this time. Just walls, traps, towers - and each other.
|
||||||
|
coop.m11.start = They are coming. All of them. Well, most of them.
|
||||||
|
coop.m11.goal = Survive five waves.
|
||||||
|
coop.m11.t1 = Doors and traps, masters. A siege is just guests who refuse to use the bell.
|
||||||
|
coop.m11.t2 = Gargoyles from the south. Stone with wings - nobody asked for that.
|
||||||
|
coop.m12.title = The Gate
|
||||||
|
coop.m12.story = The hero gate in this cavern is how the knights reach the deep dungeons. Close it and their reinforcements must walk the long way round.\n\nBreak the gate, and the Lightwatch will feel it all the way up to the High Marshal.
|
||||||
|
coop.m12.start = A gate. Gates are just walls with ambitions.
|
||||||
|
coop.m12.goal = Destroy the hero gate.
|
||||||
|
coop.m12.t1 = Waves keep coming out of it. Annoying, like a dripping tap.
|
||||||
|
coop.m12.t2 = Push, masters! The gate is cracking. So is my patience.
|
||||||
|
coop.m13.title = Mossy Tunnels
|
||||||
|
coop.m13.story = We are in Mossgrim's backyard now. His tunnels are soft and damp, and two of his hoards lie in sealed caves.\n\nDig, take, leave. Mossgrim will notice. That is the point.
|
||||||
|
coop.m13.start = It smells of mushrooms and treachery. Mossgrim must be near.
|
||||||
|
coop.m13.goal = Loot gold.
|
||||||
|
coop.m13.t1 = Spiders, again. Mossgrim breeds them like other people grow tomatoes.
|
||||||
|
coop.m13.t2 = His hoards are bigger than I thought. Our pockets are bigger still.
|
||||||
|
coop.m14.title = Poison in the Water
|
||||||
|
coop.m14.story = Mossgrim has turned his dead into revenants and sends them from two sides. The knights call it holy work. Typical.\n\nDefeat every knight and every walking corpse. Then wash your hands.
|
||||||
|
coop.m14.start = Revenants. Our trick, stolen. I am offended.
|
||||||
|
coop.m14.goal = Defeat every enemy.
|
||||||
|
coop.m14.t1 = They come from the north and the south. Back to back, masters.
|
||||||
|
coop.m14.t2 = A dead thing that walks is a dead thing that has not finished its job yet.
|
||||||
|
coop.m15.title = The Chainbreaker
|
||||||
|
coop.m15.story = Deep in the rock the knights keep the Chainbreaker, a creature even they fear. Free it, and it will fight for whoever opened its cell.\n\nOr grab enough gold for the long way round - Crookfinger knows a smuggler.
|
||||||
|
coop.m15.start = The Chainbreaker. Big, angry, misunderstood. A bit like me.
|
||||||
|
coop.m15.goal = Free the Chainbreaker and raze the towers.
|
||||||
|
coop.m15.branch = Bought the smuggler's map
|
||||||
|
coop.m15.t1 = The cell is in the north-west. Mind its temper.
|
||||||
|
coop.m15.t2 = The smuggler is waiting. Smugglers hate waiting. And love gold.
|
||||||
|
coop.m16.title = The Burning Spring
|
||||||
|
coop.m16.story = In the middle of a lava lake lies an island with a mana spring that never runs dry. Mossgrim's mages guard it.\n\nTake the spring, defeat the guards. Then sit on the island and enjoy the warmth.
|
||||||
|
coop.m16.start = A spring in lava. Whoever thought of that has style. Shame it is Mossgrim.
|
||||||
|
coop.m16.goal = Claim the spring and defeat every enemy.
|
||||||
|
coop.m16.t1 = Mages from the east. Their spells are green and so are their teeth.
|
||||||
|
coop.m16.t2 = Once the spring is ours, mana for everyone. Share nicely.
|
||||||
|
coop.m17.title = Mossgrim's Garden
|
||||||
|
coop.m17.story = This is it: Mossgrim's garden, a cavern of mushrooms and creatures loyal to him. Ogres in the east, an ember wyrm in the north.\n\nTrample the garden. Every mushroom.
|
||||||
|
coop.m17.start = Look at all those mushrooms. Pity about the owner.
|
||||||
|
coop.m17.goal = Defeat every enemy.
|
||||||
|
coop.m17.t1 = An ember wyrm! Mossgrim must have saved up for years.
|
||||||
|
coop.m17.t2 = The garden is wilting. Gardening is not your strength, masters. That is a compliment.
|
||||||
|
coop.m18.title = The Last Fortress
|
||||||
|
coop.m18.story = Three fortresses guard the stairs down to the High Marshal's hall. They are the last thing between you and the end of the Lightwatch's crusade.\n\nRaze them all. Then the stairs are yours.
|
||||||
|
coop.m18.start = Three fortresses. They really did save the best for last.
|
||||||
|
coop.m18.goal = Raze the knight towers.
|
||||||
|
coop.m18.t1 = Each master a fortress, and the third one together. I will count.
|
||||||
|
coop.m18.t2 = I can see the stairs already. Dusty. Nobody comes down here voluntarily.
|
||||||
|
coop.m19.title = The High Marshal
|
||||||
|
coop.m19.story = The High Marshal of the Lightwatch himself, in armour polished for a hundred years. His guards come from north and south.\n\nBring him down, and the Order will have nobody left to give the orders.
|
||||||
|
coop.m19.start = The High Marshal. I have dreamed of this. Mostly the part where he falls over.
|
||||||
|
coop.m19.goal = Defeat the High Marshal.
|
||||||
|
coop.m19.boss = The High Marshal has fallen! His armour clatters like a dropped cutlery drawer.
|
||||||
|
coop.m19.t1 = He waits in the east. Let him wait. Clear the guards first.
|
||||||
|
coop.m19.t2 = Knights and archers again. The Order is running out of ideas.
|
||||||
|
coop.m20.title = Mossgrim's Fall
|
||||||
|
coop.m20.story = With the High Marshal gone, Mossgrim stands alone – up in the forest, in the sun, of all places. He has taken the shape of an ember wyrm and gathered every creature that still listens to him.\n\nClimb out of the rock and end the traitor. End his army. And then, masters - tea.
|
||||||
|
coop.m20.start = Mossgrim, you old fungus. The league sends its regards.
|
||||||
|
coop.m20.goal = Defeat Mossgrim and all his creatures.
|
||||||
|
coop.m20.boss = Mossgrim is no more. The moss will grow back. He will not.
|
||||||
|
coop.m20.t1 = That wyrm in the east is Mossgrim. Yes, he let himself go.
|
||||||
|
coop.m20.t2 = Every creature you defeat is one less that will tell this story wrong.
|
||||||
|
|
||||||
|
# achievements and statistics
|
||||||
|
menu.achievements = Achievements
|
||||||
|
ach.count = {0} of {1} achievements unlocked · {2} minutes played
|
||||||
|
ach.hidden = A secret. Crookfinger is not telling.
|
||||||
|
msg.achievement = Achievement unlocked: {0}!
|
||||||
|
end.stats2 = Heroes {0} · Creatures lost {1} · Tiles dug {2} · Gold {3} · Wood {4}
|
||||||
|
ach.heroes.name = Hero Bane {0}
|
||||||
|
ach.heroes.desc = Defeat {0} heroes.
|
||||||
|
ach.bosses.name = Champion Breaker {0}
|
||||||
|
ach.bosses.desc = Bring down {0} champions of the Order.
|
||||||
|
ach.dug.name = Deep Digger {0}
|
||||||
|
ach.dug.desc = Dig out {0} tiles.
|
||||||
|
ach.gold.name = Hoarder {0}
|
||||||
|
ach.gold.desc = Earn {0} gold.
|
||||||
|
ach.wood.name = Lumberlord {0}
|
||||||
|
ach.wood.desc = Fell {0} wood.
|
||||||
|
ach.walls.name = Mason {0}
|
||||||
|
ach.walls.desc = Raise {0} walls.
|
||||||
|
ach.rooms.name = Architect of Shadows {0}
|
||||||
|
ach.rooms.desc = Build {0} room tiles.
|
||||||
|
ach.spells.name = Dark Caster {0}
|
||||||
|
ach.spells.desc = Cast {0} spells.
|
||||||
|
ach.slaps.name = Firm Hand {0}
|
||||||
|
ach.slaps.desc = Slap your creatures {0} times. They love it. Probably.
|
||||||
|
ach.traps.name = Trapsmith {0}
|
||||||
|
ach.traps.desc = Order {0} traps and doors in the forge.
|
||||||
|
ach.towers.name = Tower Lord {0}
|
||||||
|
ach.towers.desc = Build {0} towers.
|
||||||
|
ach.drowned.name = Swimming Lessons {0}
|
||||||
|
ach.drowned.desc = Let {0} heroes fall into water or lava.
|
||||||
|
ach.burnt.name = Bridge Burner {0}
|
||||||
|
ach.burnt.desc = Burn down {0} bridge planks.
|
||||||
|
ach.siege.name = Siege Master {0}
|
||||||
|
ach.siege.desc = Build {0} siege engines.
|
||||||
|
ach.captured.name = Warden {0}
|
||||||
|
ach.captured.desc = Capture {0} heroes.
|
||||||
|
ach.drawbridge.name = Up She Goes {0}
|
||||||
|
ach.drawbridge.desc = Raise drawbridges {0} times.
|
||||||
|
ach.wins.name = Conqueror {0}
|
||||||
|
ach.wins.desc = Win {0} free games.
|
||||||
|
ach.missions.name = Shard Seeker {0}
|
||||||
|
ach.missions.desc = Win {0} campaign missions.
|
||||||
|
ach.coop.name = League Member {0}
|
||||||
|
ach.coop.desc = Win {0} co-op missions.
|
||||||
|
ach.minutes.name = Night Owl {0}
|
||||||
|
ach.minutes.desc = Play for {0} minutes.
|
||||||
|
ach.flawless.name = Not a Scratch
|
||||||
|
ach.flawless.desc = Win without losing a single creature.
|
||||||
|
ach.speedrun.name = Before the Tea Gets Cold
|
||||||
|
ach.speedrun.desc = Win within fifteen minutes.
|
||||||
|
ach.campaign.name = The One Master
|
||||||
|
ach.campaign.desc = Win the last mission of the campaign.
|
||||||
|
ach.surface.name = Sunburn
|
||||||
|
ach.surface.desc = Win a game that reaches the surface.
|
||||||
|
ach.defeat.name = Tea and Sympathy
|
||||||
|
ach.defeat.desc = Lose a game. Crookfinger puts the kettle on.
|
||||||
|
ach.legend.name = Living Legend
|
||||||
|
ach.legend.desc = A creature reaches level ten.
|
||||||
|
ach.wyrm.name = Here Be Dragons
|
||||||
|
ach.wyrm.desc = An ember wyrm joins your dungeon.
|
||||||
|
ach.fullhand.name = Hands Full
|
||||||
|
ach.fullhand.desc = Hold eight units in your hand at once.
|
||||||
|
|
||||||
|
# tutorial
|
||||||
|
menu.tutorial = Tutorial
|
||||||
|
tut.name = First Steps
|
||||||
|
tut.title = Crookfinger Shows You the Ropes
|
||||||
|
tut.story = So you are the new master. Small, but the core still beats, and that is what counts.\n\nI will show you the basics: digging, rooms, creatures, and what to do with knights. Do what I say, and nobody gets hurt. Except the knights.
|
||||||
|
tut.start = Crookfinger: First lesson - digging. Take the pick (key 1), drag over the earth east of the heart and your imps dig it out.
|
||||||
|
tut.obj1 = Dig out the tunnel east of the heart.
|
||||||
|
tut.dug = Crookfinger: There, a hall behind it! Your imps claim the ground by themselves. Now open Rooms and build a lair on your own floor - your creatures sleep there.
|
||||||
|
tut.obj2 = Build a lair (Rooms).
|
||||||
|
tut.lair = Crookfinger: Beds, check. Creatures also eat: build a mushroom garden next to it.
|
||||||
|
tut.obj3 = Build a mushroom garden (Rooms).
|
||||||
|
tut.farm = Crookfinger: Bed and board - now the portal sends you creatures. Be patient, they come by themselves. Wait for two.
|
||||||
|
tut.obj4 = Wait until two creatures have come through the portal.
|
||||||
|
tut.creatures = Crookfinger: Here they are! A little motivation never hurts: take the hand (key 2) and click a creature with Shift held down (on a touch screen: tap it twice) to slap it. It works faster for a while.
|
||||||
|
tut.obj5 = Slap one of your creatures (hand, Shift + click).
|
||||||
|
tut.slap = Crookfinger: Ha! Did you hear that? Something is coming from the east. Get ready: lightning (Spells) hits hard, and your creatures fight on their own.
|
||||||
|
tut.obj6 = Defeat the two knights.
|
||||||
|
tut.knights = Crookfinger: Two knights! Send your creatures with the attack flag (Orders) or strike them with lightning.
|
||||||
|
tut.win = Crookfinger: Well done, master! That is all there is to it. Well - almost. The campaign tells the rest. I'll put the kettle on.
|
||||||
|
|
||||||
|
# master talents
|
||||||
|
cat.master = Master
|
||||||
|
talent.title = Master talents
|
||||||
|
talent.points = Free talent points: {0}
|
||||||
|
talent.next = {0}/{1} experience to the next point
|
||||||
|
talent.all = Every talent is learned.
|
||||||
|
talent.builder = Builder
|
||||||
|
talent.warlord = Warlord
|
||||||
|
talent.warlock = Warlock
|
||||||
|
talent.merchant = Merchant
|
||||||
|
talent.builder.desc = Imps dig {0}% faster, rooms cost {1}% less.
|
||||||
|
talent.warlord.desc = Creatures hit {0}% harder and take {1}% less damage.
|
||||||
|
talent.warlock.desc = Mana flows {0}% faster, spells cost {1}% less.
|
||||||
|
talent.merchant.desc = Wages are {0}% lower, delivered treasure is worth {1}% more.
|
||||||
|
talent.none = Not learned yet.
|
||||||
|
talent.then = Next level: {0}
|
||||||
|
msg.talent_point = A new talent point! Spend it in the Master window.
|
||||||
|
msg.talent_learned = {0} level {1} learned.
|
||||||
|
|
||||||
|
# weather above ground
|
||||||
|
weather.fair = Fair
|
||||||
|
weather.rain = Rain
|
||||||
|
weather.fog = Fog
|
||||||
|
weather.storm = Storm
|
||||||
|
msg.weather_clear = The sky clears up above ground.
|
||||||
|
msg.weather_rain = It starts to rain above ground - fire will not catch.
|
||||||
|
msg.weather_fog = Fog rolls over the land - everyone outside sees less.
|
||||||
|
msg.weather_storm = A storm is brewing above ground! Lightning strikes units and trees.
|
||||||
|
msg.lightning_unit = Lightning struck: {0}!
|
||||||
|
# creature names
|
||||||
|
names.imp = Grubnik, Snot, Pickles, Scrabble, Dimple, Muck, Nibbles, Grot, Twitch, Smudge, Pebble, Wort, Scuttle, Fizzle, Gristle, Sniv, Burrow, Crumb, Dribble, Tinker
|
||||||
|
names.kobold = Yip, Skrag, Ratchet, Snagtooth, Kibble, Rusk, Nettle, Skiv, Gnash, Scrapper, Flint, Yark, Biter, Sprocket, Dreg, Skulk, Tatter, Grizz, Nab, Scabby
|
||||||
|
names.spider = Webbly, Itsy, Nibsy, Silkie, Creepkin, Tickle, Venna, Lacey, Spindle, Skitter, Fangsy, Mopsy, Widow, Tangles, Weaver, Pip, Gossamer, Twiddle, Snare, Muffet
|
||||||
|
names.mage = Bartholomew, Ignatius, Percival, Mordecai, Fennimore, Cornelius, Aldous, Thaddeus, Barnabus, Erasmus, Montague, Ambrose, Septimus, Horatio, Quentin, Lucan, Archibald, Florian, Zebulon, Crispin
|
||||||
|
names.ogre = Grunk, Thud, Bonk, Lump, Mudge, Klod, Hulk, Gorb, Dunk, Brog, Oaf, Plonk, Boulder, Grog, Mungo, Slab, Wump, Tonk, Duh, Gullet
|
||||||
|
names.jailer = Grimm, Lockjaw, Chains, Dredd, Morrow, Shackle, Vex, Bolt, Garrote, Stern, Rook, Hask, Iron, Grist, Clank, Mordo, Bleak, Snare, Kessel, Warden
|
||||||
|
names.priestess = Ember, Seraphine, Pyra, Ashlyn, Scarlet, Morwen, Vesper, Cinder, Ravenna, Lilith, Blaze, Isolde, Brimma, Nyx, Sable, Velvet, Flare, Ophelia, Hestia, Rowan
|
||||||
|
names.chainbreaker = Fetterbane, Ulfgar, Ironsoul, Brannoc, Kragmor, Valdrek, Thorn, Gorath, Unbound, Rurik, Torvald, Hagan, Durnok, Shatter, Brand, Wulfric, Korrun, Arvak, Stigr, Halvard
|
||||||
|
names.bat = Squeak, Flutter, Pippin, Nibs, Echo, Flap, Dusk, Zippy, Chirp, Fang, Twirl, Batsy, Mothy, Peep, Swoop, Dangle, Velvet, Tiddles, Squee, Blip
|
||||||
|
names.gargoyle = Granite, Slate, Basalt, Grimsby, Mortar, Cobble, Gravel, Flint, Grout, Rubble, Quartz, Pumice, Lintel, Gnarl, Stoneface, Dour, Gable, Spout, Chisel, Ledge
|
||||||
|
names.owl = Hootsworth, Sage, Quill, Barnaby, Minerva, Tobias, Hoots, Ollivant, Feathers, Wisp, Ponder, Elder, Mopsy, Noctua, Owlbert, Scroll, Dusk, Tawny, Professor, Ruffle
|
||||||
|
names.wyrm = Vermithrax, Ashmaw, Scorch, Calderon, Ignivar, Smoulder, Drakkar, Pyrrhus, Obsidian, Embermaw, Vulcanor, Searfang, Malgrath, Tyrax, Inferno, Brimstone, Kaldor, Fyrnak, Ragnor, Cindros
|
||||||
|
names.cogling = Gizmo, Sprocket, Widget, Ratchet, Bolt, Gearwin, Tinker, Doohickey, Spanner, Pinion, Clink, Fiddle, Rivet, Whirr, Cogsworth, Twiddle, Flange, Gadget, Ticktock, Nubbin
|
||||||
|
names.revenant = Morrow, Grieve, Ashen, Lament, Hollow, Mourn, Pallor, Sorrow, Wraithe, Dirge, Barrow, Gloam, Tomb, Weep, Vigil, Cairn, Shroud, Elegy, Dregs, Bleak
|
||||||
|
names.beast = Fuzzbert, Grizzle, Bruno, Shaggs, Chomper, Mossback, Bumble, Ursa, Burl, Crunch, Clicker, Scuttles, Bristle, Moss, Rumble, Bramble, Chitter, Grumbles, Hulkster, Snuffle
|
||||||
|
names.human = Gareth, Roland, Cedric, Aldric, Tristan, Edmund, Gawain, Lionel, Percy, Hugo, Bertram, Godfrey, Osric, Baldwin, Reginald, Wilfred, Ector, Gilbert, Rupert, Duncan
|
||||||
|
epithets.mid = Bonecrusher, Mudfoot, Ratbiter, Grimeface, Pebblechewer, Skullthumper, Stinkfist, Rustknuckle, Gutpuncher, Shinkicker, Mouldbeard, Dirtclaw, Snotslinger, Earchewer, Kneebreaker, Wormeater
|
||||||
|
epithets.high = Kingslayer, Doomhand, Dreadfang, Worldbreaker, Herobane, Stormfist, Nightterror, Soulreaper, Ironheart, Castlecrusher, Dragonbane, Hellstrider, Shadowlord, Bloodcrown, Grimtide, Starbreaker
|
||||||
|
name.epithet = {0} {1}
|
||||||
|
msg.creature_fallen = {0} has fallen. A memorial stone keeps the name.
|
||||||
|
tile.memorial = Memorial stone
|
||||||
|
tile.memorial_info = Here lies {0} ({1}, level {2}).
|
||||||
|
|
||||||
|
# hall of the fallen
|
||||||
|
hall.title = Hall of the fallen
|
||||||
|
hall.count = {0} fallen creatures remembered
|
||||||
|
hall.empty = No one has fallen yet. Long may it last.
|
||||||
|
hall.kills = {0} heroes slain
|
||||||
|
ov.fallen = Fallen ({0})
|
||||||
|
ov.living = Living
|
||||||
|
cause.slain = slain by: {0}
|
||||||
|
cause.lava = burned in lava
|
||||||
|
cause.water = drowned
|
||||||
|
cause.snare = caught in a snare
|
||||||
|
cause.lightning = struck by lightning
|
||||||
|
cause.fell = fell
|
||||||
@@ -53,17 +53,29 @@ msg.creature_arrived = Un {0} ha cruzado el portal.
|
|||||||
msg.payday_partial = ¡Día de paga! {0} criaturas cobran ({1} de oro), ¡{2} no reciben nada y están furiosas!
|
msg.payday_partial = ¡Día de paga! {0} criaturas cobran ({1} de oro), ¡{2} no reciben nada y están furiosas!
|
||||||
msg.payday = ¡Día de paga! {0} criaturas reciben {1} de oro.
|
msg.payday = ¡Día de paga! {0} criaturas reciben {1} de oro.
|
||||||
msg.wave_boss = Oleada {0}: ¡un Paladín de la Luz guía a {1} héroes hacia tu reino!
|
msg.wave_boss = Oleada {0}: ¡un Paladín de la Luz guía a {1} héroes hacia tu reino!
|
||||||
|
boss.aldric = Sir Aldric el Inflexible
|
||||||
|
boss.seraphine = Dama Seraphine del Alba
|
||||||
|
boss.halvard = Hermano Halvard el Piadoso
|
||||||
|
boss.rothgar = Capitán Rothgar Flanco de Hierro
|
||||||
|
msg.boss_comes = Un campeón de la Orden encabeza la oleada: ¡{0}!
|
||||||
|
msg.boss_slain = ¡{0} ha caído! Botín: {1} de oro y los planos de una torre de forja rúnica.
|
||||||
msg.wave = Oleada {0}: ¡{1} héroes atraviesan la puerta!
|
msg.wave = Oleada {0}: ¡{1} héroes atraviesan la puerta!
|
||||||
msg.gate_boss = ¡La puerta se desmorona! ¡El Guardián de la Puerta aparece con {0} defensores!
|
msg.gate_boss = ¡La puerta se desmorona! ¡El Guardián de la Puerta aparece con {0} defensores!
|
||||||
msg.gate_defenders = ¡La puerta invoca a {0} defensores!
|
msg.gate_defenders = ¡La puerta invoca a {0} defensores!
|
||||||
msg.level_up = {0} alcanza el nivel {1}.
|
msg.level_up = {0} alcanza el nivel {1}.
|
||||||
msg.hero_slain = ¡{0} derrotado! Botín: {1} de oro.
|
msg.hero_slain = ¡{0} derrotado! Botín: {1} de oro.
|
||||||
|
msg.thief_in_treasury = ¡Hay un ladrón en tu tesorería!
|
||||||
|
msg.thief_escaped = ¡Un ladrón ha escapado con {0} de oro!
|
||||||
|
msg.snare_caught = ¡Ha caído en un cepo: {0}!
|
||||||
|
msg.converted = ¡El inquisidor ha convertido a una de tus criaturas: {0}!
|
||||||
msg.creature_died = Tu {0} ha caído.
|
msg.creature_died = Tu {0} ha caído.
|
||||||
msg.portal_claimed = ¡El portal es tuyo! Atrae a las criaturas.
|
msg.portal_claimed = ¡El portal es tuyo! Atrae a las criaturas.
|
||||||
msg.flag_removed = Bandera de ataque retirada. Tus criaturas regresan.
|
msg.flag_removed = Bandera de ataque retirada. Tus criaturas regresan.
|
||||||
msg.flag_set = ¡Bandera de ataque colocada! Las criaturas listas para luchar se reúnen allí.
|
msg.flag_set = ¡Bandera de ataque colocada! Las criaturas listas para luchar se reúnen allí.
|
||||||
msg.creature_leaves = ¡Un {0} descontento abandona tu reino!
|
msg.creature_leaves = ¡Un {0} descontento abandona tu reino!
|
||||||
msg.no_gold = ¡No hay oro suficiente!
|
msg.no_gold = ¡No hay oro suficiente!
|
||||||
|
msg.no_wood = ¡No hay madera suficiente! Tala setas gigantes (o árboles) con el pico.
|
||||||
|
msg.siege_built = Un {0} está listo: pon la bandera de ataque para enviarlo.
|
||||||
msg.sold = Salas vendidas: +{0} de oro.
|
msg.sold = Salas vendidas: +{0} de oro.
|
||||||
msg.no_mana_bolt = No hay maná suficiente para el Rayo.
|
msg.no_mana_bolt = No hay maná suficiente para el Rayo.
|
||||||
msg.no_mana_heal = No hay maná suficiente para Curar.
|
msg.no_mana_heal = No hay maná suficiente para Curar.
|
||||||
@@ -73,6 +85,7 @@ msg.no_mana_cost = No hay maná suficiente (se necesitan {0}).
|
|||||||
hint.portal_no_lair = El portal está abierto, pero sin guarida no vendrá ninguna criatura.
|
hint.portal_no_lair = El portal está abierto, pero sin guarida no vendrá ninguna criatura.
|
||||||
hint.no_farm = Las criaturas quieren comer: construye un huerto de setas.
|
hint.no_farm = Las criaturas quieren comer: construye un huerto de setas.
|
||||||
hint.lair_full = Todas las camas de la guarida están ocupadas. Amplía tu guarida.
|
hint.lair_full = Todas las camas de la guarida están ocupadas. Amplía tu guarida.
|
||||||
|
hint.creature_max = Tu mazmorra alberga 100 criaturas: no vendrán más por el portal.
|
||||||
hint.gate_attack = ¡Tus criaturas atacan la Puerta de los Héroes!
|
hint.gate_attack = ¡Tus criaturas atacan la Puerta de los Héroes!
|
||||||
hint.treasury_full = ¡Tus tesorerías están llenas! Construye más casillas de tesorería.
|
hint.treasury_full = ¡Tus tesorerías están llenas! Construye más casillas de tesorería.
|
||||||
hint.flag_unreachable = Tus criaturas no pueden llegar a la bandera de ataque.
|
hint.flag_unreachable = Tus criaturas no pueden llegar a la bandera de ataque.
|
||||||
@@ -124,6 +137,7 @@ ui.key = Tecla: {0}
|
|||||||
|
|
||||||
# ---- HUD
|
# ---- HUD
|
||||||
hud.gold = Oro
|
hud.gold = Oro
|
||||||
|
hud.wood = Madera
|
||||||
hud.mana = Maná
|
hud.mana = Maná
|
||||||
hud.core = Núcleo
|
hud.core = Núcleo
|
||||||
hud.gate = Puerta
|
hud.gate = Puerta
|
||||||
@@ -145,6 +159,7 @@ help.k_right = Clic derecho Soltar unidad (prisioneros: también en la cámar
|
|||||||
help.k_slap = Mayús+Izq. Mano: abofetear unidades propias, golpear héroes en tu suelo
|
help.k_slap = Mayús+Izq. Mano: abofetear unidades propias, golpear héroes en tu suelo
|
||||||
help.k_pan = Arrastrar dcho. Mover el mapa (también WASD/flechas, borde de pantalla)
|
help.k_pan = Arrastrar dcho. Mover el mapa (también WASD/flechas, borde de pantalla)
|
||||||
help.k_rot = Arrastrar medio Rotar e inclinar la cámara (también Q/E, R/F)
|
help.k_rot = Arrastrar medio Rotar e inclinar la cámara (también Q/E, R/F)
|
||||||
|
help.k_reset = Medio 2x clic Restablecer ángulo y giro (el lugar se queda)
|
||||||
help.k_zoom = Rueda del ratón Zoom
|
help.k_zoom = Rueda del ratón Zoom
|
||||||
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Elegir herramienta
|
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Elegir herramienta
|
||||||
help.k_misc = P / Espacio Pausa M Sonido sí/no +/- Volumen
|
help.k_misc = P / Espacio Pausa M Sonido sí/no +/- Volumen
|
||||||
@@ -237,6 +252,7 @@ set.lights = Fuentes de luz
|
|||||||
set.ui_scale = Tamaño de interfaz
|
set.ui_scale = Tamaño de interfaz
|
||||||
set.game_speed = Velocidad de juego
|
set.game_speed = Velocidad de juego
|
||||||
set.hints = Consejos
|
set.hints = Consejos
|
||||||
|
set.events = Sucesos aleatorios
|
||||||
set.keys_hint = Haz clic en una acción y pulsa una tecla. Las teclas ya usadas se intercambian.
|
set.keys_hint = Haz clic en una acción y pulsa una tecla. Las teclas ya usadas se intercambian.
|
||||||
set.press_key = Pulsa una tecla para: {0}
|
set.press_key = Pulsa una tecla para: {0}
|
||||||
set.esc_cancel = Esc cancela
|
set.esc_cancel = Esc cancela
|
||||||
@@ -248,6 +264,8 @@ val.medium = Medio
|
|||||||
val.high = Alto
|
val.high = Alto
|
||||||
val.normal = Normal
|
val.normal = Normal
|
||||||
val.large = Grande
|
val.large = Grande
|
||||||
|
val.small = Pequeño
|
||||||
|
val.auto_plain = Automático
|
||||||
|
|
||||||
# ---- key binding names
|
# ---- key binding names
|
||||||
key.cam_forward = Cámara adelante
|
key.cam_forward = Cámara adelante
|
||||||
@@ -313,12 +331,30 @@ hint.tower_max = Esta torre ya está mejorada al máximo.
|
|||||||
hint.tower_where = Las torres solo pueden construirse en casillas de suelo reclamadas y vacías.
|
hint.tower_where = Las torres solo pueden construirse en casillas de suelo reclamadas y vacías.
|
||||||
hint.tower_mana = Tus torres mágicas se han quedado sin maná.
|
hint.tower_mana = Tus torres mágicas se han quedado sin maná.
|
||||||
job.repair = Reparando una torre
|
job.repair = Reparando una torre
|
||||||
|
job.mend_bridge = Repara un puente
|
||||||
|
job.raise_wall = Levanta un muro
|
||||||
|
job.march = Marcha hacia una espada
|
||||||
job.attack_tower = Atacando una torre
|
job.attack_tower = Atacando una torre
|
||||||
|
|
||||||
unit.jailer = Carcelero
|
unit.jailer = Carcelero
|
||||||
unit.priestess = Sacerdotisa de llamas
|
unit.priestess = Sacerdotisa de llamas
|
||||||
msg.hero_captured = ¡{0} noqueado y hecho prisionero! Botín: {1} de oro.
|
msg.hero_captured = ¡{0} noqueado y hecho prisionero! Botín: {1} de oro.
|
||||||
msg.defected = ¡{0} se ha pasado a tu bando!
|
msg.defected = ¡{0} se ha pasado a tu bando!
|
||||||
|
msg.beast_tamed = ¡Un {0} salvaje se rinde y se une a tu mazmorra!
|
||||||
|
gear.1 = equipo de hierro
|
||||||
|
gear.2 = equipo de acero
|
||||||
|
gear.3 = equipo rúnico
|
||||||
|
msg.gear_given = La forja entrega {0} a: {1}.
|
||||||
|
msg.trader_arrives = Un mercader ha cruzado tu portal: {0} por {1} de oro. Haz clic en él con la mano para comprar; espera un minuto.
|
||||||
|
msg.trader_sold = Comprado: {0}. Un placer, dice el mercader.
|
||||||
|
msg.trader_left = El mercader se encoge de hombros y se marcha por el portal.
|
||||||
|
trader.info = Ofrece {0} por {1} de oro (clic con la mano)
|
||||||
|
trader.left = Se va en {0} s
|
||||||
|
trader.rune = los planos de una torre de forja rúnica
|
||||||
|
trader.wyrm = una sierpe de brasas
|
||||||
|
trader.chainbreaker = un rompecadenas
|
||||||
|
trader.wood = 30 de madera
|
||||||
|
trader.mana = una poción que llena tu maná
|
||||||
msg.prisoner_died = {0} ha muerto bajo tortura.
|
msg.prisoner_died = {0} ha muerto bajo tortura.
|
||||||
job.carry = Llevando a un prisionero
|
job.carry = Llevando a un prisionero
|
||||||
job.pray = Rezando
|
job.pray = Rezando
|
||||||
@@ -329,6 +365,10 @@ info.tortured = siendo torturado
|
|||||||
info.prison = Prisioneros: {0}/{1}
|
info.prison = Prisioneros: {0}/{1}
|
||||||
|
|
||||||
msg.wave_warn_dir = Los exploradores informan: ¡la oleada de héroes {0} llega a la puerta en 30 segundos! Planean entrar por el {1}.
|
msg.wave_warn_dir = Los exploradores informan: ¡la oleada de héroes {0} llega a la puerta en 30 segundos! Planean entrar por el {1}.
|
||||||
|
msg.event_quake = ¡Terremoto! Se han derrumbado túneles en el {0}.
|
||||||
|
msg.event_lava = ¡La lava se desborda en el {0}!
|
||||||
|
msg.event_flood = ¡Inundación! El río se desborda en el {0}.
|
||||||
|
msg.event_vein = ¡Los diablillos informan de una nueva veta de oro en el {0}!
|
||||||
msg.wave_warn_two = Los exploradores informan: ¡la oleada de héroes {0} llega a la puerta en 30 segundos! Se dividen y entran por el {1} y por el {2}.
|
msg.wave_warn_two = Los exploradores informan: ¡la oleada de héroes {0} llega a la puerta en 30 segundos! Se dividen y entran por el {1} y por el {2}.
|
||||||
dir.n = norte
|
dir.n = norte
|
||||||
dir.ne = nordeste
|
dir.ne = nordeste
|
||||||
@@ -340,7 +380,8 @@ dir.w = oeste
|
|||||||
dir.nw = noroeste
|
dir.nw = noroeste
|
||||||
|
|
||||||
val.auto = Auto ({0}x)
|
val.auto = Auto ({0}x)
|
||||||
set.render_scale = Resolución 3D
|
set.render_scale = Aspecto pixelado
|
||||||
|
set.pixel_rows_val = {0} líneas
|
||||||
set.debug_fps = Mostrar FPS
|
set.debug_fps = Mostrar FPS
|
||||||
set.debug_frame_time = Mostrar ms por fotograma
|
set.debug_frame_time = Mostrar ms por fotograma
|
||||||
set.debug_ping = Mostrar ping
|
set.debug_ping = Mostrar ping
|
||||||
@@ -398,8 +439,25 @@ help.start_touch = Toca para empezar
|
|||||||
tool.bridge.label = Puente
|
tool.bridge.label = Puente
|
||||||
tool.bridge.name = Construir puente
|
tool.bridge.name = Construir puente
|
||||||
tool.bridge.desc = Construye un puente sobre agua o lava. Arrastra desde tu propio suelo a través del río; el puente crece casilla a casilla desde tu territorio.
|
tool.bridge.desc = Construye un puente sobre agua o lava. Arrastra desde tu propio suelo a través del río; el puente crece casilla a casilla desde tu territorio.
|
||||||
cost.bridge = {0} de oro/casilla sobre agua, {1} sobre lava
|
tool.drawbridge.label = Levadizo
|
||||||
|
tool.drawbridge.name = Construir puente levadizo
|
||||||
|
tool.drawbridge.desc = Un puente que puedes alzar: arrástralo sobre el río para construirlo y haz clic para cambiar: abajo, arriba, automático (se alza cuando se acercan enemigos). La madera sobre agua arde, la piedra sobre lava no.
|
||||||
|
tool.wall.label = Muro
|
||||||
|
tool.wall.name = Levantar muro
|
||||||
|
tool.wall.desc = Planea muros en tu propio suelo vacío (arrastra otra vez para cancelar): los diablillos los levantan como tierra reforzada. Cierra túneles y pon puertas donde importan.
|
||||||
|
tool.ram.label = Ariete
|
||||||
|
tool.ram.name = Construir ariete
|
||||||
|
tool.ram.desc = Un ariete sale de tu forja (oro y madera). Sigue tu bandera de ataque y destroza la puerta y las torres de los caballeros.
|
||||||
|
tool.catapult.label = Catapulta
|
||||||
|
tool.catapult.name = Construir catapulta
|
||||||
|
tool.catapult.desc = Una catapulta sale de tu forja (oro y madera). Dispara a las torres de los caballeros desde lejos y sigue tu bandera hasta su puerta.
|
||||||
|
cost.bridge = {0} oro + {1} madera/casilla sobre agua, {2} oro sobre lava
|
||||||
|
cost.drawbridge = {0} oro + {1} madera/casilla sobre agua, {2} oro sobre lava
|
||||||
|
cost.gold_wood = {0} oro + {1} madera
|
||||||
hint.bridge_where = Los puentes deben empezar en tu propio suelo: arrastra desde tu territorio a través del río.
|
hint.bridge_where = Los puentes deben empezar en tu propio suelo: arrastra desde tu territorio a través del río.
|
||||||
|
hint.bridge_fire = Los puentes de madera arden: aleja de ellos las trampas de fuego, el aliento de fuego y los rayos; los puentes de piedra sobre lava son ignífugos.
|
||||||
|
hint.wall_where = Solo puedes levantar muros en tu propio suelo vacío (sin sala, trampa ni puerta).
|
||||||
|
hint.siege_where = Las máquinas de asedio salen en tu propio suelo libre, y necesitas una forja.
|
||||||
hint.bridge_river = Los puentes solo pueden construirse sobre agua o lava.
|
hint.bridge_river = Los puentes solo pueden construirse sobre agua o lava.
|
||||||
msg.hero_burned = ¡Un {0} ha caído en la lava! +{1} de oro.
|
msg.hero_burned = ¡Un {0} ha caído en la lava! +{1} de oro.
|
||||||
msg.creature_burned = Tu {0} ha caído en la lava.
|
msg.creature_burned = Tu {0} ha caído en la lava.
|
||||||
@@ -409,6 +467,22 @@ tile.lava = Lava
|
|||||||
tile.lava_info = Lo que cae dentro arde. Los puentes de piedra lo cruzan.
|
tile.lava_info = Lo que cae dentro arde. Los puentes de piedra lo cruzan.
|
||||||
tile.bridge = Puente de madera
|
tile.bridge = Puente de madera
|
||||||
tile.lavabridge = Puente de piedra
|
tile.lavabridge = Puente de piedra
|
||||||
|
tile.mushroom = Seta gigante
|
||||||
|
tile.mushroom_grown = Adulto: listo para talar.
|
||||||
|
tile.mushroom_growing = Creciendo: {0}% - solo se puede talar cuando esté adulto.
|
||||||
|
tile.grove = Bosquecillo de setas
|
||||||
|
tile.grove_info = Nadie puede conquistar este musgo. Aquí vuelven a crecer setas gigantes.
|
||||||
|
tile.tree = Árbol
|
||||||
|
tile.plant_info = Márcalo con el pico: un diablillo lo tala por {0} de madera.
|
||||||
|
tile.drawbridge = Puente levadizo
|
||||||
|
tile.drawbridge_up = Alzado
|
||||||
|
tile.drawbridge_down = Bajado
|
||||||
|
tile.drawbridge_auto = Automático (abajo)
|
||||||
|
tile.drawbridge_auto_up = Automático (arriba)
|
||||||
|
tile.obsidian = Obsidiana
|
||||||
|
tile.obsidian_info = Lava enfriada: se funde en {0} s
|
||||||
|
tile.bridge_burning = ¡Arde!
|
||||||
|
tile.bridge_worn = Dañado {0}%
|
||||||
tile.shard = Fragmento de tu núcleo de alma
|
tile.shard = Fragmento de tu núcleo de alma
|
||||||
tile.shard_info = Reclama esta cámara para recuperar el fragmento.
|
tile.shard_info = Reclama esta cámara para recuperar el fragmento.
|
||||||
|
|
||||||
@@ -430,6 +504,11 @@ msg.enemy_tower_destroyed = ¡Ha caído una {0} de los caballeros!
|
|||||||
msg.vault_taken = Cámara acorazada tomada: {0} en tesoros son tuyos.
|
msg.vault_taken = Cámara acorazada tomada: {0} en tesoros son tuyos.
|
||||||
msg.shard_taken = ¡Un fragmento de tu núcleo de alma vuelve a estar en tus manos!
|
msg.shard_taken = ¡Un fragmento de tu núcleo de alma vuelve a estar en tus manos!
|
||||||
msg.bridge_torn = ¡Los caballeros han derribado uno de tus puentes!
|
msg.bridge_torn = ¡Los caballeros han derribado uno de tus puentes!
|
||||||
|
msg.bridge_fire = ¡Uno de tus puentes está en llamas!
|
||||||
|
msg.drawbridge_up = El puente levadizo está alzado.
|
||||||
|
msg.drawbridge_down = El puente levadizo está bajado.
|
||||||
|
msg.drawbridge_auto = El puente levadizo se alza solo cuando se acercan enemigos.
|
||||||
|
msg.drawbridge_rises = ¡Se acercan enemigos: un puente levadizo se alza!
|
||||||
job.tear_bridge = Derribando un puente
|
job.tear_bridge = Derribando un puente
|
||||||
job.patrol = De patrulla
|
job.patrol = De patrulla
|
||||||
job.guard = De guardia
|
job.guard = De guardia
|
||||||
@@ -533,7 +612,7 @@ camp.m29.story = Islas de lava, amo, con fuentes de maná hirviendo en el centro
|
|||||||
camp.m20.title = El Gran Maestre
|
camp.m20.title = El Gran Maestre
|
||||||
camp.m20.story = El Gran Maestre Valerian Escudoclaro espera en su ciudadela al otro lado del gran río, con el último fragmento y la puerta de la orden. Lleva cien años rezando, lustrando y preparándose. | Destruye su puerta y recupera el fragmento. Entonces tu núcleo estará completo, y todos los señores que alguna vez lo quisieron vendrán a por él a la vez. Cada cosa a su tiempo.
|
camp.m20.story = El Gran Maestre Valerian Escudoclaro espera en su ciudadela al otro lado del gran río, con el último fragmento y la puerta de la orden. Lleva cien años rezando, lustrando y preparándose. | Destruye su puerta y recupera el fragmento. Entonces tu núcleo estará completo, y todos los señores que alguna vez lo quisieron vendrán a por él a la vez. Cada cosa a su tiempo.
|
||||||
camp.m30.title = El trono de las profundidades
|
camp.m30.title = El trono de las profundidades
|
||||||
camp.m30.story = Pasó lo que dije: tu núcleo está completo y ahora todo el mundo lo quiere. Mossgrim y Lady Nightwell marchan juntos, y lo que queda de la Orden abre su puerta por última vez. | Destruye ambos núcleos de señores y la puerta de la orden. Después de esto, las profundidades tendrán exactamente un amo. Adivina quién. Pista: yo no, yo soy el del té.
|
camp.m30.story = Pasó lo que dije: tu núcleo está entero y ahora todos lo quieren. Mossgrim y Lady Nightwell han excavado hasta la superficie, y el Alto Mariscal espera en su fortaleza del bosque: luz del día, pájaros, todo el lote. Asqueroso. | Sal de la roca, destruye los dos núcleos de los maestros y la puerta de la orden dentro de la fortaleza. Después, el mundo tendrá exactamente un maestro. Adivina quién. Pista: yo no, yo soy el del té.
|
||||||
camp.epilogue = El núcleo verde se desmorona, el azul se apaga y la puerta de la orden enmudece. En algún lugar de arriba, la Guardia Luminosa redacta un informe larguísimo, y las cartas de Lady Nightwell han dejado de llegar. | Dedotuerto ha preparado té. Vuelves a estar completo, amo. El inframundo es tuyo.
|
camp.epilogue = El núcleo verde se desmorona, el azul se apaga y la puerta de la orden enmudece. En algún lugar de arriba, la Guardia Luminosa redacta un informe larguísimo, y las cartas de Lady Nightwell han dejado de llegar. | Dedotuerto ha preparado té. Vuelves a estar completo, amo. El inframundo es tuyo.
|
||||||
lair.arrivals = {0} camas libres: las nuevas criaturas llegan al {1}% de velocidad
|
lair.arrivals = {0} camas libres: las nuevas criaturas llegan al {1}% de velocidad
|
||||||
job.help = Ayudando
|
job.help = Ayudando
|
||||||
@@ -657,6 +736,7 @@ net.error = {0}
|
|||||||
net.rooms = Partidas abiertas
|
net.rooms = Partidas abiertas
|
||||||
net.no_rooms = No hay partidas abiertas.
|
net.no_rooms = No hay partidas abiertas.
|
||||||
net.create = Crear partida
|
net.create = Crear partida
|
||||||
|
net.map_size = Mapa {0}x{1} · {2} jugadores
|
||||||
net.coop_mission = Campaña cooperativa: misión {0} ({1})
|
net.coop_mission = Campaña cooperativa: misión {0} ({1})
|
||||||
net.coop_create = Campaña coop.
|
net.coop_create = Campaña coop.
|
||||||
net.join = Unirse
|
net.join = Unirse
|
||||||
@@ -687,6 +767,9 @@ net.ai_off = No
|
|||||||
|
|
||||||
# rival masters (free game)
|
# rival masters (free game)
|
||||||
newgame.difficulty = Rivales
|
newgame.difficulty = Rivales
|
||||||
|
newgame.land = Terreno
|
||||||
|
newgame.land0 = Subterráneo
|
||||||
|
newgame.land1 = Mitad en superficie
|
||||||
diff.0 = Fácil
|
diff.0 = Fácil
|
||||||
diff.1 = Normal
|
diff.1 = Normal
|
||||||
diff.2 = Difícil
|
diff.2 = Difícil
|
||||||
@@ -699,9 +782,14 @@ msg.rival1_attack = ¡Mossgrim, el señor verde, envía {0} criaturas contra ti!
|
|||||||
msg.rival2_attack = ¡Lady Nightwell, la señora azul, envía {0} criaturas contra ti!
|
msg.rival2_attack = ¡Lady Nightwell, la señora azul, envía {0} criaturas contra ti!
|
||||||
msg.rival1_out = Mossgrim ha sido derrotado: ¡su núcleo de alma se ha hecho añicos!
|
msg.rival1_out = Mossgrim ha sido derrotado: ¡su núcleo de alma se ha hecho añicos!
|
||||||
msg.rival2_out = Lady Nightwell ha sido derrotada: ¡su núcleo de alma se ha hecho añicos!
|
msg.rival2_out = Lady Nightwell ha sido derrotada: ¡su núcleo de alma se ha hecho añicos!
|
||||||
|
msg.rival3_attack = ¡Saffrax, el amo amarillo, envía {0} criaturas contra ti!
|
||||||
|
msg.rival3_out = Saffrax ha sido derrotado: ¡su núcleo de alma se ha hecho añicos!
|
||||||
msg.rival_unit_slain = ¡Cae un {0} de un señor rival! +{1} de oro.
|
msg.rival_unit_slain = ¡Cae un {0} de un señor rival! +{1} de oro.
|
||||||
msg.wave_on_rival1 = Oleada {0}: {1} héroes marchan contra el señor verde.
|
msg.wave_on_rival1 = Oleada {0}: {1} héroes marchan contra el señor verde.
|
||||||
msg.wave_on_rival2 = Oleada {0}: {1} héroes marchan contra la señora azul.
|
msg.wave_on_rival2 = Oleada {0}: {1} héroes marchan contra la señora azul.
|
||||||
|
msg.wave_warn_on_master = Los exploradores informan: la oleada {0} llega en 30 segundos y va a por el maestro {1}.
|
||||||
|
msg.wave_on_master = Oleada {0}: {1} héroes marchan contra el maestro {2}.
|
||||||
|
msg.partner_out = ¡El corazón de tu socio IA ha caído: sigue luchando solo!
|
||||||
hud.rival1 = Verde
|
hud.rival1 = Verde
|
||||||
hud.rival2 = Azul
|
hud.rival2 = Azul
|
||||||
msg.creature_freed = ¡Un {0} rompe sus cadenas y se une a ti!
|
msg.creature_freed = ¡Un {0} rompe sus cadenas y se une a ti!
|
||||||
@@ -790,6 +878,8 @@ best.b.torture.text = Los héroes capturados acaban aquí. Con tiempo y un carce
|
|||||||
best.b.temple.text = Las oraciones curan el núcleo de alma y mantienen alta la moral. La sacerdotisa de llamas viene por un templo.
|
best.b.temple.text = Las oraciones curan el núcleo de alma y mantienen alta la moral. La sacerdotisa de llamas viene por un templo.
|
||||||
best.b.aerie.text = Perchas y nidos para criaturas voladoras. Murciélagos, gárgolas, búhos e incluso el dragón de brasas necesitan sitio aquí.
|
best.b.aerie.text = Perchas y nidos para criaturas voladoras. Murciélagos, gárgolas, búhos e incluso el dragón de brasas necesitan sitio aquí.
|
||||||
best.b.bridge.text = Madera sobre agua, piedra sobre lava. Los caballeros derriban tus puentes: reclama los suyos.
|
best.b.bridge.text = Madera sobre agua, piedra sobre lava. Los caballeros derriban tus puentes: reclama los suyos.
|
||||||
|
best.b.drawbridge.text = Álzalo y el río vuelve a ser un muro. En automático se alza cuando se acercan enemigos y baja para tus criaturas.
|
||||||
|
best.b.wall.text = Un túnel excavado no es para siempre: tápialo y deja a los héroes un solo camino, a través de tu puerta.
|
||||||
best.b.steel.text = Placas de hierro remachadas sobre un muro reforzado. Nadie lo atraviesa rápido, ni siquiera los enanos.
|
best.b.steel.text = Placas de hierro remachadas sobre un muro reforzado. Nadie lo atraviesa rápido, ni siquiera los enanos.
|
||||||
best.b.arrow.text = La flecha atraviesa hasta dos enemigos más detrás del objetivo. Barata y fiable.
|
best.b.arrow.text = La flecha atraviesa hasta dos enemigos más detrás del objetivo. Barata y fiable.
|
||||||
best.b.cannon.text = Daño en área y retroceso: empuja a los caballeros a la lava.
|
best.b.cannon.text = Daño en área y retroceso: empuja a los caballeros a la lava.
|
||||||
@@ -801,7 +891,7 @@ room.forge = Forja
|
|||||||
tool.forge.label = Forja
|
tool.forge.label = Forja
|
||||||
tool.forge.desc = Las criaturas fabrican aquí trampas y puertas: trabajo, no oro. Las piezas terminadas esperan en cajas hasta que un diablillo las instala. Con una forja de 6+ casillas vienen engranajillos.
|
tool.forge.desc = Las criaturas fabrican aquí trampas y puertas: trabajo, no oro. Las piezas terminadas esperan en cajas hasta que un diablillo las instala. Con una forja de 6+ casillas vienen engranajillos.
|
||||||
unit.cogling = Engranajillo
|
unit.cogling = Engranajillo
|
||||||
cat.traps = Trampas
|
cat.traps = Construir
|
||||||
cost.work = {0} de trabajo en la forja
|
cost.work = {0} de trabajo en la forja
|
||||||
job.forge = Forjando
|
job.forge = Forjando
|
||||||
job.install = Instalando una trampa
|
job.install = Instalando una trampa
|
||||||
@@ -878,6 +968,7 @@ research.now = Investigando: {0} ({1}%)
|
|||||||
research.next = después: {0}
|
research.next = después: {0}
|
||||||
research.done = Aquí no queda nada por investigar.
|
research.done = Aquí no queda nada por investigar.
|
||||||
msg.researched = Investigado: ¡{0}! Ya está en el menú de construcción.
|
msg.researched = Investigado: ¡{0}! Ya está en el menú de construcción.
|
||||||
|
msg.room_upgraded = Investigación: {0} alcanza el nivel {1}: funciona un cuarto mejor, y se nota.
|
||||||
msg.tool_research = Aún no investigado: las criaturas que estudian en la biblioteca trabajan en ello.
|
msg.tool_research = Aún no investigado: las criaturas que estudian en la biblioteca trabajan en ello.
|
||||||
|
|
||||||
msg.script = {0}
|
msg.script = {0}
|
||||||
@@ -915,6 +1006,9 @@ tool.plague.desc = Una enfermedad que corroe a los enemigos alrededor del punto
|
|||||||
tool.quake.label = Temblor
|
tool.quake.label = Temblor
|
||||||
tool.quake.name = Terremoto
|
tool.quake.name = Terremoto
|
||||||
tool.quake.desc = La tierra tiembla: los enemigos alrededor del punto sufren daño y quedan aturdidos dos segundos. Tus criaturas no pierden el equilibrio.
|
tool.quake.desc = La tierra tiembla: los enemigos alrededor del punto sufren daño y quedan aturdidos dos segundos. Tus criaturas no pierden el equilibrio.
|
||||||
|
tool.freeze.label = Escarcha
|
||||||
|
tool.freeze.name = Escarcha
|
||||||
|
tool.freeze.desc = Frío glacial: la lava alrededor se enfría en obsidiana transitable durante 40 segundos; los enemigos quedan helados y ralentizados.
|
||||||
tile.unexplored = Oscuridad inexplorada
|
tile.unexplored = Oscuridad inexplorada
|
||||||
|
|
||||||
# personality
|
# personality
|
||||||
@@ -967,6 +1061,9 @@ tool.arena.desc = Las criaturas combaten por parejas: el doble de experiencia qu
|
|||||||
tool.altar.label = Altar
|
tool.altar.label = Altar
|
||||||
tool.altar.desc = Suelta una criatura sobre el altar para sacrificarla. Ciertas parejas de sacrificios traen una criatura rara; si no, la mazmorra queda bendecida: mejor ánimo y maná.
|
tool.altar.desc = Suelta una criatura sobre el altar para sacrificarla. Ciertas parejas de sacrificios traen una criatura rara; si no, la mazmorra queda bendecida: mejor ánimo y maná.
|
||||||
unit.revenant = Aparecido
|
unit.revenant = Aparecido
|
||||||
|
unit.cavebear = Oso cavernario
|
||||||
|
unit.beetle = Escarabajo gigante
|
||||||
|
unit.trader = Mercader
|
||||||
job.guard_post = De guardia
|
job.guard_post = De guardia
|
||||||
job.spar = Combatiendo
|
job.spar = Combatiendo
|
||||||
msg.revenant_rises = ¡Un aparecido se levanta del cementerio y te sirve!
|
msg.revenant_rises = ¡Un aparecido se levanta del cementerio y te sirve!
|
||||||
@@ -975,6 +1072,10 @@ msg.altar_gift = El altar acepta la pareja: ¡aparece un {0}!
|
|||||||
msg.altar_blessing = El {0} ha sido sacrificado. La mazmorra queda bendecida.
|
msg.altar_blessing = El {0} ha sido sacrificado. La mazmorra queda bendecida.
|
||||||
best.revenant.lure = se levanta del cementerio
|
best.revenant.lure = se levanta del cementerio
|
||||||
best.revenant.text = Un aparecido no come, no duerme y no se queja: el empleado perfecto, si soportas el olor. | Con algo de experiencia, absorbe la fuerza de aquellos a quienes golpea. Él y la sacerdotisa no se llevan bien, por razones obvias.
|
best.revenant.text = Un aparecido no come, no duerme y no se queja: el empleado perfecto, si soportas el olor. | Con algo de experiencia, absorbe la fuerza de aquellos a quienes golpea. Él y la sacerdotisa no se llevan bien, por razones obvias.
|
||||||
|
best.cavebear.lure = vencerlo en su guarida teniendo una prisión
|
||||||
|
best.cavebear.text = Un oso que nunca vio el sol y no lo echa de menos. Peludo, fuerte y gruñón hasta el desayuno. | Véncelo en su guarida mientras haya sitio en una prisión: se rinde y se une a ti. Le gusta el huerto de setas, y las arañas, de tentempié.
|
||||||
|
best.beetle.lure = vencerlo en su guarida teniendo una prisión
|
||||||
|
best.beetle.text = Un escarabajo del tamaño de un barril, acorazado como un caballero y el doble de educado. | Véncelo en su guarida mientras haya sitio en una prisión y se unirá a ti. Duerme en la guarida y no soporta a los murciélagos.
|
||||||
best.b.prison.text = Hileras de celdas. Los héroes se pudren aquí en silencio hasta que alguien decide qué hacer con ellos.
|
best.b.prison.text = Hileras de celdas. Los héroes se pudren aquí en silencio hasta que alguien decide qué hacer con ellos.
|
||||||
best.b.graveyard.text = Musgo, lápidas y tierra fresca. Aquí abajo nadie sigue muerto mucho tiempo.
|
best.b.graveyard.text = Musgo, lápidas y tierra fresca. Aquí abajo nadie sigue muerto mucho tiempo.
|
||||||
best.b.guardpost.text = Un brasero, un estandarte y una criatura aburrida de vista aguda.
|
best.b.guardpost.text = Un brasero, un estandarte y una criatura aburrida de vista aguda.
|
||||||
@@ -987,23 +1088,39 @@ ability.drain.desc = se cura un tercio del daño que inflige.
|
|||||||
unit.giant = Gigante
|
unit.giant = Gigante
|
||||||
unit.fairy = Hada espía
|
unit.fairy = Hada espía
|
||||||
unit.battlemage = Mago de batalla
|
unit.battlemage = Mago de batalla
|
||||||
|
unit.thief = Ladrón
|
||||||
|
unit.monk = Monje
|
||||||
|
unit.ranger = Explorador
|
||||||
|
unit.inquisitor = Inquisidor
|
||||||
|
unit.ram = Ariete
|
||||||
|
unit.catapult = Catapulta
|
||||||
msg.trap_wrecked = Los héroes han destrozado una trampa: {0}.
|
msg.trap_wrecked = Los héroes han destrozado una trampa: {0}.
|
||||||
best.giant.text = Un gigante es lo que pasa cuando un caballero se come la verdura durante trescientos años. Lento, enorme, y trata las puertas como una sugerencia. | Destroza trampas con solo plantarse a su lado. Modales repugnantes. Golpéalo con todo, a ser posible desde lejos.
|
best.giant.text = Un gigante es lo que pasa cuando un caballero se come la verdura durante trescientos años. Lento, enorme, y trata las puertas como una sugerencia. | Destroza trampas con solo plantarse a su lado. Modales repugnantes. Golpéalo con todo, a ser posible desde lejos.
|
||||||
best.fairy.text = La pequeña chivata de la Orden: un mosquito brillante con alas, enviado por delante para encontrar tus trampas y contárselo a todos. Cuando ha visto una trampa, los héroes la destrozan. | También rocía polvo curativo sobre los héroes. Aplástala primero. Dos veces, por si acaso.
|
best.fairy.text = La pequeña chivata de la Orden: un mosquito brillante con alas, enviado por delante para encontrar tus trampas y contárselo a todos. Cuando ha visto una trampa, los héroes la destrozan. | También rocía polvo curativo sobre los héroes. Aplástala primero. Dos veces, por si acaso.
|
||||||
best.battlemage.text = Un mago con el hábito de la Orden que considera los muros un insulto personal. Los muros reforzados revientan bajo sus bolas de fuego, y nunca deja de hablar de ello. | Frágil de cerca. Lo que más le fastidia son los muros de acero. Bien.
|
best.battlemage.text = Un mago con el hábito de la Orden que considera los muros un insulto personal. Los muros reforzados revientan bajo sus bolas de fuego, y nunca deja de hablar de ello. | Frágil de cerca. Lo que más le fastidia son los muros de acero. Bien.
|
||||||
|
best.thief.text = Dedos rápidos, pies aún más rápidos. No viene a luchar: se cuela en tu tesorería, llena su saco y corre de vuelta a la puerta. | Mátalo antes de que escape y el oro vuelve a ser tuyo. Con intereses.
|
||||||
|
best.monk.text = Años de ayuno y oración lo han vuelto sordo a tu magia: el rayo, la plaga y el frío le resbalan como la lluvia por un tejado. | Los puños también duelen. Recíbelo con acero, garras y dientes.
|
||||||
|
best.ranger.text = Un hombre de los bosques de la Orden que dispara desde lejos y deja cepos de hierro en tus pasillos. La criatura que pisa uno queda herida y atrapada un rato. | Los cepos siguen ahí hasta que alguien los pisa. Mejor un kóbold que tu ogro.
|
||||||
|
best.inquisitor.text = Frágil, pero sus sermones son peligrosos: cada veinte segundos convence a una de tus criaturas más débiles (un kóbold o una araña) de unirse a los héroes. | Las criaturas fuertes se ríen de él. Golpea tú primero.
|
||||||
|
best.ram.text = Un tronco con punta de hierro y un tejado contra las flechas. Tus criaturas le dan igual; tus puertas, muros y torres no. | Detenlo antes de que llegue a la puerta, o búscate otra puerta.
|
||||||
|
best.catapult.text = Un brazo de madera, un cubo de piedras y un caballero con demasiado tiempo libre. Alcanza más lejos que tus torres y apunta a ellas primero. | Lánzate sobre ella: de cerca no hace daño ni a una mosca.
|
||||||
|
|
||||||
# orders
|
# orders
|
||||||
tool.order_attack.label = Atacar
|
tool.order_attack.label = Atacar
|
||||||
tool.order_attack.name = ¡Al ataque!
|
tool.order_attack.name = ¡Al ataque!
|
||||||
tool.order_attack.desc = Haz clic en un enemigo o una torre enemiga: todas las criaturas combatientes a menos de 18 casillas van a por él. Clic derecho con una herramienta de órdenes cancela las órdenes de guardia.
|
tool.order_attack.desc = Haz clic en un enemigo o una torre enemiga: todas las criaturas listas para luchar en 18 casillas van a por él. Haz clic en suelo libre: un marcador de espada; las criaturas listas se reparten entre todas las espadas y luchan allí. Clic derecho en un marcador lo quita.
|
||||||
tool.order_guard.label = Guardia
|
tool.order_guard.label = Guardia
|
||||||
tool.order_guard.name = Vigilar esta sala
|
tool.order_guard.name = Vigilar esta sala
|
||||||
tool.order_guard.desc = Haz clic en una sala: hasta tres criaturas cercanas toman posiciones allí durante dos minutos y atacan todo lo que aparezca a la vista. Clic derecho cancela.
|
tool.order_guard.desc = Haz clic en tu propio suelo: un marcador de escudo que tres criaturas mantienen y defienden. Hasta ocho escudos; clic derecho en un escudo lo quita.
|
||||||
hint.order_attack = Haz clic en un enemigo o una torre enemiga.
|
hint.order_attack = Haz clic en un enemigo o una torre enemiga.
|
||||||
hint.order_guard = Haz clic en una de tus salas (o en tu propio suelo).
|
hint.order_guard = Haz clic en una de tus salas (o en tu propio suelo).
|
||||||
hint.order_nobody = Ninguna criatura está libre para esa orden.
|
hint.order_nobody = Ninguna criatura está libre para esa orden.
|
||||||
msg.order_attack = ¡{0} criaturas atacan!
|
msg.order_attack = ¡{0} criaturas atacan!
|
||||||
msg.order_guard = {0} criaturas toman posiciones.
|
msg.order_guard = {0} criaturas toman posiciones.
|
||||||
|
msg.mark_attack = Una espada marca el sitio: tus criaturas listas marchan allí.
|
||||||
|
msg.mark_guard = Un escudo marca el sitio: tres criaturas lo mantendrán.
|
||||||
|
msg.mark_removed = Marcador quitado: sus criaturas vuelven a estar libres.
|
||||||
|
hint.marks_full = Ocho marcadores de ese tipo es el máximo. Quita uno con clic derecho.
|
||||||
msg.orders_cancelled = Órdenes de guardia canceladas.
|
msg.orders_cancelled = Órdenes de guardia canceladas.
|
||||||
|
|
||||||
# creature overview
|
# creature overview
|
||||||
@@ -1013,6 +1130,8 @@ ov.summary = {0} criaturas - sueldos {1} de oro por paga - ánimo {2}%
|
|||||||
ov.none = Aún no hay criaturas. Construye una guarida y un huerto de setas y reclama un portal.
|
ov.none = Aún no hay criaturas. Construye una guarida y un huerto de setas y reclama un portal.
|
||||||
ov.hint = Nv, ánimo, sueldo, tarea, salud - clic: ir allí
|
ov.hint = Nv, ánimo, sueldo, tarea, salud - clic: ir allí
|
||||||
set.voice = Voz de Dedotuerto
|
set.voice = Voz de Dedotuerto
|
||||||
|
set.track = Pista musical
|
||||||
|
val.track_auto = Automática
|
||||||
|
|
||||||
# ---- campaign editor, block scripts, demo campaign
|
# ---- campaign editor, block scripts, demo campaign
|
||||||
menu.back_editor = Volver al editor
|
menu.back_editor = Volver al editor
|
||||||
@@ -1085,6 +1204,7 @@ ed.forts = Fortalezas
|
|||||||
ed.rivals = Señores rivales
|
ed.rivals = Señores rivales
|
||||||
ed.difficulty = Dificultad
|
ed.difficulty = Dificultad
|
||||||
ed.portals = Portales
|
ed.portals = Portales
|
||||||
|
ed.surface = Superficie (este)
|
||||||
ed.edit_map = Editar el mapa
|
ed.edit_map = Editar el mapa
|
||||||
ed.make_random = Hacerlo aleatorio
|
ed.make_random = Hacerlo aleatorio
|
||||||
ed.paint_map = Generar y pintar el mapa
|
ed.paint_map = Generar y pintar el mapa
|
||||||
@@ -1164,6 +1284,10 @@ ed.terrain.silver = Veta de plata
|
|||||||
ed.terrain.ruby = Veta de rubíes
|
ed.terrain.ruby = Veta de rubíes
|
||||||
ed.terrain.diamond = Veta de diamantes
|
ed.terrain.diamond = Veta de diamantes
|
||||||
ed.terrain.steel = Muro de acero
|
ed.terrain.steel = Muro de acero
|
||||||
|
ed.terrain.mushroom = Seta gigante
|
||||||
|
ed.terrain.grove = Bosquecillo de setas
|
||||||
|
ed.terrain.grass = Hierba (superficie)
|
||||||
|
ed.terrain.tree = Árbol
|
||||||
ed.place.core = Núcleo de alma
|
ed.place.core = Núcleo de alma
|
||||||
ed.place.portal = Portal
|
ed.place.portal = Portal
|
||||||
ed.place.gate = Puerta de héroes
|
ed.place.gate = Puerta de héroes
|
||||||
@@ -1194,6 +1318,7 @@ ed.level = Nivel
|
|||||||
ed.zone_shape = Forma de la nueva zona
|
ed.zone_shape = Forma de la nueva zona
|
||||||
ed.zone_new_name = Nombre de la nueva zona
|
ed.zone_new_name = Nombre de la nueva zona
|
||||||
ed.zones = Zonas
|
ed.zones = Zonas
|
||||||
|
ed.zone_erase = Quitar casillas
|
||||||
ed.shape.rect = Rectángulo
|
ed.shape.rect = Rectángulo
|
||||||
ed.shape.circle = Círculo
|
ed.shape.circle = Círculo
|
||||||
ed.shape.tiles = Casillas
|
ed.shape.tiles = Casillas
|
||||||
@@ -1243,6 +1368,8 @@ blk.reveal = Revelar
|
|||||||
blk.camera = Mostrar
|
blk.camera = Mostrar
|
||||||
blk.gold = Oro +
|
blk.gold = Oro +
|
||||||
blk.mana = Maná +
|
blk.mana = Maná +
|
||||||
|
blk.weather = Clima en la superficie
|
||||||
|
blk.talentxp = Experiencia de talentos +
|
||||||
blk.if = Si
|
blk.if = Si
|
||||||
blk.repeat = Repetir
|
blk.repeat = Repetir
|
||||||
blk.trigger = Conmutar disparador
|
blk.trigger = Conmutar disparador
|
||||||
@@ -1262,6 +1389,8 @@ blk.help.reveal = Levanta la niebla de guerra sobre una zona.
|
|||||||
blk.help.camera = Mueve la cámara a una zona.
|
blk.help.camera = Mueve la cámara a una zona.
|
||||||
blk.help.gold = Da (o quita) oro.
|
blk.help.gold = Da (o quita) oro.
|
||||||
blk.help.mana = Da (o quita) maná.
|
blk.help.mana = Da (o quita) maná.
|
||||||
|
blk.help.weather = Fija el clima en la superficie (despejado, lluvia, niebla o tormenta) durante tantos segundos; 0 lo mantiene hasta que otro bloque lo cambie.
|
||||||
|
blk.help.talentxp = Da al jugador experiencia para los talentos del maestro.
|
||||||
blk.help.if = Ejecuta los bloques de dentro solo si se cumple la condición; si no, los de «si no».
|
blk.help.if = Ejecuta los bloques de dentro solo si se cumple la condición; si no, los de «si no».
|
||||||
blk.help.repeat = Ejecuta varias veces los bloques de dentro.
|
blk.help.repeat = Ejecuta varias veces los bloques de dentro.
|
||||||
blk.help.trigger = Activa un disparador (puede volver a saltar) o lo desactiva.
|
blk.help.trigger = Activa un disparador (puede volver a saltar) o lo desactiva.
|
||||||
@@ -1284,6 +1413,7 @@ blk.v.or = o
|
|||||||
blk.v.not = no
|
blk.v.not = no
|
||||||
blk.v.bool = Sí/no
|
blk.v.bool = Sí/no
|
||||||
blk.v.eventkind = El causante es un
|
blk.v.eventkind = El causante es un
|
||||||
|
blk.v.weatheris = El clima en la superficie es
|
||||||
blk.w_at = en
|
blk.w_at = en
|
||||||
blk.w_by = en
|
blk.w_by = en
|
||||||
blk.w_in = en
|
blk.w_in = en
|
||||||
@@ -1328,14 +1458,313 @@ demo.m2.title = Otra tirada
|
|||||||
demo.m2.story = Este mapa se genera de nuevo en cada partida de la campaña. Derrota a todos los héroes.
|
demo.m2.story = Este mapa se genera de nuevo en cada partida de la campaña. Derrota a todos los héroes.
|
||||||
demo.m2.start = Un mapa aleatorio. Ni siquiera yo lo había visto antes.
|
demo.m2.start = Un mapa aleatorio. Ni siquiera yo lo había visto antes.
|
||||||
demo.m2.wave = Han pasado dos minutos: llegan los héroes.
|
demo.m2.wave = Han pasado dos minutos: llegan los héroes.
|
||||||
net.coop_editor = Editor campaign
|
net.coop_editor = Campaña del editor
|
||||||
net.coop_builtin = Built-in campaign
|
net.coop_builtin = Campaña integrada
|
||||||
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2})
|
net.coop_league = Liga contra la Guardia Luminosa
|
||||||
net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters)
|
net.coop_solo = Socio IA
|
||||||
|
net.coop_solo_started = Cooperativo sin servidor: tu socio IA construye su propia mazmorra, sigue tu bandera de ataque y te ayuda con oro.
|
||||||
|
net.coop_resume = Seguir M{0}
|
||||||
|
net.coop_editor_mission = Campaña del editor {0}: misión {1} ({2})
|
||||||
|
net.coop_editor_none = Ninguna misión de la campaña del editor sirve para cooperativo (mapa de al menos 72x72, un objetivo aparte de los maestros rivales)
|
||||||
blk.until = Repetir hasta
|
blk.until = Repetir hasta
|
||||||
blk.dountil = Repetir y luego: hasta
|
blk.dountil = Repetir y luego: hasta
|
||||||
blk.wait = Esperar
|
blk.wait = Esperar
|
||||||
blk.w_seconds = segundos
|
blk.w_seconds = segundos
|
||||||
|
blk.w_for = durante
|
||||||
blk.help.until = Ejecuta los bloques de dentro una y otra vez hasta que se cumpla la condición; se comprueba antes de cada vuelta, así que si ya se cumple no se ejecuta nada.
|
blk.help.until = Ejecuta los bloques de dentro una y otra vez hasta que se cumpla la condición; se comprueba antes de cada vuelta, así que si ya se cumple no se ejecuta nada.
|
||||||
blk.help.dountil = Ejecuta los bloques de dentro, luego comprueba la condición y repite hasta que se cumpla; el contenido se ejecuta al menos una vez.
|
blk.help.dountil = Ejecuta los bloques de dentro, luego comprueba la condición y repite hasta que se cumpla; el contenido se ejecuta al menos una vez.
|
||||||
blk.help.wait = Detiene este script unos segundos y luego sigue con el siguiente bloque (también dentro de bucles).
|
blk.help.wait = Detiene este script unos segundos y luego sigue con el siguiente bloque (también dentro de bucles).
|
||||||
|
msg.master_out = {0} ha perdido el núcleo de almas: sus criaturas siguen luchando sin corazón.
|
||||||
|
msg.master_out_you = Tu núcleo de almas se ha roto. Estás fuera: observa el resto de la partida.
|
||||||
|
msg.match_won = ¡Tu equipo ha ganado la partida!
|
||||||
|
msg.match_lost = La partida está perdida: otro equipo ha ganado.
|
||||||
|
msg.gold_received = {1} te envía {0} de oro.
|
||||||
|
msg.gold_sent = {0} de oro enviado a {1}.
|
||||||
|
msg.gold_none = No tienes oro para enviar.
|
||||||
|
msg.gold_full = El tesoro de {0} está lleno.
|
||||||
|
gold.send_to = Oro para {0}
|
||||||
|
gold.all = Todo
|
||||||
|
net.spectating = Tu corazón ha caído: estás observando la partida (chat con Intro).
|
||||||
|
net.team = Equipo {0}
|
||||||
|
net.versus = Todos contra todos
|
||||||
|
net.coop_wait_host = Esperando a que el anfitrión inicie la siguiente misión...
|
||||||
|
net.coop_run_over = Volver al vestíbulo
|
||||||
|
|
||||||
|
# ---- co-op campaign "League against the Lightwatch"
|
||||||
|
coop.name = Liga contra la Guardia Luminosa
|
||||||
|
coop.raid = ¡Las criaturas de Mossgrim salen arrastrándose de la oscuridad!
|
||||||
|
coop.m01.title = Dos corazones, un Dedotuerto
|
||||||
|
coop.m01.story = Maestros, en plural. Yo tampoco me lo creía. La Orden de la Guardia Luminosa ha sellado un pacto con Mossgrim, ese traidor mohoso, para ahumar todas las mazmorras bajo las colinas.\n\nSolos, acabaríais ahumados. Juntos, quizá seáis vosotros el humo. Excavad codo con codo y enseñadle a la patrulla cercana lo que significa un sótano compartido.
|
||||||
|
coop.m01.start = Dos maestros, un consejero. Mi sueldo, por cierto, sigue siendo el mismo.
|
||||||
|
coop.m01.goal = Derrotad a todos los caballeros.
|
||||||
|
coop.m01.t1 = Repartíos el trabajo: uno excava, el otro construye. O excaváis los dos. Soy flexible.
|
||||||
|
coop.m01.t2 = La patrulla se está poniendo curiosa. Bien. La curiosidad es un tentempié delicioso.
|
||||||
|
coop.m02.title = El primer puesto avanzado
|
||||||
|
coop.m02.story = Al otro lado del río, la Guardia Luminosa ha levantado un puesto avanzado con torres relucientes. Lo reluciente hay que romperlo: es la regla más antigua del inframundo.\n\nTended un puente sobre el agua, derribad las torres y hacedlo juntos antes de que llamen a sus amigos.
|
||||||
|
coop.m02.start = Torres. Pulidas, puntiagudas, ofensivas. Arreglemos eso.
|
||||||
|
coop.m02.goal = Arrasad las torres de los caballeros.
|
||||||
|
coop.m02.t1 = Los puentes son como las amistades, maestros: se construyen y luego se hace marchar un ejército por encima.
|
||||||
|
coop.m02.t2 = Una torre caída es un comienzo. Todas las torres caídas son una fiesta.
|
||||||
|
coop.m03.title = Huellas en el musgo
|
||||||
|
coop.m03.story = Algo verde y húmedo acecha en los túneles. Mossgrim les ha prestado sus criaturas a los caballeros, y llegan por la Puerta de los Héroes en oleadas.\n\nAguantad. Permaneced unidos. Tapaos la nariz.
|
||||||
|
coop.m03.start = Los ogros de Mossgrim huelen a alfombra mojada. Sabréis cuándo llegan.
|
||||||
|
coop.m03.goal = Sobrevivid a tres oleadas.
|
||||||
|
coop.m03.t1 = Ya están aquí los primeros ogros. Mossgrim nunca supo tener a sus mascotas en casa.
|
||||||
|
coop.m03.t2 = Mantened vuestros corazones separados, maestros: un solo ogro nunca debería alcanzar los dos.
|
||||||
|
coop.m04.title = Las fuentes robadas
|
||||||
|
coop.m04.story = Dos fuentes de maná burbujean en islas del lago, y los caballeros las han reclamado como su agua bendita. Agua bendita, francamente.\n\nTomad ambas fuentes. Y si la cámara acorazada de los caballeros cae antes en vuestras manos, Dedotuerto conoce un atajo.
|
||||||
|
coop.m04.start = El maná sabe mejor cuando era de otro.
|
||||||
|
coop.m04.goal = Reclamad dos fuentes de maná.
|
||||||
|
coop.m04.branch = Lo bastante ricos para el atajo
|
||||||
|
coop.m04.t1 = Las islas necesitan puentes. O alas. Los murciélagos son alas con dientes.
|
||||||
|
coop.m04.t2 = Esa cámara acorazada parece pesada. Las cosas pesadas hay que llevarlas a casa.
|
||||||
|
coop.m05.title = Cadenas en la roca
|
||||||
|
coop.m05.story = La Guardia Luminosa mantiene encadenadas en cuevas selladas a criaturas que se negaron a inclinarse. Un ogro y un mago, ambos furiosos, ambos disponibles.\n\nExcavad hasta ellos, reclamad sus cuevas y las cadenas caerán.
|
||||||
|
coop.m05.start = ¡Prisioneros! Pronto nuestros. Libres, quiero decir. Libres para trabajar para vosotros.
|
||||||
|
coop.m05.goal = Liberad a dos criaturas encadenadas.
|
||||||
|
coop.m05.t1 = Escuchad. ¿Oís al ogro maldecir? Es gratitud por adelantado.
|
||||||
|
coop.m05.t2 = Otra vez arañas del musgo. La guardería de Mossgrim no para.
|
||||||
|
coop.m06.title = El puente de la Guardia
|
||||||
|
coop.m06.story = Dos ríos, una fortaleza y los mejores arqueros de la Guardia Luminosa en las murallas. Lo llaman el Puente de la Guardia. Nosotros lo llamaremos el puente que fue.\n\nRomped sus torres. Dejad el puente: puede que lo necesitemos.
|
||||||
|
coop.m06.start = Los arqueros en las murallas son como las palomas en los tejados: ruidosos y fáciles de quitar.
|
||||||
|
coop.m06.goal = Arrasad las torres de los caballeros.
|
||||||
|
coop.m06.t1 = Dos ríos significan dos puentes. O un ejército muy mojado.
|
||||||
|
coop.m06.t2 = Los caballeros están puliendo sus armaduras. Démosles un motivo.
|
||||||
|
coop.m07.title = La forja de lava
|
||||||
|
coop.m07.story = Entre dos ríos de lava, los caballeros forjan sus espadas benditas. Su cámara acorazada rebosa de oro pagado por cada mazmorra que vaciaron.\n\nVamos a recuperarlo. Todo. Y un poco más.
|
||||||
|
coop.m07.start = Pies calientes, corazones fríos, bolsillos llenos: mi tipo de velada.
|
||||||
|
coop.m07.goal = Saquead el oro de los caballeros.
|
||||||
|
coop.m07.t1 = No os acerquéis demasiado a la lava. Vuestras criaturas, quiero decir. Vosotros estáis bien.
|
||||||
|
coop.m07.t2 = Más caballeros desde el este. De verdad que adoran su oro.
|
||||||
|
coop.m08.title = El hospital de campaña
|
||||||
|
coop.m08.story = La Guardia Luminosa ha montado un hospital de campaña en las cuevas, y sus sacerdotes remiendan a cada caballero que derribáis.\n\nPoned fin al tratamiento. Para siempre.
|
||||||
|
coop.m08.start = Sacerdotes. Ellos curan. Nosotros hacemos lo contrario. Es una relación equilibrada.
|
||||||
|
coop.m08.goal = Derrotad a todos los caballeros.
|
||||||
|
coop.m08.t1 = Cada minuto llega un sacerdote nuevo. Matad más rápido, maestros.
|
||||||
|
coop.m08.t2 = Una mazmorra sin facturas de hospital. Eso es lo que yo llamo eficiencia.
|
||||||
|
coop.m09.title = El embajador de Mossgrim
|
||||||
|
coop.m09.story = Mossgrim ha enviado un embajador a los caballeros: un paladín con la armadura manchada de verde que lleva las promesas de su amo.\n\nLas promesas nunca deberían llegar. Encontrad al embajador y silenciadlo.
|
||||||
|
coop.m09.start = Un embajador. Un mensajero con sombrero elegante. Los sombreros se caen.
|
||||||
|
coop.m09.goal = Derrotad al embajador de Mossgrim.
|
||||||
|
coop.m09.boss = El embajador ha caído. Su mensaje: sin entregar.
|
||||||
|
coop.m09.t1 = El embajador espera en el este, rodeado de ogros que han perdido el sentido del humor.
|
||||||
|
coop.m09.t2 = En algún lugar, Mossgrim está enviando otra carta. Que sea cortita.
|
||||||
|
coop.m10.title = Las torres gemelas
|
||||||
|
coop.m10.story = Dos fortalezas guardan el camino al norte, cada una cubriendo las espaldas de la otra. Golpead una y la otra responde.\n\nGolpead las dos. A la vez. Para eso hay dos maestros.
|
||||||
|
coop.m10.start = Dos fortalezas, dos maestros. Lo justo es justo.
|
||||||
|
coop.m10.goal = Arrasad las torres de los caballeros.
|
||||||
|
coop.m10.t1 = Separaos, maestros: una fortaleza para cada uno. Yo animaré a los dos.
|
||||||
|
coop.m10.t2 = Si las dos caen a la vez, los caballeros ni sabrán a quién echarle la culpa.
|
||||||
|
coop.m11.title = El asedio
|
||||||
|
coop.m11.story = La Guardia Luminosa ha encontrado vuestros corazones y lo trae todo: cinco oleadas de caballeros y las gárgolas de Mossgrim desde los acantilados.\n\nEsta vez nada de astucias. Solo muros, trampas, torres... y el uno al otro.
|
||||||
|
coop.m11.start = Ya vienen. Todos. Bueno, casi todos.
|
||||||
|
coop.m11.goal = Sobrevivid a cinco oleadas.
|
||||||
|
coop.m11.t1 = Puertas y trampas, maestros. Un asedio no es más que invitados que se niegan a tocar el timbre.
|
||||||
|
coop.m11.t2 = Gárgolas desde el sur. Piedra con alas: nadie pidió eso.
|
||||||
|
coop.m12.title = La puerta
|
||||||
|
coop.m12.story = La Puerta de los Héroes de esta caverna es el camino de los caballeros hacia las mazmorras profundas. Cerradla y sus refuerzos tendrán que dar el gran rodeo.\n\nRomped la puerta y la Guardia Luminosa lo notará hasta el Gran Mariscal.
|
||||||
|
coop.m12.start = Una puerta. Las puertas no son más que muros con ambiciones.
|
||||||
|
coop.m12.goal = Destruid la Puerta de los Héroes.
|
||||||
|
coop.m12.t1 = No paran de salir oleadas. Molesto, como un grifo que gotea.
|
||||||
|
coop.m12.t2 = ¡Empujad, maestros! La puerta se está agrietando. Y mi paciencia también.
|
||||||
|
coop.m13.title = Túneles musgosos
|
||||||
|
coop.m13.story = Ahora estamos en el patio trasero de Mossgrim. Sus túneles son blandos y húmedos, y dos de sus tesoros yacen en cuevas selladas.\n\nExcavad, tomad, marchaos. Mossgrim se dará cuenta. De eso se trata.
|
||||||
|
coop.m13.start = Huele a setas y a traición. Mossgrim debe de andar cerca.
|
||||||
|
coop.m13.goal = Saquead el oro.
|
||||||
|
coop.m13.t1 = Arañas, otra vez. Mossgrim las cría como otros cultivan tomates.
|
||||||
|
coop.m13.t2 = Sus tesoros son más grandes de lo que pensaba. Nuestros bolsillos, más aún.
|
||||||
|
coop.m14.title = Veneno en el agua
|
||||||
|
coop.m14.story = Mossgrim ha convertido a sus muertos en aparecidos y los envía desde dos lados. Los caballeros lo llaman obra santa. Típico.\n\nDerrotad a todos los caballeros y a todos los cadáveres andantes. Luego lavaos las manos.
|
||||||
|
coop.m14.start = Aparecidos. Nuestro truco, robado. Me siento ofendido.
|
||||||
|
coop.m14.goal = Derrotad a todos los enemigos.
|
||||||
|
coop.m14.t1 = Vienen del norte y del sur. Espalda con espalda, maestros.
|
||||||
|
coop.m14.t2 = Un muerto que camina es un muerto que todavía no ha terminado su trabajo.
|
||||||
|
coop.m15.title = El Rompecadenas
|
||||||
|
coop.m15.story = En lo profundo de la roca, los caballeros tienen al Rompecadenas, una criatura que hasta ellos temen. Liberadlo y luchará por quien abra su celda.\n\nO reunid oro suficiente para el gran rodeo: Dedotuerto conoce a un contrabandista.
|
||||||
|
coop.m15.start = El Rompecadenas. Grande, furioso, incomprendido. Un poco como yo.
|
||||||
|
coop.m15.goal = Liberad al Rompecadenas y arrasad las torres.
|
||||||
|
coop.m15.branch = Comprado el mapa del contrabandista
|
||||||
|
coop.m15.t1 = La celda está al noroeste. Cuidado con su genio.
|
||||||
|
coop.m15.t2 = El contrabandista espera. Los contrabandistas odian esperar. Y adoran el oro.
|
||||||
|
coop.m16.title = La fuente ardiente
|
||||||
|
coop.m16.story = En medio de un lago de lava hay una isla con una fuente de maná que nunca se seca. La custodian los magos de Mossgrim.\n\nTomad la fuente, derrotad a los guardias. Luego sentaos en la isla y disfrutad del calorcito.
|
||||||
|
coop.m16.start = Una fuente en la lava. Quien lo pensó tiene estilo. Lástima que sea Mossgrim.
|
||||||
|
coop.m16.goal = Reclamad la fuente y derrotad a todos los enemigos.
|
||||||
|
coop.m16.t1 = Magos desde el este. Sus hechizos son verdes, y sus dientes también.
|
||||||
|
coop.m16.t2 = En cuanto la fuente sea nuestra, maná para todos. Compartid como es debido.
|
||||||
|
coop.m17.title = El jardín de Mossgrim
|
||||||
|
coop.m17.story = Ha llegado el momento: el jardín de Mossgrim, una caverna de setas y criaturas leales a él. Ogros en el este, un dragón de brasas en el norte.\n\nPisotead el jardín. Cada seta.
|
||||||
|
coop.m17.start = Mirad cuántas setas. Lástima del dueño.
|
||||||
|
coop.m17.goal = Derrotad a todos los enemigos.
|
||||||
|
coop.m17.t1 = ¡Un dragón de brasas! Mossgrim debe de haber ahorrado durante años.
|
||||||
|
coop.m17.t2 = El jardín se marchita. La jardinería no es vuestro fuerte, maestros. Es un cumplido.
|
||||||
|
coop.m18.title = La última fortaleza
|
||||||
|
coop.m18.story = Tres fortalezas guardan la escalera que baja a la sala del Gran Mariscal. Son lo último que se interpone entre vosotros y el fin de la cruzada de la Guardia Luminosa.\n\nArrasadlas todas. Entonces la escalera será vuestra.
|
||||||
|
coop.m18.start = Tres fortalezas. De verdad que dejaron lo mejor para el final.
|
||||||
|
coop.m18.goal = Arrasad las torres de los caballeros.
|
||||||
|
coop.m18.t1 = Una fortaleza para cada maestro, y la tercera juntos. Yo llevaré la cuenta.
|
||||||
|
coop.m18.t2 = Ya veo la escalera. Polvorienta. Nadie baja aquí por gusto.
|
||||||
|
coop.m19.title = El Gran Mariscal
|
||||||
|
coop.m19.story = El mismísimo Gran Mariscal de la Guardia Luminosa, con una armadura pulida durante cien años. Sus guardias llegan del norte y del sur.\n\nDerribadlo y a la Orden no le quedará nadie que dé las órdenes.
|
||||||
|
coop.m19.start = El Gran Mariscal. He soñado con esto. Sobre todo con la parte en que se cae.
|
||||||
|
coop.m19.goal = Derrotad al Gran Mariscal.
|
||||||
|
coop.m19.boss = ¡El Gran Mariscal ha caído! Su armadura tintinea como un cajón de cubiertos volcado.
|
||||||
|
coop.m19.t1 = Espera en el este. Que espere. Acabad primero con los guardias.
|
||||||
|
coop.m19.t2 = Otra vez caballeros y arqueros. A la Orden se le acaban las ideas.
|
||||||
|
coop.m20.title = La caída de Mossgrim
|
||||||
|
coop.m20.story = Caído el Alto Mariscal, Mossgrim está solo: arriba, en el bosque, al sol, nada menos. Ha tomado la forma de una sierpe de brasas y ha reunido a toda criatura que aún le obedece.\n\nSalid de la roca y acabad con el traidor. Acabad con su ejército. Y luego, maestros: té.
|
||||||
|
coop.m20.start = Mossgrim, viejo hongo. La liga te manda saludos.
|
||||||
|
coop.m20.goal = Derrotad a Mossgrim y a todas sus criaturas.
|
||||||
|
coop.m20.boss = Mossgrim ya no existe. El musgo volverá a crecer. Él no.
|
||||||
|
coop.m20.t1 = Ese dragón del este es Mossgrim. Sí, se ha descuidado bastante.
|
||||||
|
coop.m20.t2 = Cada criatura que derrotáis es una menos que contará mal esta historia.
|
||||||
|
|
||||||
|
# achievements and statistics
|
||||||
|
menu.achievements = Logros
|
||||||
|
ach.count = {0} de {1} logros desbloqueados · {2} minutos jugados
|
||||||
|
ach.hidden = Un secreto. Dedotuerto no suelta prenda.
|
||||||
|
msg.achievement = Logro desbloqueado: ¡{0}!
|
||||||
|
end.stats2 = Héroes {0} · Criaturas perdidas {1} · Casillas excavadas {2} · Oro {3} · Madera {4}
|
||||||
|
ach.heroes.name = Azote de héroes {0}
|
||||||
|
ach.heroes.desc = Derrota a {0} héroes.
|
||||||
|
ach.bosses.name = Rompecampeones {0}
|
||||||
|
ach.bosses.desc = Derriba a {0} campeones de la Orden.
|
||||||
|
ach.dug.name = Excavador profundo {0}
|
||||||
|
ach.dug.desc = Excava {0} casillas.
|
||||||
|
ach.gold.name = Acaparador {0}
|
||||||
|
ach.gold.desc = Gana {0} de oro.
|
||||||
|
ach.wood.name = Señor de la madera {0}
|
||||||
|
ach.wood.desc = Tala {0} de madera.
|
||||||
|
ach.walls.name = Albañil {0}
|
||||||
|
ach.walls.desc = Levanta {0} muros.
|
||||||
|
ach.rooms.name = Arquitecto de las sombras {0}
|
||||||
|
ach.rooms.desc = Construye {0} casillas de sala.
|
||||||
|
ach.spells.name = Hechicero oscuro {0}
|
||||||
|
ach.spells.desc = Lanza {0} hechizos.
|
||||||
|
ach.slaps.name = Mano firme {0}
|
||||||
|
ach.slaps.desc = Abofetea a tus criaturas {0} veces. Les encanta. Seguramente.
|
||||||
|
ach.traps.name = Forjatrampas {0}
|
||||||
|
ach.traps.desc = Encarga {0} trampas y puertas en la forja.
|
||||||
|
ach.towers.name = Señor de las torres {0}
|
||||||
|
ach.towers.desc = Construye {0} torres.
|
||||||
|
ach.drowned.name = Clases de natación {0}
|
||||||
|
ach.drowned.desc = Haz que {0} héroes caigan al agua o a la lava.
|
||||||
|
ach.burnt.name = Quemapuentes {0}
|
||||||
|
ach.burnt.desc = Quema {0} tablones de puente.
|
||||||
|
ach.siege.name = Maestro de asedio {0}
|
||||||
|
ach.siege.desc = Construye {0} máquinas de asedio.
|
||||||
|
ach.captured.name = Carcelero {0}
|
||||||
|
ach.captured.desc = Captura a {0} héroes.
|
||||||
|
ach.drawbridge.name = ¡Arriba con él! {0}
|
||||||
|
ach.drawbridge.desc = Alza puentes levadizos {0} veces.
|
||||||
|
ach.wins.name = Conquistador {0}
|
||||||
|
ach.wins.desc = Gana {0} partidas libres.
|
||||||
|
ach.missions.name = Buscafragmentos {0}
|
||||||
|
ach.missions.desc = Gana {0} misiones de campaña.
|
||||||
|
ach.coop.name = Miembro de la liga {0}
|
||||||
|
ach.coop.desc = Gana {0} misiones cooperativas.
|
||||||
|
ach.minutes.name = Ave nocturna {0}
|
||||||
|
ach.minutes.desc = Juega durante {0} minutos.
|
||||||
|
ach.flawless.name = Ni un rasguño
|
||||||
|
ach.flawless.desc = Gana sin perder una sola criatura.
|
||||||
|
ach.speedrun.name = Antes de que se enfríe el té
|
||||||
|
ach.speedrun.desc = Gana en menos de quince minutos.
|
||||||
|
ach.campaign.name = El Único Amo
|
||||||
|
ach.campaign.desc = Gana la última misión de la campaña.
|
||||||
|
ach.surface.name = Quemadura solar
|
||||||
|
ach.surface.desc = Gana una partida que llegue hasta la superficie.
|
||||||
|
ach.defeat.name = Té y compasión
|
||||||
|
ach.defeat.desc = Pierde una partida. Dedotuerto pone el agua a hervir.
|
||||||
|
ach.legend.name = Leyenda viviente
|
||||||
|
ach.legend.desc = Una criatura alcanza el nivel diez.
|
||||||
|
ach.wyrm.name = Aquí hay dragones
|
||||||
|
ach.wyrm.desc = Un dragón de brasas se une a tu mazmorra.
|
||||||
|
ach.fullhand.name = Manos llenas
|
||||||
|
ach.fullhand.desc = Sostén ocho unidades en la mano a la vez.
|
||||||
|
|
||||||
|
# tutorial
|
||||||
|
menu.tutorial = Tutorial
|
||||||
|
tut.name = Primeros pasos
|
||||||
|
tut.title = Dedotuerto te enseña el oficio
|
||||||
|
tut.story = Así que tú eres el nuevo amo. Pequeño, pero el núcleo aún late, y eso es lo que cuenta.\n\nTe enseñaré lo básico: excavar, salas, criaturas y qué hacer con los caballeros. Haz lo que te digo y nadie saldrá herido. Salvo los caballeros.
|
||||||
|
tut.start = Dedotuerto: Primera lección: excavar. Coge el pico (tecla 1), arrastra sobre la tierra al este del corazón y tus diablillos la excavarán.
|
||||||
|
tut.obj1 = Excava el túnel al este del corazón.
|
||||||
|
tut.dug = Dedotuerto: ¡Mira, una sala detrás! Tus diablillos reclaman el suelo por sí solos. Ahora abre Salas y construye una guarida en tu propio suelo: ahí duermen tus criaturas.
|
||||||
|
tut.obj2 = Construye una guarida (Salas).
|
||||||
|
tut.lair = Dedotuerto: Camas, hecho. Las criaturas también comen: construye un huerto de setas al lado.
|
||||||
|
tut.obj3 = Construye un huerto de setas (Salas).
|
||||||
|
tut.farm = Dedotuerto: Cama y comida: ahora el portal te envía criaturas. Paciencia, vienen solas. Espera a dos.
|
||||||
|
tut.obj4 = Espera a que dos criaturas lleguen por el portal.
|
||||||
|
tut.creatures = Dedotuerto: ¡Aquí están! Un poco de motivación nunca viene mal: coge la mano (tecla 2) y haz clic en una criatura manteniendo Mayús pulsada (en pantalla táctil: tócala dos veces) para darle un bofetón. Trabajará más rápido durante un rato.
|
||||||
|
tut.obj5 = Dale un bofetón a una de tus criaturas (mano, Mayús + clic).
|
||||||
|
tut.slap = Dedotuerto: ¡Ja! ¿Lo has oído? Algo viene del este. Prepárate: el rayo (Hechizos) golpea fuerte, y tus criaturas luchan solas.
|
||||||
|
tut.obj6 = Derrota a los dos caballeros.
|
||||||
|
tut.knights = Dedotuerto: ¡Dos caballeros! Envía a tus criaturas con la bandera de ataque (Órdenes) o fulmínalos con el rayo.
|
||||||
|
tut.win = Dedotuerto: ¡Bien hecho, amo! Eso es todo. Bueno, casi. La campaña cuenta el resto. Voy a poner el agua a hervir.
|
||||||
|
|
||||||
|
# master talents
|
||||||
|
cat.master = Maestro
|
||||||
|
talent.title = Talentos del maestro
|
||||||
|
talent.points = Puntos de talento libres: {0}
|
||||||
|
talent.next = {0}/{1} de experiencia hasta el siguiente punto
|
||||||
|
talent.all = Todos los talentos están aprendidos.
|
||||||
|
talent.builder = Constructor
|
||||||
|
talent.warlord = Caudillo
|
||||||
|
talent.warlock = Brujo
|
||||||
|
talent.merchant = Mercader
|
||||||
|
talent.builder.desc = Los diablillos cavan un {0}% más rápido y las salas cuestan un {1}% menos.
|
||||||
|
talent.warlord.desc = Las criaturas golpean un {0}% más fuerte y reciben un {1}% menos de daño.
|
||||||
|
talent.warlock.desc = El maná fluye un {0}% más rápido y los hechizos cuestan un {1}% menos.
|
||||||
|
talent.merchant.desc = Los sueldos son un {0}% más bajos y el tesoro entregado vale un {1}% más.
|
||||||
|
talent.none = Aún no aprendido.
|
||||||
|
talent.then = Siguiente nivel: {0}
|
||||||
|
msg.talent_point = ¡Un nuevo punto de talento! Gástalo en la ventana del maestro.
|
||||||
|
msg.talent_learned = {0}: nivel {1} aprendido.
|
||||||
|
|
||||||
|
# weather above ground
|
||||||
|
weather.fair = Despejado
|
||||||
|
weather.rain = Lluvia
|
||||||
|
weather.fog = Niebla
|
||||||
|
weather.storm = Tormenta
|
||||||
|
msg.weather_clear = El cielo se despeja en la superficie.
|
||||||
|
msg.weather_rain = Empieza a llover en la superficie: el fuego no prende.
|
||||||
|
msg.weather_fog = La niebla cubre la tierra: todos ven menos al aire libre.
|
||||||
|
msg.weather_storm = ¡Se avecina una tormenta en la superficie! Los rayos alcanzan unidades y árboles.
|
||||||
|
msg.lightning_unit = ¡Un rayo alcanza a {0}!
|
||||||
|
# creature names
|
||||||
|
names.imp = Mugrín, Mocoso, Cascarrín, Pringue, Garbancito, Roñito, Pelusa, Zurullo, Tizón, Chafardín, Escarbín, Migaja, Chispín, Lodito, Gusarapo, Rascón, Pocholo, Terrón, Pipo, Zarpín
|
||||||
|
names.kobold = Gruñín, Raspa, Mordisco, Chirlo, Trapo, Tarugo, Zarpazo, Rufo, Pellizco, Garfio, Cardo, Chuzo, Zorrino, Pícaro, Ratero, Mellado, Rasguño, Guijarro, Cuchi, Tiznado
|
||||||
|
names.spider = Patitas, Telarín, Hilacha, Cosquillas, Ocho, Sedita, Tejedora, Pelusín, Brujilla, Enredo, Ovillo, Agujita, Trampita, Colmilla, Lunares, Retales, Bordadora, Pinchito, Sombrita, Telita
|
||||||
|
names.mage = Bartolomé, Ignacio, Hermenegildo, Anacleto, Fulgencio, Eustaquio, Leopoldo, Baltasar, Melquíades, Teodoro, Segismundo, Florencio, Casimiro, Gumersindo, Nicanor, Aurelio, Rigoberto, Cipriano, Marcelino, Wenceslao
|
||||||
|
names.ogre = Pedrusco, Ceporro, Zoquete, Mamotreto, Tocho, Bruto, Mazacote, Bolo, Morlaco, Pancho, Trompo, Tronco, Cachalote, Pataca, Mendrugo, Bombo, Gordinflón, Porrazo, Garrulo, Borrico
|
||||||
|
names.jailer = Cerrojo, Candado, Grilletes, Ceñudo, Sombrío, Mazmorro, Rejas, Torvo, Hierro, Cadenas, Severo, Aldaba, Garrote, Cancerbero, Llavero, Lúgubre, Hosco, Calabozo, Tranca, Argolla
|
||||||
|
names.priestess = Brasa, Ascua, Llama, Pira, Centella, Ceniza, Bermeja, Hoguera, Fulgor, Lumbre, Morgana, Serafina, Escarlata, Chispa, Ignea, Rubí, Ámbar, Fénix, Candela, Pavesa
|
||||||
|
names.chainbreaker = Rompecadenas, Ulfgar, Almahierro, Brannok, Kragmor, Valdrek, Espino, Gorath, Indómito, Rurik, Torvaldo, Hagan, Durnok, Quebranto, Tizona, Wulfrido, Korrún, Arvak, Libertador, Halvardo
|
||||||
|
names.bat = Chillido, Aletas, Pío, Mordi, Eco, Batí, Ocaso, Zumbi, Chirri, Colmillito, Revolo, Murci, Polilla, Piquito, Picado, Colgandero, Terciopelo, Nocturno, Chirrín, Zigzag
|
||||||
|
names.gargoyle = Granito, Pizarra, Basalto, Adoquín, Mortero, Guijarro, Grava, Pedernal, Cascote, Escombro, Cuarzo, Pómez, Cornisa, Gárgola, Carapiedra, Sillar, Frontón, Cantero, Cincel, Peñasco
|
||||||
|
names.owl = Sabiondo, Ulular, Pluma, Buhíto, Minerva, Tobías, Lechuzo, Sapiencio, Plumón, Sabio, Cavilón, Anciano, Mochuelo, Noctua, Búho, Pergamino, Ocaso, Cárabo, Profesor, Erudito
|
||||||
|
names.wyrm = Vermitrax, Fauceniza, Chamusco, Calderón, Ignívoro, Ascuamor, Drakkar, Pirro, Obsidiano, Brasamor, Vulcanor, Abrasador, Malgrath, Tyrax, Infierno, Azufre, Kaldor, Fyrnak, Ragnor, Cindros
|
||||||
|
names.cogling = Tuerquita, Engranaje, Tornillo, Trinquete, Perno, Piñón, Chirimbolo, Cachivache, Llavecita, Muelle, Tintín, Chisme, Remache, Zumbido, Manivela, Arandela, Bisagra, Artilugio, Tictac, Resorte
|
||||||
|
names.revenant = Lamento, Pesar, Ceniciento, Quebranto, Hueco, Luto, Lívido, Pena, Espectro, Endecha, Túmulo, Penumbra, Cripta, Llanto, Vigilia, Mortaja, Sudario, Elegía, Ánima, Desolado
|
||||||
|
names.beast = Greñas, Melenudo, Bruno, Pelambre, Zampón, Musgoso, Gruñón, Osezno, Matorral, Crujido, Cascarón, Patudo, Cerdas, Moho, Retumbo, Zarza, Chasquido, Rezongón, Mole, Hocicón
|
||||||
|
names.human = Rodrigo, Gonzalo, Álvaro, Fernando, Tristán, Sancho, Bermudo, Lope, Pelayo, Hugo, Beltrán, Godofredo, Ordoño, Balduino, Ramiro, Gutierre, Nuño, Gilberto, Rufino, Diego
|
||||||
|
epithets.mid = Rompehuesos, Pisabarro, Muerderratas, Caramugre, Mascapiedras, Machacacráneos, Puñoapestoso, Nudillorroñoso, Revientapanzas, Pateaespinillas, Barbamoho, Garramugre, Lanzamocos, Masticaorejas, Parterrodillas, Comegusanos
|
||||||
|
epithets.high = Matarreyes, Manofunesta, Colmillopavor, Rompemundos, Azotehéroes, Puñotormenta, Terrornocturno, Segadalmas, Corazóndehierro, Derribacastillos, Matadragones, Andainfiernos, Señorsombra, Coronasangre, Mareafunesta, Quiebrastros
|
||||||
|
name.epithet = {0} {1}
|
||||||
|
msg.creature_fallen = {0} ha caído. Una piedra conmemorativa guarda su nombre.
|
||||||
|
tile.memorial = Piedra conmemorativa
|
||||||
|
tile.memorial_info = Aquí yace {0} ({1}, nivel {2}).
|
||||||
|
|
||||||
|
# hall of the fallen
|
||||||
|
hall.title = Salón de los caídos
|
||||||
|
hall.count = Criaturas caídas en el recuerdo: {0}
|
||||||
|
hall.empty = Aún no ha caído nadie. Que siga así.
|
||||||
|
hall.kills = Héroes abatidos: {0}
|
||||||
|
ov.fallen = Caídos ({0})
|
||||||
|
ov.living = Vivos
|
||||||
|
cause.slain = muerte a manos de: {0}
|
||||||
|
cause.lava = muerte en lava
|
||||||
|
cause.water = ahogamiento
|
||||||
|
cause.snare = muerte en un cepo
|
||||||
|
cause.lightning = muerte por rayo
|
||||||
|
cause.fell = caída en combate
|
||||||
@@ -53,17 +53,29 @@ msg.creature_arrived = Nuovo arrivo dal portale: {0}.
|
|||||||
msg.payday_partial = Giorno di paga! {0} creature pagate ({1} oro), {2} a mani vuote e furiose!
|
msg.payday_partial = Giorno di paga! {0} creature pagate ({1} oro), {2} a mani vuote e furiose!
|
||||||
msg.payday = Giorno di paga! {0} creature hanno ricevuto {1} oro.
|
msg.payday = Giorno di paga! {0} creature hanno ricevuto {1} oro.
|
||||||
msg.wave_boss = Ondata {0}: un Paladino della Luce guida {1} eroi nel tuo regno!
|
msg.wave_boss = Ondata {0}: un Paladino della Luce guida {1} eroi nel tuo regno!
|
||||||
|
boss.aldric = Sir Aldric l'Inflessibile
|
||||||
|
boss.seraphine = Dama Seraphine dell'Aurora
|
||||||
|
boss.halvard = Fratello Halvard il Pio
|
||||||
|
boss.rothgar = Capitano Rothgar Fianco di Ferro
|
||||||
|
msg.boss_comes = Un campione dell'Ordine guida l'ondata: {0}!
|
||||||
|
msg.boss_slain = {0} è caduto! Bottino: {1} oro e i piani di una torre della forgia runica.
|
||||||
msg.wave = Ondata {0}: {1} eroi sfondano la porta!
|
msg.wave = Ondata {0}: {1} eroi sfondano la porta!
|
||||||
msg.gate_boss = La porta si sgretola! Appare il Guardiano della Porta con {0} difensori!
|
msg.gate_boss = La porta si sgretola! Appare il Guardiano della Porta con {0} difensori!
|
||||||
msg.gate_defenders = La porta evoca {0} difensori!
|
msg.gate_defenders = La porta evoca {0} difensori!
|
||||||
msg.level_up = {0} raggiunge il livello {1}.
|
msg.level_up = {0} raggiunge il livello {1}.
|
||||||
msg.hero_slain = {0} sconfitto! Bottino: {1} oro.
|
msg.hero_slain = {0} sconfitto! Bottino: {1} oro.
|
||||||
|
msg.thief_in_treasury = C'è un ladro nella tua tesoreria!
|
||||||
|
msg.thief_escaped = Un ladro è scappato con {0} oro!
|
||||||
|
msg.snare_caught = È finito in una tagliola: {0}!
|
||||||
|
msg.converted = L'inquisitore ha convertito una delle tue creature: {0}!
|
||||||
msg.creature_died = È caduto in battaglia: {0}.
|
msg.creature_died = È caduto in battaglia: {0}.
|
||||||
msg.portal_claimed = Il portale è tuo! Attira le creature.
|
msg.portal_claimed = Il portale è tuo! Attira le creature.
|
||||||
msg.flag_removed = Bandiera d'attacco rimossa. Le creature tornano indietro.
|
msg.flag_removed = Bandiera d'attacco rimossa. Le creature tornano indietro.
|
||||||
msg.flag_set = Bandiera d'attacco piantata! Le creature pronte alla lotta si radunano lì.
|
msg.flag_set = Bandiera d'attacco piantata! Le creature pronte alla lotta si radunano lì.
|
||||||
msg.creature_leaves = Una creatura scontenta lascia il tuo regno: {0}!
|
msg.creature_leaves = Una creatura scontenta lascia il tuo regno: {0}!
|
||||||
msg.no_gold = Oro insufficiente!
|
msg.no_gold = Oro insufficiente!
|
||||||
|
msg.no_wood = Legno insufficiente! Abbatti funghi giganti (o alberi) con il piccone.
|
||||||
|
msg.siege_built = Un {0} è pronto: metti la bandiera d'attacco per mandarlo.
|
||||||
msg.sold = Stanze vendute: +{0} oro.
|
msg.sold = Stanze vendute: +{0} oro.
|
||||||
msg.no_mana_bolt = Mana insufficiente per il Fulmine.
|
msg.no_mana_bolt = Mana insufficiente per il Fulmine.
|
||||||
msg.no_mana_heal = Mana insufficiente per la Cura.
|
msg.no_mana_heal = Mana insufficiente per la Cura.
|
||||||
@@ -73,6 +85,7 @@ msg.no_mana_cost = Mana insufficiente (servono {0}).
|
|||||||
hint.portal_no_lair = Il portale è aperto, ma senza una tana non verrà nessuna creatura.
|
hint.portal_no_lair = Il portale è aperto, ma senza una tana non verrà nessuna creatura.
|
||||||
hint.no_farm = Le creature vogliono mangiare: costruisci una fungaia.
|
hint.no_farm = Le creature vogliono mangiare: costruisci una fungaia.
|
||||||
hint.lair_full = Tutti i letti della tana sono occupati. Ingrandisci la tana.
|
hint.lair_full = Tutti i letti della tana sono occupati. Ingrandisci la tana.
|
||||||
|
hint.creature_max = Il tuo dungeon ospita 100 creature: dal portale non ne arrivano altre.
|
||||||
hint.gate_attack = Le tue creature attaccano la Porta degli Eroi!
|
hint.gate_attack = Le tue creature attaccano la Porta degli Eroi!
|
||||||
hint.treasury_full = Le tesorerie sono piene! Costruisci altre caselle di tesoreria.
|
hint.treasury_full = Le tesorerie sono piene! Costruisci altre caselle di tesoreria.
|
||||||
hint.flag_unreachable = Le tue creature non riescono a raggiungere la bandiera d'attacco.
|
hint.flag_unreachable = Le tue creature non riescono a raggiungere la bandiera d'attacco.
|
||||||
@@ -124,6 +137,7 @@ ui.key = Tasto: {0}
|
|||||||
|
|
||||||
# ---- HUD
|
# ---- HUD
|
||||||
hud.gold = Oro
|
hud.gold = Oro
|
||||||
|
hud.wood = Legno
|
||||||
hud.mana = Mana
|
hud.mana = Mana
|
||||||
hud.core = Nucleo
|
hud.core = Nucleo
|
||||||
hud.gate = Porta
|
hud.gate = Porta
|
||||||
@@ -145,6 +159,7 @@ help.k_right = Clic destro Posa un'unità (prigionieri: anche nella camera d
|
|||||||
help.k_slap = Shift+Sinistro Mano: schiaffo alle tue unità, pugno agli eroi sul tuo suolo
|
help.k_slap = Shift+Sinistro Mano: schiaffo alle tue unità, pugno agli eroi sul tuo suolo
|
||||||
help.k_pan = Destro+trascina Sposta la mappa (anche WASD/frecce, bordo schermo)
|
help.k_pan = Destro+trascina Sposta la mappa (anche WASD/frecce, bordo schermo)
|
||||||
help.k_rot = Centro+trascina Ruota e inclina la camera (anche Q/E, R/F)
|
help.k_rot = Centro+trascina Ruota e inclina la camera (anche Q/E, R/F)
|
||||||
|
help.k_reset = Centro 2x clic Ripristina angolo e rotazione (il posto resta)
|
||||||
help.k_zoom = Rotellina Zoom
|
help.k_zoom = Rotellina Zoom
|
||||||
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Scegli strumento
|
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Scegli strumento
|
||||||
help.k_misc = P / Spazio Pausa M Audio sì/no +/- Volume
|
help.k_misc = P / Spazio Pausa M Audio sì/no +/- Volume
|
||||||
@@ -237,6 +252,7 @@ set.lights = Fonti di luce
|
|||||||
set.ui_scale = Dimensione interfaccia
|
set.ui_scale = Dimensione interfaccia
|
||||||
set.game_speed = Velocità di gioco
|
set.game_speed = Velocità di gioco
|
||||||
set.hints = Suggerimenti
|
set.hints = Suggerimenti
|
||||||
|
set.events = Eventi casuali
|
||||||
set.keys_hint = Clicca un'azione e premi un tasto. I tasti già in uso vengono scambiati.
|
set.keys_hint = Clicca un'azione e premi un tasto. I tasti già in uso vengono scambiati.
|
||||||
set.press_key = Premi un tasto per: {0}
|
set.press_key = Premi un tasto per: {0}
|
||||||
set.esc_cancel = Esc annulla
|
set.esc_cancel = Esc annulla
|
||||||
@@ -248,6 +264,8 @@ val.medium = Media
|
|||||||
val.high = Alta
|
val.high = Alta
|
||||||
val.normal = Normale
|
val.normal = Normale
|
||||||
val.large = Grande
|
val.large = Grande
|
||||||
|
val.small = Piccolo
|
||||||
|
val.auto_plain = Automatico
|
||||||
|
|
||||||
# ---- key binding names
|
# ---- key binding names
|
||||||
key.cam_forward = Camera avanti
|
key.cam_forward = Camera avanti
|
||||||
@@ -313,12 +331,30 @@ hint.tower_max = Questa torre è al massimo potenziamento.
|
|||||||
hint.tower_where = Le torri si costruiscono solo su caselle di pavimento conquistate e vuote.
|
hint.tower_where = Le torri si costruiscono solo su caselle di pavimento conquistate e vuote.
|
||||||
hint.tower_mana = Le tue torri magiche sono rimaste senza mana.
|
hint.tower_mana = Le tue torri magiche sono rimaste senza mana.
|
||||||
job.repair = Ripara una torre
|
job.repair = Ripara una torre
|
||||||
|
job.mend_bridge = Ripara un ponte
|
||||||
|
job.raise_wall = Alza un muro
|
||||||
|
job.march = Marcia verso una spada
|
||||||
job.attack_tower = Attacca una torre
|
job.attack_tower = Attacca una torre
|
||||||
|
|
||||||
unit.jailer = Carceriere
|
unit.jailer = Carceriere
|
||||||
unit.priestess = Sacerdotessa della Fiamma
|
unit.priestess = Sacerdotessa della Fiamma
|
||||||
msg.hero_captured = {0} stordito e fatto prigioniero! Bottino: {1} oro.
|
msg.hero_captured = {0} stordito e fatto prigioniero! Bottino: {1} oro.
|
||||||
msg.defected = {0} è passato dalla tua parte!
|
msg.defected = {0} è passato dalla tua parte!
|
||||||
|
msg.beast_tamed = Un {0} selvatico si arrende e si unisce al tuo dungeon!
|
||||||
|
gear.1 = equipaggiamento di ferro
|
||||||
|
gear.2 = equipaggiamento d'acciaio
|
||||||
|
gear.3 = equipaggiamento runico
|
||||||
|
msg.gear_given = La forgia consegna {0} a: {1}.
|
||||||
|
msg.trader_arrives = Un mercante è arrivato dal tuo portale: {0} per {1} oro. Cliccalo con la mano per comprare: aspetta un minuto.
|
||||||
|
msg.trader_sold = Comprato: {0}. Un piacere, dice il mercante.
|
||||||
|
msg.trader_left = Il mercante alza le spalle e se ne va dal portale.
|
||||||
|
trader.info = Offre {0} per {1} oro (clic con la mano)
|
||||||
|
trader.left = Se ne va tra {0} s
|
||||||
|
trader.rune = i piani di una torre della forgia runica
|
||||||
|
trader.wyrm = un drago di brace
|
||||||
|
trader.chainbreaker = uno spezzacatene
|
||||||
|
trader.wood = 30 legno
|
||||||
|
trader.mana = una pozione che riempie il mana
|
||||||
msg.prisoner_died = {0} è morto sotto tortura.
|
msg.prisoner_died = {0} è morto sotto tortura.
|
||||||
job.carry = Trasporta un prigioniero
|
job.carry = Trasporta un prigioniero
|
||||||
job.pray = Prega
|
job.pray = Prega
|
||||||
@@ -329,6 +365,10 @@ info.tortured = sotto tortura
|
|||||||
info.prison = Prigionieri: {0}/{1}
|
info.prison = Prigionieri: {0}/{1}
|
||||||
|
|
||||||
msg.wave_warn_dir = Gli esploratori riferiscono: l'ondata di eroi {0} raggiunge la porta tra 30 secondi! Vogliono irrompere da {1}.
|
msg.wave_warn_dir = Gli esploratori riferiscono: l'ondata di eroi {0} raggiunge la porta tra 30 secondi! Vogliono irrompere da {1}.
|
||||||
|
msg.event_quake = Terremoto! Sono crollati dei tunnel a {0}.
|
||||||
|
msg.event_lava = La lava straripa a {0}!
|
||||||
|
msg.event_flood = Inondazione! Il fiume straripa a {0}.
|
||||||
|
msg.event_vein = I diavoletti segnalano una nuova vena d'oro a {0}!
|
||||||
msg.wave_warn_two = Gli esploratori riferiscono: l'ondata di eroi {0} raggiunge la porta tra 30 secondi! Si dividono e irrompono da {1} e da {2}.
|
msg.wave_warn_two = Gli esploratori riferiscono: l'ondata di eroi {0} raggiunge la porta tra 30 secondi! Si dividono e irrompono da {1} e da {2}.
|
||||||
dir.n = nord
|
dir.n = nord
|
||||||
dir.ne = nord-est
|
dir.ne = nord-est
|
||||||
@@ -340,7 +380,8 @@ dir.w = ovest
|
|||||||
dir.nw = nord-ovest
|
dir.nw = nord-ovest
|
||||||
|
|
||||||
val.auto = Auto ({0}x)
|
val.auto = Auto ({0}x)
|
||||||
set.render_scale = Risoluzione 3D
|
set.render_scale = Aspetto pixel
|
||||||
|
set.pixel_rows_val = {0} righe
|
||||||
set.debug_fps = Mostra FPS
|
set.debug_fps = Mostra FPS
|
||||||
set.debug_frame_time = Mostra tempo frame
|
set.debug_frame_time = Mostra tempo frame
|
||||||
set.debug_ping = Mostra ping
|
set.debug_ping = Mostra ping
|
||||||
@@ -398,8 +439,25 @@ help.start_touch = Tocca per iniziare
|
|||||||
tool.bridge.label = Ponte
|
tool.bridge.label = Ponte
|
||||||
tool.bridge.name = Costruisci ponte
|
tool.bridge.name = Costruisci ponte
|
||||||
tool.bridge.desc = Costruisce un ponte su acqua o lava. Trascina dal tuo suolo attraverso il fiume; il ponte cresce casella per casella dal tuo territorio.
|
tool.bridge.desc = Costruisce un ponte su acqua o lava. Trascina dal tuo suolo attraverso il fiume; il ponte cresce casella per casella dal tuo territorio.
|
||||||
cost.bridge = {0} oro/casella sull'acqua, {1} sulla lava
|
tool.drawbridge.label = Levatoio
|
||||||
|
tool.drawbridge.name = Costruisci ponte levatoio
|
||||||
|
tool.drawbridge.desc = Un ponte che puoi sollevare: trascina sul fiume per costruirlo, clicca per cambiare: giù, su, automatico (si alza quando arrivano nemici). Il legno sull'acqua brucia, la pietra sulla lava no.
|
||||||
|
tool.wall.label = Muro
|
||||||
|
tool.wall.name = Alza muro
|
||||||
|
tool.wall.desc = Pianifica muri sul tuo pavimento vuoto (trascina di nuovo per annullare): i diavoletti li alzano come terra rinforzata. Chiudi i tunnel e metti le porte dove contano.
|
||||||
|
tool.ram.label = Ariete
|
||||||
|
tool.ram.name = Costruisci ariete
|
||||||
|
tool.ram.desc = Un ariete esce dalla tua forgia (oro e legno). Segue la tua bandiera d'attacco e sfonda porta e torri dei cavalieri.
|
||||||
|
tool.catapult.label = Catapulta
|
||||||
|
tool.catapult.name = Costruisci catapulta
|
||||||
|
tool.catapult.desc = Una catapulta esce dalla tua forgia (oro e legno). Colpisce da lontano le torri dei cavalieri e segue la tua bandiera fino alla loro porta.
|
||||||
|
cost.bridge = {0} oro + {1} legno/casella sull'acqua, {2} oro sulla lava
|
||||||
|
cost.drawbridge = {0} oro + {1} legno/casella sull'acqua, {2} oro sulla lava
|
||||||
|
cost.gold_wood = {0} oro + {1} legno
|
||||||
hint.bridge_where = I ponti devono partire dal tuo suolo: trascina dal tuo territorio attraverso il fiume.
|
hint.bridge_where = I ponti devono partire dal tuo suolo: trascina dal tuo territorio attraverso il fiume.
|
||||||
|
hint.bridge_fire = I ponti di legno bruciano: tieni lontani trappole di fuoco, soffio infuocato e fulmini; i ponti di pietra sulla lava sono ignifughi.
|
||||||
|
hint.wall_where = I muri si alzano solo sul tuo pavimento vuoto (niente stanze, trappole o porte).
|
||||||
|
hint.siege_where = Le macchine d'assedio escono sul tuo terreno libero, e serve una forgia.
|
||||||
hint.bridge_river = I ponti si costruiscono solo su acqua o lava.
|
hint.bridge_river = I ponti si costruiscono solo su acqua o lava.
|
||||||
msg.hero_burned = Un eroe è caduto nella lava: {0}! +{1} oro.
|
msg.hero_burned = Un eroe è caduto nella lava: {0}! +{1} oro.
|
||||||
msg.creature_burned = È caduto nella lava: {0}.
|
msg.creature_burned = È caduto nella lava: {0}.
|
||||||
@@ -409,6 +467,22 @@ tile.lava = Lava
|
|||||||
tile.lava_info = Ciò che ci cade brucia. I ponti di pietra la attraversano.
|
tile.lava_info = Ciò che ci cade brucia. I ponti di pietra la attraversano.
|
||||||
tile.bridge = Ponte di legno
|
tile.bridge = Ponte di legno
|
||||||
tile.lavabridge = Ponte di pietra
|
tile.lavabridge = Ponte di pietra
|
||||||
|
tile.mushroom = Fungo gigante
|
||||||
|
tile.mushroom_grown = Cresciuto: pronto per il taglio.
|
||||||
|
tile.mushroom_growing = Cresce: {0}% - si può abbattere solo quando è cresciuto.
|
||||||
|
tile.grove = Boschetto di funghi
|
||||||
|
tile.grove_info = Nessuno può conquistare questo muschio. Qui ricrescono funghi giganti.
|
||||||
|
tile.tree = Albero
|
||||||
|
tile.plant_info = Segnalo col piccone: un diavoletto lo abbatte per {0} legno.
|
||||||
|
tile.drawbridge = Ponte levatoio
|
||||||
|
tile.drawbridge_up = Sollevato
|
||||||
|
tile.drawbridge_down = Abbassato
|
||||||
|
tile.drawbridge_auto = Automatico (giù)
|
||||||
|
tile.drawbridge_auto_up = Automatico (su)
|
||||||
|
tile.obsidian = Ossidiana
|
||||||
|
tile.obsidian_info = Lava raffreddata: si scioglie tra {0} s
|
||||||
|
tile.bridge_burning = Brucia!
|
||||||
|
tile.bridge_worn = Danneggiato {0}%
|
||||||
tile.shard = Frammento del tuo nucleo
|
tile.shard = Frammento del tuo nucleo
|
||||||
tile.shard_info = Conquista questa camera per riprenderti il frammento.
|
tile.shard_info = Conquista questa camera per riprenderti il frammento.
|
||||||
|
|
||||||
@@ -430,6 +504,11 @@ msg.enemy_tower_destroyed = È caduta una {0} dei cavalieri!
|
|||||||
msg.vault_taken = Caveau dei cavalieri preso: tesori per {0} sono tuoi.
|
msg.vault_taken = Caveau dei cavalieri preso: tesori per {0} sono tuoi.
|
||||||
msg.shard_taken = Un frammento del tuo nucleo è di nuovo nelle tue mani!
|
msg.shard_taken = Un frammento del tuo nucleo è di nuovo nelle tue mani!
|
||||||
msg.bridge_torn = I cavalieri hanno abbattuto uno dei tuoi ponti!
|
msg.bridge_torn = I cavalieri hanno abbattuto uno dei tuoi ponti!
|
||||||
|
msg.bridge_fire = Uno dei tuoi ponti è in fiamme!
|
||||||
|
msg.drawbridge_up = Il ponte levatoio è sollevato.
|
||||||
|
msg.drawbridge_down = Il ponte levatoio è abbassato.
|
||||||
|
msg.drawbridge_auto = Il ponte levatoio si alza da solo quando arrivano nemici.
|
||||||
|
msg.drawbridge_rises = Arrivano nemici: un ponte levatoio si alza!
|
||||||
job.tear_bridge = Abbatte un ponte
|
job.tear_bridge = Abbatte un ponte
|
||||||
job.patrol = Di pattuglia
|
job.patrol = Di pattuglia
|
||||||
job.guard = Di guardia
|
job.guard = Di guardia
|
||||||
@@ -533,7 +612,7 @@ camp.m29.story = Isole di lava, maestro, con fonti di mana che ribollono nel mez
|
|||||||
camp.m20.title = Il Gran Maestro
|
camp.m20.title = Il Gran Maestro
|
||||||
camp.m20.story = Il Gran Maestro Valeriano Scudolucente attende nella sua cittadella oltre il grande fiume, con l'ultimo frammento e la porta dell'ordine. Per cento anni ha pregato, lucidato e si è preparato. | Distruggi la sua porta e riprenditi il frammento. Allora il tuo nucleo sarà intero - e ogni maestro che l'abbia mai voluto verrà a prenderselo tutto in una volta. Una cosa alla volta.
|
camp.m20.story = Il Gran Maestro Valeriano Scudolucente attende nella sua cittadella oltre il grande fiume, con l'ultimo frammento e la porta dell'ordine. Per cento anni ha pregato, lucidato e si è preparato. | Distruggi la sua porta e riprenditi il frammento. Allora il tuo nucleo sarà intero - e ogni maestro che l'abbia mai voluto verrà a prenderselo tutto in una volta. Una cosa alla volta.
|
||||||
camp.m30.title = Il trono degli abissi
|
camp.m30.title = Il trono degli abissi
|
||||||
camp.m30.story = È andata come dicevo: il tuo nucleo è intero, e ora lo vogliono tutti. Mossgrim e Lady Nightwell marciano insieme, e ciò che resta dell'Ordine apre la sua porta un'ultima volta. | Distruggi entrambi i nuclei dei maestri e la porta dell'ordine. Dopo, gli abissi avranno esattamente un maestro. Indovina chi. Un indizio: non io, io sono quello del tè.
|
camp.m30.story = È andata come dicevo: il tuo nucleo è intero, e ora lo vogliono tutti. Mossgrim e Lady Nightwell hanno scavato fino in superficie, e l'Alto Maresciallo aspetta nella sua fortezza nel bosco: luce del giorno, uccelli, tutto il pacchetto. Disgustoso. | Esci dalla roccia, distruggi entrambi i nuclei dei maestri e la porta dell'ordine dentro la fortezza. Dopo, il mondo avrà un solo maestro. Indovina chi. Suggerimento: non io, io sono quello del tè.
|
||||||
camp.epilogue = Il nucleo verde si sbriciola, quello blu si spegne e la porta dell'ordine tace. Da qualche parte lassù, la Guardia della Luce sta scrivendo un rapporto lunghissimo, e le lettere di Lady Nightwell hanno smesso di arrivare. | Ditastorte ha preparato il tè. Sei di nuovo intero, maestro. Gli inferi sono tuoi.
|
camp.epilogue = Il nucleo verde si sbriciola, quello blu si spegne e la porta dell'ordine tace. Da qualche parte lassù, la Guardia della Luce sta scrivendo un rapporto lunghissimo, e le lettere di Lady Nightwell hanno smesso di arrivare. | Ditastorte ha preparato il tè. Sei di nuovo intero, maestro. Gli inferi sono tuoi.
|
||||||
lair.arrivals = {0} letti liberi: le nuove creature arrivano al {1}% della velocità
|
lair.arrivals = {0} letti liberi: le nuove creature arrivano al {1}% della velocità
|
||||||
job.help = Aiuta
|
job.help = Aiuta
|
||||||
@@ -657,6 +736,7 @@ net.error = {0}
|
|||||||
net.rooms = Partite aperte
|
net.rooms = Partite aperte
|
||||||
net.no_rooms = Nessuna partita aperta.
|
net.no_rooms = Nessuna partita aperta.
|
||||||
net.create = Crea partita
|
net.create = Crea partita
|
||||||
|
net.map_size = Mappa {0}x{1} · {2} giocatori
|
||||||
net.coop_mission = Campagna co-op: missione {0} ({1})
|
net.coop_mission = Campagna co-op: missione {0} ({1})
|
||||||
net.coop_create = Campagna co-op
|
net.coop_create = Campagna co-op
|
||||||
net.join = Entra
|
net.join = Entra
|
||||||
@@ -687,6 +767,9 @@ net.ai_off = No
|
|||||||
|
|
||||||
# rival masters (free game)
|
# rival masters (free game)
|
||||||
newgame.difficulty = Avversari
|
newgame.difficulty = Avversari
|
||||||
|
newgame.land = Terreno
|
||||||
|
newgame.land0 = Sotterraneo
|
||||||
|
newgame.land1 = Metà in superficie
|
||||||
diff.0 = Facile
|
diff.0 = Facile
|
||||||
diff.1 = Medio
|
diff.1 = Medio
|
||||||
diff.2 = Difficile
|
diff.2 = Difficile
|
||||||
@@ -699,9 +782,14 @@ msg.rival1_attack = Mossgrim, il maestro verde, ti manda contro {0} creature!
|
|||||||
msg.rival2_attack = Lady Nightwell, la maestra blu, ti manda contro {0} creature!
|
msg.rival2_attack = Lady Nightwell, la maestra blu, ti manda contro {0} creature!
|
||||||
msg.rival1_out = Mossgrim è sconfitto - il suo nucleo dell'anima è in frantumi!
|
msg.rival1_out = Mossgrim è sconfitto - il suo nucleo dell'anima è in frantumi!
|
||||||
msg.rival2_out = Lady Nightwell è sconfitta - il suo nucleo dell'anima è in frantumi!
|
msg.rival2_out = Lady Nightwell è sconfitta - il suo nucleo dell'anima è in frantumi!
|
||||||
|
msg.rival3_attack = Saffrax, il signore giallo, manda {0} creature contro di te!
|
||||||
|
msg.rival3_out = Saffrax è sconfitto: il suo nucleo dell'anima è andato in frantumi!
|
||||||
msg.rival_unit_slain = Cade una creatura di un maestro rivale: {0}! +{1} oro.
|
msg.rival_unit_slain = Cade una creatura di un maestro rivale: {0}! +{1} oro.
|
||||||
msg.wave_on_rival1 = Ondata {0}: {1} eroi marciano sul maestro verde.
|
msg.wave_on_rival1 = Ondata {0}: {1} eroi marciano sul maestro verde.
|
||||||
msg.wave_on_rival2 = Ondata {0}: {1} eroi marciano sulla maestra blu.
|
msg.wave_on_rival2 = Ondata {0}: {1} eroi marciano sulla maestra blu.
|
||||||
|
msg.wave_warn_on_master = Gli esploratori riferiscono: l'ondata {0} arriva tra 30 secondi e punta sul maestro {1}.
|
||||||
|
msg.wave_on_master = Ondata {0}: {1} eroi marciano contro il maestro {2}.
|
||||||
|
msg.partner_out = Il cuore del tuo alleato IA è caduto: combatti da solo!
|
||||||
hud.rival1 = Verde
|
hud.rival1 = Verde
|
||||||
hud.rival2 = Blu
|
hud.rival2 = Blu
|
||||||
msg.creature_freed = Spezza le catene e si unisce a te: {0}!
|
msg.creature_freed = Spezza le catene e si unisce a te: {0}!
|
||||||
@@ -790,6 +878,8 @@ best.b.torture.text = Gli eroi catturati finiscono qui. Con il tempo e un carcer
|
|||||||
best.b.temple.text = Le preghiere curano il nucleo dell'anima e tengono alto il morale. La sacerdotessa della fiamma viene per un tempio.
|
best.b.temple.text = Le preghiere curano il nucleo dell'anima e tengono alto il morale. La sacerdotessa della fiamma viene per un tempio.
|
||||||
best.b.aerie.text = Posatoi e nidi per creature volanti. Pipistrelli, gargoyle, gufi e perfino il drago di brace hanno bisogno di spazio qui.
|
best.b.aerie.text = Posatoi e nidi per creature volanti. Pipistrelli, gargoyle, gufi e perfino il drago di brace hanno bisogno di spazio qui.
|
||||||
best.b.bridge.text = Legno sull'acqua, pietra sulla lava. I cavalieri abbattono i tuoi ponti - conquista i loro.
|
best.b.bridge.text = Legno sull'acqua, pietra sulla lava. I cavalieri abbattono i tuoi ponti - conquista i loro.
|
||||||
|
best.b.drawbridge.text = Sollevato, il fiume torna a essere un muro. In automatico si alza quando arrivano nemici e si abbassa per le tue creature.
|
||||||
|
best.b.wall.text = Un tunnel scavato non è per sempre: murato di nuovo, agli eroi resta una sola via, attraverso la tua porta.
|
||||||
best.b.steel.text = Piastre di ferro rivettate su un muro rinforzato. Nessuno ci scava attraverso in fretta, nemmeno i nani.
|
best.b.steel.text = Piastre di ferro rivettate su un muro rinforzato. Nessuno ci scava attraverso in fretta, nemmeno i nani.
|
||||||
best.b.arrow.text = La freccia trapassa fino a due nemici dietro il bersaglio. Economica e affidabile.
|
best.b.arrow.text = La freccia trapassa fino a due nemici dietro il bersaglio. Economica e affidabile.
|
||||||
best.b.cannon.text = Danno ad area e contraccolpo - spingi i cavalieri nella lava.
|
best.b.cannon.text = Danno ad area e contraccolpo - spingi i cavalieri nella lava.
|
||||||
@@ -801,7 +891,7 @@ room.forge = Fucina
|
|||||||
tool.forge.label = Fucina
|
tool.forge.label = Fucina
|
||||||
tool.forge.desc = Le creature fabbricano qui trappole e porte - lavoro, niente oro. I pezzi finiti aspettano in casse finché un imp non li installa. Gli Ingranotti vengono per una fucina di 6+ caselle.
|
tool.forge.desc = Le creature fabbricano qui trappole e porte - lavoro, niente oro. I pezzi finiti aspettano in casse finché un imp non li installa. Gli Ingranotti vengono per una fucina di 6+ caselle.
|
||||||
unit.cogling = Ingranotto
|
unit.cogling = Ingranotto
|
||||||
cat.traps = Trappole
|
cat.traps = Costruire
|
||||||
cost.work = {0} di lavoro in fucina
|
cost.work = {0} di lavoro in fucina
|
||||||
job.forge = Forgia
|
job.forge = Forgia
|
||||||
job.install = Installa una trappola
|
job.install = Installa una trappola
|
||||||
@@ -878,6 +968,7 @@ research.now = In ricerca: {0} ({1}%)
|
|||||||
research.next = poi: {0}
|
research.next = poi: {0}
|
||||||
research.done = Qui non resta niente da ricercare.
|
research.done = Qui non resta niente da ricercare.
|
||||||
msg.researched = Ricerca completata: {0}! Ora è nel menu di costruzione.
|
msg.researched = Ricerca completata: {0}! Ora è nel menu di costruzione.
|
||||||
|
msg.room_upgraded = Ricerca: {0} raggiunge il livello {1}: funziona un quarto meglio, e si vede.
|
||||||
msg.tool_research = Non ancora ricercato - ci lavorano le creature che studiano in biblioteca.
|
msg.tool_research = Non ancora ricercato - ci lavorano le creature che studiano in biblioteca.
|
||||||
|
|
||||||
msg.script = {0}
|
msg.script = {0}
|
||||||
@@ -915,6 +1006,9 @@ tool.plague.desc = Una malattia che divora i nemici intorno al punto e salta dal
|
|||||||
tool.quake.label = Sisma
|
tool.quake.label = Sisma
|
||||||
tool.quake.name = Terremoto
|
tool.quake.name = Terremoto
|
||||||
tool.quake.desc = La terra trema: i nemici intorno al punto vengono feriti e storditi per due secondi. Le tue creature restano in piedi.
|
tool.quake.desc = La terra trema: i nemici intorno al punto vengono feriti e storditi per due secondi. Le tue creature restano in piedi.
|
||||||
|
tool.freeze.label = Gelo
|
||||||
|
tool.freeze.name = Gelo
|
||||||
|
tool.freeze.desc = Freddo glaciale: la lava intorno si raffredda in ossidiana calpestabile per 40 secondi, i nemici vengono gelati e rallentati.
|
||||||
tile.unexplored = Oscurità inesplorata
|
tile.unexplored = Oscurità inesplorata
|
||||||
|
|
||||||
# personality
|
# personality
|
||||||
@@ -967,6 +1061,9 @@ tool.arena.desc = Le creature si sfidano a coppie: il doppio dell'esperienza del
|
|||||||
tool.altar.label = Altare
|
tool.altar.label = Altare
|
||||||
tool.altar.desc = Posa una creatura sull'altare per sacrificarla. Certe coppie di sacrifici portano una creatura rara, altrimenti il dungeon viene benedetto: umore migliore e mana.
|
tool.altar.desc = Posa una creatura sull'altare per sacrificarla. Certe coppie di sacrifici portano una creatura rara, altrimenti il dungeon viene benedetto: umore migliore e mana.
|
||||||
unit.revenant = Redivivo
|
unit.revenant = Redivivo
|
||||||
|
unit.cavebear = Orso delle caverne
|
||||||
|
unit.beetle = Scarabeo gigante
|
||||||
|
unit.trader = Mercante
|
||||||
job.guard_post = Di guardia
|
job.guard_post = Di guardia
|
||||||
job.spar = Si allena in coppia
|
job.spar = Si allena in coppia
|
||||||
msg.revenant_rises = Un redivivo risorge dal cimitero e ti serve!
|
msg.revenant_rises = Un redivivo risorge dal cimitero e ti serve!
|
||||||
@@ -975,6 +1072,10 @@ msg.altar_gift = L'altare accetta la coppia: appare {0}!
|
|||||||
msg.altar_blessing = Sacrificio compiuto: {0}. Il dungeon è benedetto.
|
msg.altar_blessing = Sacrificio compiuto: {0}. Il dungeon è benedetto.
|
||||||
best.revenant.lure = risorge dal cimitero
|
best.revenant.lure = risorge dal cimitero
|
||||||
best.revenant.text = Un redivivo non mangia, non dorme e non si lamenta - il dipendente perfetto, se sopporti l'odore. | Con un po' di esperienza beve la forza di chi colpisce. Lui e la sacerdotessa non vanno d'accordo, per ovvi motivi.
|
best.revenant.text = Un redivivo non mangia, non dorme e non si lamenta - il dipendente perfetto, se sopporti l'odore. | Con un po' di esperienza beve la forza di chi colpisce. Lui e la sacerdotessa non vanno d'accordo, per ovvi motivi.
|
||||||
|
best.cavebear.lure = sconfiggerlo nella sua tana avendo una prigione
|
||||||
|
best.cavebear.text = Un orso che non ha mai visto il sole e non gli manca. Irsuto, forte e scontroso fino a colazione. | Sconfiggilo nella sua tana finché una prigione ha posto: si arrende e si unisce a te. Ama l'orto di funghi, e i ragni, come spuntino.
|
||||||
|
best.beetle.lure = sconfiggerlo nella sua tana avendo una prigione
|
||||||
|
best.beetle.text = Uno scarabeo grande come una botte, corazzato come un cavaliere e due volte più educato. | Sconfiggilo nella sua tana finché una prigione ha posto e si unirà a te. Dorme nella tana e non sopporta i pipistrelli.
|
||||||
best.b.prison.text = File di celle. Qui gli eroi marciscono in silenzio finché qualcuno non decide cosa farne.
|
best.b.prison.text = File di celle. Qui gli eroi marciscono in silenzio finché qualcuno non decide cosa farne.
|
||||||
best.b.graveyard.text = Muschio, lapidi e terra fresca. Quaggiù nessuno resta morto a lungo.
|
best.b.graveyard.text = Muschio, lapidi e terra fresca. Quaggiù nessuno resta morto a lungo.
|
||||||
best.b.guardpost.text = Un braciere, uno stendardo e una creatura annoiata dagli occhi acuti.
|
best.b.guardpost.text = Un braciere, uno stendardo e una creatura annoiata dagli occhi acuti.
|
||||||
@@ -987,23 +1088,39 @@ ability.drain.desc = si cura di un terzo del danno che infligge.
|
|||||||
unit.giant = Gigante
|
unit.giant = Gigante
|
||||||
unit.fairy = Fata spia
|
unit.fairy = Fata spia
|
||||||
unit.battlemage = Mago da battaglia
|
unit.battlemage = Mago da battaglia
|
||||||
|
unit.thief = Ladro
|
||||||
|
unit.monk = Monaco
|
||||||
|
unit.ranger = Ranger
|
||||||
|
unit.inquisitor = Inquisitore
|
||||||
|
unit.ram = Ariete
|
||||||
|
unit.catapult = Catapulta
|
||||||
msg.trap_wrecked = Gli eroi hanno distrutto una trappola: {0}.
|
msg.trap_wrecked = Gli eroi hanno distrutto una trappola: {0}.
|
||||||
best.giant.text = Un gigante è ciò che succede quando un cavaliere mangia la verdura per trecento anni. Lento, enorme, e considera le porte un semplice suggerimento. | Distrugge le trappole solo stando loro accanto. Maniere rivoltanti. Colpiscilo con tutto, preferibilmente da lontano.
|
best.giant.text = Un gigante è ciò che succede quando un cavaliere mangia la verdura per trecento anni. Lento, enorme, e considera le porte un semplice suggerimento. | Distrugge le trappole solo stando loro accanto. Maniere rivoltanti. Colpiscilo con tutto, preferibilmente da lontano.
|
||||||
best.fairy.text = La piccola spia dell'Ordine: un moscerino luminoso con le ali, mandato avanti a trovare le tue trappole e a dirlo a tutti. Appena ha visto una trappola, gli eroi la distruggono. | Sparge anche polvere curativa sugli eroi. Schiacciala per prima. Due volte, per sicurezza.
|
best.fairy.text = La piccola spia dell'Ordine: un moscerino luminoso con le ali, mandato avanti a trovare le tue trappole e a dirlo a tutti. Appena ha visto una trappola, gli eroi la distruggono. | Sparge anche polvere curativa sugli eroi. Schiacciala per prima. Due volte, per sicurezza.
|
||||||
best.battlemage.text = Un mago con la tonaca dell'Ordine che considera i muri un insulto personale. I muri rinforzati scoppiano sotto le sue palle di fuoco, e lui non smette mai di vantarsene. | Fragile da vicino. I muri d'acciaio lo irritano più di tutto - bene.
|
best.battlemage.text = Un mago con la tonaca dell'Ordine che considera i muri un insulto personale. I muri rinforzati scoppiano sotto le sue palle di fuoco, e lui non smette mai di vantarsene. | Fragile da vicino. I muri d'acciaio lo irritano più di tutto - bene.
|
||||||
|
best.thief.text = Dita svelte, piedi ancora più svelti. Non viene per combattere: si infila nella tua tesoreria, riempie il sacco e corre di nuovo alla porta. | Uccidilo prima che scappi e l'oro torna tuo - con gli interessi.
|
||||||
|
best.monk.text = Anni di digiuno e preghiera lo hanno reso sordo alla tua magia: fulmine, peste e gelo gli scivolano addosso come pioggia su un tetto. | Anche i pugni fanno male. Accoglilo con acciaio, artigli e denti.
|
||||||
|
best.ranger.text = Un uomo dei boschi dell'Ordine che tira da lontano e lascia tagliole di ferro nei tuoi corridoi. La creatura che ci mette il piede resta ferita e bloccata per un po'. | Le tagliole restano finché qualcuno non ci finisce dentro. Meglio un coboldo che il tuo orco.
|
||||||
|
best.inquisitor.text = Fragile, ma le sue prediche sono pericolose: ogni venti secondi convince una delle tue creature più deboli - un coboldo o un ragno - a passare dalla parte degli eroi. | Le creature forti gli ridono in faccia. Colpisci per primo.
|
||||||
|
best.ram.text = Un tronco con punta di ferro e un tetto contro le frecce. Delle tue creature non gli importa: solo di porte, muri e torri. | Fermalo prima che arrivi alla porta, o procurati una porta nuova.
|
||||||
|
best.catapult.text = Un braccio di legno, un secchio di sassi e un cavaliere con troppo tempo libero. Tira più lontano delle tue torri e le punta per prime. | Caricala: da vicino non fa male a una mosca.
|
||||||
|
|
||||||
# orders
|
# orders
|
||||||
tool.order_attack.label = Attacca
|
tool.order_attack.label = Attacca
|
||||||
tool.order_attack.name = All'attacco!
|
tool.order_attack.name = All'attacco!
|
||||||
tool.order_attack.desc = Clicca un nemico o una torre nemica: tutte le creature combattenti entro 18 caselle ci si lanciano. Il clic destro con uno strumento ordine annulla gli ordini di guardia.
|
tool.order_attack.desc = Clicca un nemico o una torre nemica: tutte le creature pronte entro 18 caselle lo attaccano. Clicca terreno libero: un segnalino spada; le creature pronte si dividono tra tutte le spade e combattono lì. Clic destro su un segnalino lo rimuove.
|
||||||
tool.order_guard.label = Guardia
|
tool.order_guard.label = Guardia
|
||||||
tool.order_guard.name = Sorveglia questa stanza
|
tool.order_guard.name = Sorveglia questa stanza
|
||||||
tool.order_guard.desc = Clicca una stanza: fino a tre creature vicine prendono posizione lì per due minuti e attaccano tutto ciò che entra in vista. Il clic destro annulla.
|
tool.order_guard.desc = Clicca il tuo terreno: un segnalino scudo che tre creature tengono e difendono. Fino a otto scudi; clic destro su uno scudo lo rimuove.
|
||||||
hint.order_attack = Clicca un nemico o una torre nemica.
|
hint.order_attack = Clicca un nemico o una torre nemica.
|
||||||
hint.order_guard = Clicca una delle tue stanze (o il tuo pavimento).
|
hint.order_guard = Clicca una delle tue stanze (o il tuo pavimento).
|
||||||
hint.order_nobody = Nessuna creatura è libera per quell'ordine.
|
hint.order_nobody = Nessuna creatura è libera per quell'ordine.
|
||||||
msg.order_attack = {0} creature all'attacco!
|
msg.order_attack = {0} creature all'attacco!
|
||||||
msg.order_guard = {0} creature prendono posizione.
|
msg.order_guard = {0} creature prendono posizione.
|
||||||
|
msg.mark_attack = Una spada segna il punto: le tue creature pronte marciano lì.
|
||||||
|
msg.mark_guard = Uno scudo segna il punto: tre creature lo terranno.
|
||||||
|
msg.mark_removed = Segnalino rimosso: le sue creature sono di nuovo libere.
|
||||||
|
hint.marks_full = Otto segnalini di quel tipo sono il massimo. Rimuovine uno con il clic destro.
|
||||||
msg.orders_cancelled = Ordini di guardia annullati.
|
msg.orders_cancelled = Ordini di guardia annullati.
|
||||||
|
|
||||||
# creature overview
|
# creature overview
|
||||||
@@ -1013,6 +1130,8 @@ ov.summary = {0} creature - salari {1} oro per paga - umore {2}%
|
|||||||
ov.none = Ancora nessuna creatura. Costruisci una tana e una fungaia e conquista un portale.
|
ov.none = Ancora nessuna creatura. Costruisci una tana e una fungaia e conquista un portale.
|
||||||
ov.hint = Liv, umore, salario, lavoro, salute - clic: vai lì
|
ov.hint = Liv, umore, salario, lavoro, salute - clic: vai lì
|
||||||
set.voice = Voce di Ditastorte
|
set.voice = Voce di Ditastorte
|
||||||
|
set.track = Brano musicale
|
||||||
|
val.track_auto = Automatico
|
||||||
|
|
||||||
# ---- campaign editor, block scripts, demo campaign
|
# ---- campaign editor, block scripts, demo campaign
|
||||||
menu.back_editor = Torna all'editor
|
menu.back_editor = Torna all'editor
|
||||||
@@ -1085,6 +1204,7 @@ ed.forts = Fortezze
|
|||||||
ed.rivals = Maestri rivali
|
ed.rivals = Maestri rivali
|
||||||
ed.difficulty = Difficoltà
|
ed.difficulty = Difficoltà
|
||||||
ed.portals = Portali
|
ed.portals = Portali
|
||||||
|
ed.surface = Superficie (est)
|
||||||
ed.edit_map = Modifica mappa
|
ed.edit_map = Modifica mappa
|
||||||
ed.make_random = Rendila casuale
|
ed.make_random = Rendila casuale
|
||||||
ed.paint_map = Genera e dipingi la mappa
|
ed.paint_map = Genera e dipingi la mappa
|
||||||
@@ -1164,6 +1284,10 @@ ed.terrain.silver = Vena d'argento
|
|||||||
ed.terrain.ruby = Vena di rubini
|
ed.terrain.ruby = Vena di rubini
|
||||||
ed.terrain.diamond = Vena di diamanti
|
ed.terrain.diamond = Vena di diamanti
|
||||||
ed.terrain.steel = Muro d'acciaio
|
ed.terrain.steel = Muro d'acciaio
|
||||||
|
ed.terrain.mushroom = Fungo gigante
|
||||||
|
ed.terrain.grove = Boschetto di funghi
|
||||||
|
ed.terrain.grass = Erba (superficie)
|
||||||
|
ed.terrain.tree = Albero
|
||||||
ed.place.core = Nucleo dell'Anima
|
ed.place.core = Nucleo dell'Anima
|
||||||
ed.place.portal = Portale
|
ed.place.portal = Portale
|
||||||
ed.place.gate = Porta degli Eroi
|
ed.place.gate = Porta degli Eroi
|
||||||
@@ -1194,6 +1318,7 @@ ed.level = Livello
|
|||||||
ed.zone_shape = Forma della nuova zona
|
ed.zone_shape = Forma della nuova zona
|
||||||
ed.zone_new_name = Nome della nuova zona
|
ed.zone_new_name = Nome della nuova zona
|
||||||
ed.zones = Zone
|
ed.zones = Zone
|
||||||
|
ed.zone_erase = Togli caselle
|
||||||
ed.shape.rect = Rettangolo
|
ed.shape.rect = Rettangolo
|
||||||
ed.shape.circle = Cerchio
|
ed.shape.circle = Cerchio
|
||||||
ed.shape.tiles = Caselle
|
ed.shape.tiles = Caselle
|
||||||
@@ -1243,6 +1368,8 @@ blk.reveal = Rivela
|
|||||||
blk.camera = Mostra
|
blk.camera = Mostra
|
||||||
blk.gold = Oro +
|
blk.gold = Oro +
|
||||||
blk.mana = Mana +
|
blk.mana = Mana +
|
||||||
|
blk.weather = Meteo in superficie
|
||||||
|
blk.talentxp = Esperienza talenti +
|
||||||
blk.if = Se
|
blk.if = Se
|
||||||
blk.repeat = Ripeti
|
blk.repeat = Ripeti
|
||||||
blk.trigger = Commuta trigger
|
blk.trigger = Commuta trigger
|
||||||
@@ -1262,6 +1389,8 @@ blk.help.reveal = Solleva la nebbia di guerra su una zona.
|
|||||||
blk.help.camera = Sposta la telecamera su una zona.
|
blk.help.camera = Sposta la telecamera su una zona.
|
||||||
blk.help.gold = Dà (o toglie) oro.
|
blk.help.gold = Dà (o toglie) oro.
|
||||||
blk.help.mana = Dà (o toglie) mana.
|
blk.help.mana = Dà (o toglie) mana.
|
||||||
|
blk.help.weather = Imposta il meteo in superficie (sereno, pioggia, nebbia o tempesta) per tanti secondi; 0 lo mantiene finché un altro blocco non lo cambia.
|
||||||
|
blk.help.talentxp = Dà al giocatore esperienza per i talenti del maestro.
|
||||||
blk.help.if = Esegue i blocchi interni solo se la condizione è vera, altrimenti quelli sotto "altrimenti".
|
blk.help.if = Esegue i blocchi interni solo se la condizione è vera, altrimenti quelli sotto "altrimenti".
|
||||||
blk.help.repeat = Esegue più volte i blocchi interni.
|
blk.help.repeat = Esegue più volte i blocchi interni.
|
||||||
blk.help.trigger = Attiva un trigger (può scattare di nuovo) o lo disattiva.
|
blk.help.trigger = Attiva un trigger (può scattare di nuovo) o lo disattiva.
|
||||||
@@ -1284,6 +1413,7 @@ blk.v.or = o
|
|||||||
blk.v.not = non
|
blk.v.not = non
|
||||||
blk.v.bool = Sì/no
|
blk.v.bool = Sì/no
|
||||||
blk.v.eventkind = L'unità innesco è
|
blk.v.eventkind = L'unità innesco è
|
||||||
|
blk.v.weatheris = Il meteo in superficie è
|
||||||
blk.w_at = presso
|
blk.w_at = presso
|
||||||
blk.w_by = di
|
blk.w_by = di
|
||||||
blk.w_in = in
|
blk.w_in = in
|
||||||
@@ -1328,14 +1458,313 @@ demo.m2.title = Rimescolata
|
|||||||
demo.m2.story = Questa mappa viene generata di nuovo a ogni partita della campagna. Sconfiggi tutti gli eroi.
|
demo.m2.story = Questa mappa viene generata di nuovo a ogni partita della campagna. Sconfiggi tutti gli eroi.
|
||||||
demo.m2.start = Una mappa casuale. Nemmeno io l'avevo mai vista.
|
demo.m2.start = Una mappa casuale. Nemmeno io l'avevo mai vista.
|
||||||
demo.m2.wave = Sono passati due minuti: arrivano gli eroi.
|
demo.m2.wave = Sono passati due minuti: arrivano gli eroi.
|
||||||
net.coop_editor = Editor campaign
|
net.coop_editor = Campagna dell'editor
|
||||||
net.coop_builtin = Built-in campaign
|
net.coop_builtin = Campagna integrata
|
||||||
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2})
|
net.coop_league = Lega contro la Guardia della Luce
|
||||||
net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters)
|
net.coop_solo = Alleato IA
|
||||||
|
net.coop_solo_started = Cooperativa senza server: il tuo alleato IA costruisce il suo dungeon, segue la tua bandiera d'attacco e ti aiuta con l'oro.
|
||||||
|
net.coop_resume = Continua M{0}
|
||||||
|
net.coop_editor_mission = Campagna dell'editor {0}: missione {1} ({2})
|
||||||
|
net.coop_editor_none = Nessuna missione della campagna dell'editor è adatta alla cooperativa (mappa almeno 72x72, un obiettivo oltre ai maestri rivali)
|
||||||
blk.until = Ripeti finché
|
blk.until = Ripeti finché
|
||||||
blk.dountil = Ripeti, poi controlla: finché
|
blk.dountil = Ripeti, poi controlla: finché
|
||||||
blk.wait = Attendi
|
blk.wait = Attendi
|
||||||
blk.w_seconds = secondi
|
blk.w_seconds = secondi
|
||||||
|
blk.w_for = per
|
||||||
blk.help.until = Esegue i blocchi interni più volte finché la condizione è vera; viene controllata prima di ogni giro, quindi se è già vera il contenuto non viene eseguito.
|
blk.help.until = Esegue i blocchi interni più volte finché la condizione è vera; viene controllata prima di ogni giro, quindi se è già vera il contenuto non viene eseguito.
|
||||||
blk.help.dountil = Esegue i blocchi interni, poi controlla la condizione e ripete finché è vera; il contenuto viene eseguito almeno una volta.
|
blk.help.dountil = Esegue i blocchi interni, poi controlla la condizione e ripete finché è vera; il contenuto viene eseguito almeno una volta.
|
||||||
blk.help.wait = Mette in pausa questo script per alcuni secondi, poi prosegue con il blocco successivo (anche nei cicli).
|
blk.help.wait = Mette in pausa questo script per alcuni secondi, poi prosegue con il blocco successivo (anche nei cicli).
|
||||||
|
msg.master_out = {0} ha perso il nucleo delle anime: le sue creature combattono senza cuore.
|
||||||
|
msg.master_out_you = Il tuo nucleo delle anime è andato in frantumi. Sei fuori: guarda il resto della partita.
|
||||||
|
msg.match_won = La tua squadra ha vinto la partita!
|
||||||
|
msg.match_lost = La partita è persa: un'altra squadra ha vinto.
|
||||||
|
msg.gold_received = {1} ti manda {0} oro.
|
||||||
|
msg.gold_sent = {0} oro inviato a {1}.
|
||||||
|
msg.gold_none = Non hai oro da inviare.
|
||||||
|
msg.gold_full = Il tesoro di {0} è pieno.
|
||||||
|
gold.send_to = Oro a {0}
|
||||||
|
gold.all = Tutto
|
||||||
|
net.spectating = Il tuo cuore è caduto: stai guardando la partita (chat con Invio).
|
||||||
|
net.team = Squadra {0}
|
||||||
|
net.versus = Tutti contro tutti
|
||||||
|
net.coop_wait_host = In attesa che l'host avvii la prossima missione...
|
||||||
|
net.coop_run_over = Torna alla lobby
|
||||||
|
|
||||||
|
# ---- co-op campaign "League against the Lightwatch"
|
||||||
|
coop.name = Lega contro la Guardia della Luce
|
||||||
|
coop.raid = Le creature di Mossgrim strisciano fuori dal buio!
|
||||||
|
coop.m01.title = Due cuori, un Ditastorte
|
||||||
|
coop.m01.story = Maestri, al plurale. Non ci credevo nemmeno io. L'Ordine della Guardia della Luce ha stretto un patto con Mossgrim, quel voltagabbana muffoso, per affumicare ogni dungeon sotto le colline.\n\nDa soli, finireste affumicati. Insieme, forse il fumo sarete voi. Scavate fianco a fianco e mostrate alla pattuglia qui vicino cosa significa una cantina condivisa.
|
||||||
|
coop.m01.start = Due maestri, un consigliere. Il mio stipendio, tra l'altro, resta lo stesso.
|
||||||
|
coop.m01.goal = Sconfiggete tutti i cavalieri.
|
||||||
|
coop.m01.t1 = Dividetevi il lavoro: uno scava, l'altro costruisce. Oppure scavate entrambi. Sono flessibile.
|
||||||
|
coop.m01.t2 = La pattuglia si sta incuriosendo. Bene. La curiosità è uno spuntino delizioso.
|
||||||
|
coop.m02.title = Il primo avamposto
|
||||||
|
coop.m02.story = Oltre il fiume la Guardia della Luce ha costruito un avamposto con torri scintillanti. Le cose scintillanti vanno rotte: è la regola più antica degli inferi.\n\nGettate un ponte sull'acqua, abbattete le torri e fatelo insieme, prima che chiamino i loro amici.
|
||||||
|
coop.m02.start = Torri. Lucidate, appuntite, offensive. Rimediamo.
|
||||||
|
coop.m02.goal = Radete al suolo le torri dei cavalieri.
|
||||||
|
coop.m02.t1 = I ponti sono come le amicizie, maestri: si costruiscono, e poi ci si fa marciare sopra un esercito.
|
||||||
|
coop.m02.t2 = Una torre abbattuta è un inizio. Tutte le torri abbattute sono una festa.
|
||||||
|
coop.m03.title = Tracce nel muschio
|
||||||
|
coop.m03.story = Qualcosa di verde e umido si aggira nei tunnel. Mossgrim ha prestato le sue creature ai cavalieri, e arrivano dalla Porta degli Eroi a ondate.\n\nResistete. Restate uniti. Tappatevi il naso.
|
||||||
|
coop.m03.start = Gli orchi di Mossgrim puzzano di tappeto bagnato. Ve ne accorgerete quando arrivano.
|
||||||
|
coop.m03.goal = Sopravvivete a tre ondate.
|
||||||
|
coop.m03.t1 = Ecco i primi orchi. Mossgrim non è mai riuscito a tenere a casa i suoi animaletti.
|
||||||
|
coop.m03.t2 = Tenete i vostri cuori lontani, maestri: un solo orco non deve mai raggiungerli entrambi.
|
||||||
|
coop.m04.title = Le fonti rubate
|
||||||
|
coop.m04.story = Due fonti di mana gorgogliano su isole nel lago, e i cavalieri le hanno dichiarate la loro acqua santa. Acqua santa, ma per favore.\n\nPrendete entrambe le fonti. E se il caveau dei cavalieri vi capita in mano prima, Ditastorte conosce una scorciatoia.
|
||||||
|
coop.m04.start = Il mana ha un sapore migliore quando era di qualcun altro.
|
||||||
|
coop.m04.goal = Conquistate due fonti di mana.
|
||||||
|
coop.m04.branch = Abbastanza ricchi per la scorciatoia
|
||||||
|
coop.m04.t1 = Le isole vogliono ponti. O ali. I pipistrelli sono ali con i denti.
|
||||||
|
coop.m04.t2 = Quel caveau sembra pesante. Le cose pesanti vanno portate a casa.
|
||||||
|
coop.m05.title = Catene nella roccia
|
||||||
|
coop.m05.story = La Guardia della Luce tiene in catene, in grotte sigillate, creature che si sono rifiutate di inchinarsi. Un orco e un mago, entrambi furiosi, entrambi disponibili.\n\nScavate fino a loro, conquistate le loro grotte e le catene cadranno.
|
||||||
|
coop.m05.start = Prigionieri! Presto nostri. Liberi, intendo. Liberi di lavorare per voi.
|
||||||
|
coop.m05.goal = Liberate due creature incatenate.
|
||||||
|
coop.m05.t1 = Ascoltate. Sentite l'orco imprecare? È gratitudine anticipata.
|
||||||
|
coop.m05.t2 = Di nuovo ragni dal muschio. L'asilo di Mossgrim è affollato.
|
||||||
|
coop.m06.title = Il ponte della Guardia
|
||||||
|
coop.m06.story = Due fiumi, una fortezza e i migliori arcieri della Guardia della Luce sulle mura. Lo chiamano il Ponte della Guardia. Noi lo chiameremo il ponte che fu.\n\nSpezzate le loro torri. Lasciate il ponte: potrebbe servirci.
|
||||||
|
coop.m06.start = Gli arcieri sulle mura sono come i piccioni sui tetti: rumorosi e facili da togliere.
|
||||||
|
coop.m06.goal = Radete al suolo le torri dei cavalieri.
|
||||||
|
coop.m06.t1 = Due fiumi significano due ponti. Oppure un esercito molto bagnato.
|
||||||
|
coop.m06.t2 = I cavalieri stanno lucidando le armature. Diamogli un motivo.
|
||||||
|
coop.m07.title = La fucina di lava
|
||||||
|
coop.m07.story = Tra due fiumi di lava i cavalieri forgiano le loro lame benedette. Il loro caveau trabocca d'oro pagato da ogni dungeon che hanno svuotato.\n\nCe lo riprendiamo. Tutto. E anche qualcosa in più.
|
||||||
|
coop.m07.start = Piedi caldi, cuori freddi, tasche piene: la mia serata ideale.
|
||||||
|
coop.m07.goal = Saccheggiate l'oro dei cavalieri.
|
||||||
|
coop.m07.t1 = Non state troppo vicini alla lava. Le vostre creature, intendo. Voi siete a posto.
|
||||||
|
coop.m07.t2 = Altri cavalieri da est. Adorano proprio il loro oro.
|
||||||
|
coop.m08.title = L'ospedale da campo
|
||||||
|
coop.m08.story = La Guardia della Luce ha allestito un ospedale da campo nelle grotte, e i suoi sacerdoti rattoppano ogni cavaliere che abbattete.\n\nInterrompete la cura. Per sempre.
|
||||||
|
coop.m08.start = Sacerdoti. Loro guariscono. Noi facciamo il contrario. È un rapporto equilibrato.
|
||||||
|
coop.m08.goal = Sconfiggete tutti i cavalieri.
|
||||||
|
coop.m08.t1 = Ogni minuto arriva un nuovo sacerdote. Uccidete più in fretta, maestri.
|
||||||
|
coop.m08.t2 = Un dungeon senza spese ospedaliere. Questa sì che è efficienza.
|
||||||
|
coop.m09.title = L'ambasciatore di Mossgrim
|
||||||
|
coop.m09.story = Mossgrim ha mandato un ambasciatore ai cavalieri: un paladino dall'armatura macchiata di verde che porta le promesse del suo padrone.\n\nLe promesse non dovrebbero mai arrivare. Trovate l'ambasciatore e fatelo tacere.
|
||||||
|
coop.m09.start = Un ambasciatore. Un messaggero con un cappello elegante. I cappelli cadono.
|
||||||
|
coop.m09.goal = Sconfiggete l'ambasciatore di Mossgrim.
|
||||||
|
coop.m09.boss = L'ambasciatore è caduto. Il suo messaggio: mai consegnato.
|
||||||
|
coop.m09.t1 = L'ambasciatore aspetta a est, circondato da orchi che hanno perso il senso dell'umorismo.
|
||||||
|
coop.m09.t2 = Da qualche parte Mossgrim sta spedendo un'altra lettera. Facciamola breve.
|
||||||
|
coop.m10.title = Le torri gemelle
|
||||||
|
coop.m10.story = Due fortezze sorvegliano la via verso nord, ognuna guarda le spalle all'altra. Colpitene una e l'altra risponde.\n\nColpitele entrambe. Insieme. È a questo che servono due maestri.
|
||||||
|
coop.m10.start = Due fortezze, due maestri. Giusto è giusto.
|
||||||
|
coop.m10.goal = Radete al suolo le torri dei cavalieri.
|
||||||
|
coop.m10.t1 = Dividetevi, maestri: una fortezza a testa. Io farò il tifo per entrambi.
|
||||||
|
coop.m10.t2 = Se cadono tutte e due insieme, i cavalieri non sapranno nemmeno a chi dare la colpa.
|
||||||
|
coop.m11.title = L'assedio
|
||||||
|
coop.m11.story = La Guardia della Luce ha trovato i vostri cuori e porta tutto: cinque ondate di cavalieri e i gargoyle di Mossgrim dalle scogliere.\n\nNiente astuzie stavolta. Solo mura, trappole, torri - e voi due, l'uno accanto all'altro.
|
||||||
|
coop.m11.start = Arrivano. Tutti. Be', quasi tutti.
|
||||||
|
coop.m11.goal = Sopravvivete a cinque ondate.
|
||||||
|
coop.m11.t1 = Porte e trappole, maestri. Un assedio è solo una visita di ospiti che si rifiutano di suonare il campanello.
|
||||||
|
coop.m11.t2 = Gargoyle da sud. Pietra con le ali: nessuno l'aveva chiesto.
|
||||||
|
coop.m12.title = La porta
|
||||||
|
coop.m12.story = La Porta degli Eroi di questa caverna è la via dei cavalieri verso i dungeon profondi. Chiudetela e i loro rinforzi dovranno fare il giro lungo.\n\nSpezzate la porta e la Guardia della Luce lo sentirà fino al Gran Maresciallo.
|
||||||
|
coop.m12.start = Una porta. Le porte sono solo muri con ambizioni.
|
||||||
|
coop.m12.goal = Distruggete la Porta degli Eroi.
|
||||||
|
coop.m12.t1 = Ne escono ondate su ondate. Fastidioso, come un rubinetto che gocciola.
|
||||||
|
coop.m12.t2 = Spingete, maestri! La porta si sta incrinando. E anche la mia pazienza.
|
||||||
|
coop.m13.title = Tunnel muschiosi
|
||||||
|
coop.m13.story = Ora siamo nel cortile di Mossgrim. I suoi tunnel sono molli e umidi, e due dei suoi tesori giacciono in grotte sigillate.\n\nScavate, prendete, andatevene. Mossgrim se ne accorgerà. È proprio questo il punto.
|
||||||
|
coop.m13.start = Puzza di funghi e di tradimento. Mossgrim dev'essere vicino.
|
||||||
|
coop.m13.goal = Saccheggiate l'oro.
|
||||||
|
coop.m13.t1 = Ragni, di nuovo. Mossgrim li alleva come altri coltivano pomodori.
|
||||||
|
coop.m13.t2 = I suoi tesori sono più grandi di quanto pensassi. Le nostre tasche, ancora di più.
|
||||||
|
coop.m14.title = Veleno nell'acqua
|
||||||
|
coop.m14.story = Mossgrim ha trasformato i suoi morti in redivivi e li manda da due lati. I cavalieri la chiamano opera santa. Tipico.\n\nSconfiggete ogni cavaliere e ogni cadavere ambulante. Poi lavatevi le mani.
|
||||||
|
coop.m14.start = Redivivi. Il nostro trucco, rubato. Sono offeso.
|
||||||
|
coop.m14.goal = Sconfiggete tutti i nemici.
|
||||||
|
coop.m14.t1 = Arrivano da nord e da sud. Schiena contro schiena, maestri.
|
||||||
|
coop.m14.t2 = Un morto che cammina è un morto che non ha ancora finito il suo lavoro.
|
||||||
|
coop.m15.title = Lo Spezzacatene
|
||||||
|
coop.m15.story = Nel profondo della roccia i cavalieri tengono lo Spezzacatene, una creatura che temono persino loro. Liberatelo e combatterà per chi ha aperto la sua cella.\n\nOppure raccogliete abbastanza oro per il giro lungo: Ditastorte conosce un contrabbandiere.
|
||||||
|
coop.m15.start = Lo Spezzacatene. Grosso, arrabbiato, incompreso. Un po' come me.
|
||||||
|
coop.m15.goal = Liberate lo Spezzacatene e radete al suolo le torri.
|
||||||
|
coop.m15.branch = Comprata la mappa del contrabbandiere
|
||||||
|
coop.m15.t1 = La cella è a nord-ovest. Attenti al suo caratterino.
|
||||||
|
coop.m15.t2 = Il contrabbandiere aspetta. I contrabbandieri odiano aspettare. E adorano l'oro.
|
||||||
|
coop.m16.title = La fonte ardente
|
||||||
|
coop.m16.story = In mezzo a un lago di lava c'è un'isola con una fonte di mana che non si esaurisce mai. La sorvegliano i maghi di Mossgrim.\n\nPrendete la fonte, sconfiggete le guardie. Poi sedetevi sull'isola e godetevi il calduccio.
|
||||||
|
coop.m16.start = Una fonte nella lava. Chi ci ha pensato ha stile. Peccato che sia Mossgrim.
|
||||||
|
coop.m16.goal = Conquistate la fonte e sconfiggete tutti i nemici.
|
||||||
|
coop.m16.t1 = Maghi da est. I loro incantesimi sono verdi, e anche i loro denti.
|
||||||
|
coop.m16.t2 = Appena la fonte sarà nostra, mana per tutti. Condividete con garbo.
|
||||||
|
coop.m17.title = Il giardino di Mossgrim
|
||||||
|
coop.m17.story = Ci siamo: il giardino di Mossgrim, una caverna di funghi e di creature a lui fedeli. Orchi a est, un drago di brace a nord.\n\nCalpestate il giardino. Ogni singolo fungo.
|
||||||
|
coop.m17.start = Guardate quanti funghi. Peccato per il proprietario.
|
||||||
|
coop.m17.goal = Sconfiggete tutti i nemici.
|
||||||
|
coop.m17.t1 = Un drago di brace! Mossgrim deve aver risparmiato per anni.
|
||||||
|
coop.m17.t2 = Il giardino appassisce. Il giardinaggio non è il vostro forte, maestri. È un complimento.
|
||||||
|
coop.m18.title = L'ultima fortezza
|
||||||
|
coop.m18.story = Tre fortezze sorvegliano la scala che scende alla sala del Gran Maresciallo. Sono l'ultima cosa tra voi e la fine della crociata della Guardia della Luce.\n\nRadetele al suolo tutte. Poi la scala sarà vostra.
|
||||||
|
coop.m18.start = Tre fortezze. Hanno davvero tenuto il meglio per la fine.
|
||||||
|
coop.m18.goal = Radete al suolo le torri dei cavalieri.
|
||||||
|
coop.m18.t1 = Una fortezza per ogni maestro, e la terza insieme. Io tengo il conto.
|
||||||
|
coop.m18.t2 = Vedo già la scala. Polverosa. Nessuno scende quaggiù di sua volontà.
|
||||||
|
coop.m19.title = Il Gran Maresciallo
|
||||||
|
coop.m19.story = Il Gran Maresciallo della Guardia della Luce in persona, in un'armatura lucidata per cento anni. Le sue guardie arrivano da nord e da sud.\n\nAbbattetelo e all'Ordine non resterà nessuno che dia gli ordini.
|
||||||
|
coop.m19.start = Il Gran Maresciallo. L'ho sognato tante volte. Soprattutto la parte in cui cade.
|
||||||
|
coop.m19.goal = Sconfiggete il Gran Maresciallo.
|
||||||
|
coop.m19.boss = Il Gran Maresciallo è caduto! La sua armatura sferraglia come un cassetto delle posate rovesciato.
|
||||||
|
coop.m19.t1 = Aspetta a est. Lasciatelo aspettare. Prima eliminate le guardie.
|
||||||
|
coop.m19.t2 = Di nuovo cavalieri e arciere. L'Ordine sta finendo le idee.
|
||||||
|
coop.m20.title = La caduta di Mossgrim
|
||||||
|
coop.m20.story = Caduto l'Alto Maresciallo, Mossgrim è solo: su nel bosco, al sole, figurarsi. Ha preso la forma di un drago di brace e radunato ogni creatura che ancora lo ascolta.\n\nUscite dalla roccia e finite il traditore. Finite il suo esercito. E poi, maestri: tè.
|
||||||
|
coop.m20.start = Mossgrim, vecchio fungo. La lega ti manda i suoi saluti.
|
||||||
|
coop.m20.goal = Sconfiggete Mossgrim e tutte le sue creature.
|
||||||
|
coop.m20.boss = Mossgrim non c'è più. Il muschio ricrescerà. Lui no.
|
||||||
|
coop.m20.t1 = Quel drago a est è Mossgrim. Sì, si è lasciato andare.
|
||||||
|
coop.m20.t2 = Ogni creatura che sconfiggete è una in meno che racconterà male questa storia.
|
||||||
|
|
||||||
|
# achievements and statistics
|
||||||
|
menu.achievements = Obiettivi
|
||||||
|
ach.count = {0} di {1} obiettivi sbloccati · {2} minuti giocati
|
||||||
|
ach.hidden = Un segreto. Ditastorte non parla.
|
||||||
|
msg.achievement = Obiettivo sbloccato: {0}!
|
||||||
|
end.stats2 = Eroi {0} · Creature perse {1} · Caselle scavate {2} · Oro {3} · Legno {4}
|
||||||
|
ach.heroes.name = Flagello degli eroi {0}
|
||||||
|
ach.heroes.desc = Sconfiggi {0} eroi.
|
||||||
|
ach.bosses.name = Spezzacampioni {0}
|
||||||
|
ach.bosses.desc = Abbatti {0} campioni dell'Ordine.
|
||||||
|
ach.dug.name = Scavatore profondo {0}
|
||||||
|
ach.dug.desc = Scava {0} caselle.
|
||||||
|
ach.gold.name = Accumulatore {0}
|
||||||
|
ach.gold.desc = Guadagna {0} oro.
|
||||||
|
ach.wood.name = Signore del legno {0}
|
||||||
|
ach.wood.desc = Abbatti {0} legno.
|
||||||
|
ach.walls.name = Muratore {0}
|
||||||
|
ach.walls.desc = Alza {0} muri.
|
||||||
|
ach.rooms.name = Architetto delle ombre {0}
|
||||||
|
ach.rooms.desc = Costruisci {0} caselle di stanza.
|
||||||
|
ach.spells.name = Incantatore oscuro {0}
|
||||||
|
ach.spells.desc = Lancia {0} magie.
|
||||||
|
ach.slaps.name = Mano ferma {0}
|
||||||
|
ach.slaps.desc = Dai {0} schiaffi alle tue creature. Lo adorano. Probabilmente.
|
||||||
|
ach.traps.name = Fabbro di trappole {0}
|
||||||
|
ach.traps.desc = Ordina {0} trappole e porte alla fucina.
|
||||||
|
ach.towers.name = Signore delle torri {0}
|
||||||
|
ach.towers.desc = Costruisci {0} torri.
|
||||||
|
ach.drowned.name = Lezioni di nuoto {0}
|
||||||
|
ach.drowned.desc = Fai cadere {0} eroi in acqua o nella lava.
|
||||||
|
ach.burnt.name = Bruciaponti {0}
|
||||||
|
ach.burnt.desc = Brucia {0} assi di ponte.
|
||||||
|
ach.siege.name = Maestro d'assedio {0}
|
||||||
|
ach.siege.desc = Costruisci {0} macchine d'assedio.
|
||||||
|
ach.captured.name = Carceriere {0}
|
||||||
|
ach.captured.desc = Cattura {0} eroi.
|
||||||
|
ach.drawbridge.name = Su che si va! {0}
|
||||||
|
ach.drawbridge.desc = Alza i ponti levatoi {0} volte.
|
||||||
|
ach.wins.name = Conquistatore {0}
|
||||||
|
ach.wins.desc = Vinci {0} partite libere.
|
||||||
|
ach.missions.name = Cercaframmenti {0}
|
||||||
|
ach.missions.desc = Vinci {0} missioni della campagna.
|
||||||
|
ach.coop.name = Membro della lega {0}
|
||||||
|
ach.coop.desc = Vinci {0} missioni co-op.
|
||||||
|
ach.minutes.name = Nottambulo {0}
|
||||||
|
ach.minutes.desc = Gioca per {0} minuti.
|
||||||
|
ach.flawless.name = Neanche un graffio
|
||||||
|
ach.flawless.desc = Vinci senza perdere nemmeno una creatura.
|
||||||
|
ach.speedrun.name = Prima che il tè si raffreddi
|
||||||
|
ach.speedrun.desc = Vinci entro quindici minuti.
|
||||||
|
ach.campaign.name = L'Unico Maestro
|
||||||
|
ach.campaign.desc = Vinci l'ultima missione della campagna.
|
||||||
|
ach.surface.name = Scottatura
|
||||||
|
ach.surface.desc = Vinci una partita che arriva fino in superficie.
|
||||||
|
ach.defeat.name = Tè e compassione
|
||||||
|
ach.defeat.desc = Perdi una partita. Ditastorte mette su il bollitore.
|
||||||
|
ach.legend.name = Leggenda vivente
|
||||||
|
ach.legend.desc = Una creatura raggiunge il livello dieci.
|
||||||
|
ach.wyrm.name = Hic sunt dracones
|
||||||
|
ach.wyrm.desc = Un drago di brace si unisce al tuo dungeon.
|
||||||
|
ach.fullhand.name = Mani piene
|
||||||
|
ach.fullhand.desc = Tieni in mano otto unità contemporaneamente.
|
||||||
|
|
||||||
|
# tutorial
|
||||||
|
menu.tutorial = Tutorial
|
||||||
|
tut.name = Primi passi
|
||||||
|
tut.title = Ditastorte ti insegna il mestiere
|
||||||
|
tut.story = Dunque sei tu il nuovo padrone. Piccolo, ma il nucleo batte ancora, ed è questo che conta.\n\nTi mostrerò le basi: scavare, stanze, creature e cosa fare con i cavalieri. Fai quello che dico e nessuno si farà male. Tranne i cavalieri.
|
||||||
|
tut.start = Ditastorte: Prima lezione: scavare. Prendi il piccone (tasto 1), trascina sulla terra a est del cuore e i tuoi imp la scaveranno.
|
||||||
|
tut.obj1 = Scava il tunnel a est del cuore.
|
||||||
|
tut.dug = Ditastorte: Guarda, c'è una sala dietro! I tuoi imp reclamano il suolo da soli. Ora apri Stanze e costruisci una tana sul tuo pavimento: lì dormono le tue creature.
|
||||||
|
tut.obj2 = Costruisci una tana (Stanze).
|
||||||
|
tut.lair = Ditastorte: Letti, fatto. Anche le creature mangiano: costruisci una fungaia lì accanto.
|
||||||
|
tut.obj3 = Costruisci una fungaia (Stanze).
|
||||||
|
tut.farm = Ditastorte: Vitto e alloggio: ora il portale ti manda creature. Pazienza, arrivano da sole. Aspettane due.
|
||||||
|
tut.obj4 = Aspetta che due creature arrivino dal portale.
|
||||||
|
tut.creatures = Ditastorte: Eccole! Un po' di incoraggiamento non guasta mai: prendi la mano (tasto 2) e clicca su una creatura tenendo premuto Shift (su touch screen: toccala due volte) per darle uno schiaffo. Per un po' lavorerà più in fretta.
|
||||||
|
tut.obj5 = Dai uno schiaffo a una delle tue creature (mano, Shift + clic).
|
||||||
|
tut.slap = Ditastorte: Ah! Hai sentito? Qualcosa arriva da est. Preparati: il fulmine (Magie) colpisce duro, e le tue creature combattono da sole.
|
||||||
|
tut.obj6 = Sconfiggi i due cavalieri.
|
||||||
|
tut.knights = Ditastorte: Due cavalieri! Manda le tue creature con la bandiera d'attacco (Ordini) o colpiscili con il fulmine.
|
||||||
|
tut.win = Ditastorte: Ben fatto, padrone! Tutto qui. Be', quasi. Il resto lo racconta la campagna. Metto su il bollitore.
|
||||||
|
|
||||||
|
# master talents
|
||||||
|
cat.master = Maestro
|
||||||
|
talent.title = Talenti del maestro
|
||||||
|
talent.points = Punti talento liberi: {0}
|
||||||
|
talent.next = {0}/{1} esperienza al prossimo punto
|
||||||
|
talent.all = Tutti i talenti sono appresi.
|
||||||
|
talent.builder = Costruttore
|
||||||
|
talent.warlord = Condottiero
|
||||||
|
talent.warlock = Stregone
|
||||||
|
talent.merchant = Mercante
|
||||||
|
talent.builder.desc = Gli Imp scavano il {0}% più in fretta, le stanze costano il {1}% in meno.
|
||||||
|
talent.warlord.desc = Le creature colpiscono il {0}% più forte e subiscono il {1}% di danni in meno.
|
||||||
|
talent.warlock.desc = Il mana scorre il {0}% più in fretta, le magie costano il {1}% in meno.
|
||||||
|
talent.merchant.desc = I salari sono il {0}% più bassi, il tesoro consegnato vale il {1}% in più.
|
||||||
|
talent.none = Non ancora appreso.
|
||||||
|
talent.then = Livello successivo: {0}
|
||||||
|
msg.talent_point = Un nuovo punto talento! Spendilo nella finestra del maestro.
|
||||||
|
msg.talent_learned = {0}: livello {1} appreso.
|
||||||
|
|
||||||
|
# weather above ground
|
||||||
|
weather.fair = Sereno
|
||||||
|
weather.rain = Pioggia
|
||||||
|
weather.fog = Nebbia
|
||||||
|
weather.storm = Tempesta
|
||||||
|
msg.weather_clear = In superficie il cielo si rasserena.
|
||||||
|
msg.weather_rain = In superficie inizia a piovere: il fuoco non attecchisce.
|
||||||
|
msg.weather_fog = La nebbia avvolge la terra: all'aperto tutti vedono meno.
|
||||||
|
msg.weather_storm = In superficie si avvicina una tempesta! I fulmini colpiscono unità e alberi.
|
||||||
|
msg.lightning_unit = Un fulmine colpisce {0}!
|
||||||
|
# creature names
|
||||||
|
names.imp = Briciola, Moccolo, Scavino, Fanghetto, Pidocchio, Sudicino, Grattino, Zozzo, Pulcino, Ciottolo, Rospetto, Scricciolo, Mollica, Fuliggine, Tappo, Bricco, Zampetto, Bigolo, Frugolo, Pallino
|
||||||
|
names.kobold = Ringhio, Graffio, Morso, Straccio, Rusco, Zanna, Pizzico, Rognoso, Uncino, Cardo, Spillo, Furbetto, Tarlo, Sgraffa, Ladruncolo, Sdentato, Sassolino, Ruggine, Grinza, Malandrino
|
||||||
|
names.spider = Zampette, Ragnetto, Filuccia, Solletico, Ottopiedi, Setina, Tessitrice, Batuffolo, Streghina, Groviglio, Gomitolo, Aghetto, Trappolina, Zannina, Pois, Merletto, Ricamina, Pungino, Ombretta, Ragnatela
|
||||||
|
names.mage = Bartolomeo, Ignazio, Ermenegildo, Anacleto, Fulgenzio, Eustachio, Leopoldo, Baldassarre, Prospero, Teodoro, Sigismondo, Fiorenzo, Casimiro, Gualtiero, Pancrazio, Aurelio, Ruggero, Cipriano, Serafino, Venceslao
|
||||||
|
names.ogre = Macigno, Testone, Zuccone, Bestione, Mattone, Bruto, Mangione, Ciccione, Ceppo, Tontolone, Tronco, Pancione, Babbeo, Pataccone, Gnocco, Bombo, Ciocco, Sberla, Bamboccione, Somarone
|
||||||
|
names.jailer = Chiavistello, Lucchetto, Ceppi, Cupo, Torvo, Segreta, Sbarra, Bieco, Ferro, Catena, Severo, Paletto, Manetta, Cerbero, Chiavaio, Lugubre, Arcigno, Gattabuia, Spranga, Anello
|
||||||
|
names.priestess = Brace, Favilla, Fiamma, Pira, Scintilla, Cenere, Vermiglia, Falò, Fulgida, Vampa, Morgana, Serafina, Scarlatta, Fiammetta, Ignea, Rubina, Ambra, Fenice, Candela, Lucilla
|
||||||
|
names.chainbreaker = Spezzacatene, Ulfgar, Animaferro, Brannoc, Kragmor, Valdrek, Spino, Gorath, Indomito, Rurik, Torvaldo, Agone, Durnok, Frantumo, Brando, Gualfredo, Korrun, Arvak, Liberatore, Alvardo
|
||||||
|
names.bat = Squittio, Alucce, Squitto, Morsetto, Eco, Pipistrello, Crepuscolo, Zinzino, Cri, Dentino, Svolazzo, Notturno, Falenino, Svolazzino, Picchiata, Pendolino, Vellutino, Ombrino, Strillo, Zigzag
|
||||||
|
names.gargoyle = Granito, Ardesia, Basalto, Selciato, Calcina, Ciottolone, Ghiaia, Selce, Mattone, Maceria, Quarzo, Pomice, Cornicione, Grifone, Musone, Travertino, Frontone, Scalpellino, Scalpello, Macigno
|
||||||
|
names.owl = Saputello, Gufetto, Piuma, Barbagianni, Minerva, Tobia, Civetta, Sapienzio, Piumino, Savio, Meditabondo, Anziano, Assiolo, Noctua, Allocco, Pergamena, Imbrunire, Gufone, Professore, Erudito
|
||||||
|
names.wyrm = Vermitrax, Fauci, Bruciaterra, Calderone, Ignivoro, Fumarolo, Drakkar, Pirro, Ossidiana, Boccafuoco, Vulcanor, Ustione, Malgrath, Tyrax, Inferno, Zolfo, Kaldor, Fyrnak, Ragnor, Tizzone
|
||||||
|
names.cogling = Bulloncino, Ingranaggio, Vitina, Cricchetto, Perno, Pignone, Aggeggio, Arnese, Chiavetta, Molla, Tintinnio, Coso, Rivetto, Ronzio, Manovella, Rondella, Cardine, Marchingegno, Tictac, Molletta
|
||||||
|
names.revenant = Lamento, Pena, Cinereo, Strazio, Vuoto, Lutto, Livido, Dolore, Spettro, Nenia, Tumulo, Penombra, Cripta, Pianto, Veglia, Sudario, Esangue, Elegia, Anima, Desolato
|
||||||
|
names.beast = Arruffo, Criniera, Bruno, Pelone, Sgranocchio, Muschio, Brontolo, Orsetto, Cespuglio, Scrocchio, Guscio, Zampone, Setola, Muffa, Rombo, Rovo, Cricrì, Borbotto, Bestione, Annusino
|
||||||
|
names.human = Rinaldo, Orlando, Corrado, Goffredo, Tristano, Ugo, Galvano, Lancillotto, Astolfo, Tancredi, Bertoldo, Ruggiero, Oliviero, Baldovino, Rambaldo, Guglielmo, Ettore, Gilberto, Rodolfo, Brunetto
|
||||||
|
epithets.mid = Spaccaossa, Pestafango, Mordiratti, Faccialercia, Masticasassi, Picchiateschi, Pugnopuzzo, Nocchedruggine, Sventrapance, Calciastinchi, Barbamuffa, Artigliosozzo, Sparamocci, Mangiaorecchie, Spaccaginocchia, Mangiavermi
|
||||||
|
epithets.high = Regicida, Manofunesta, Zannaterrore, Spaccamondi, Flagelloeroi, Pugnotempesta, Terrornotturno, Mietianime, Cuordiferro, Sfondacastelli, Ammazzadraghi, Passainferi, Signordombra, Coronasangue, Mareafunesta, Spezzastelle
|
||||||
|
name.epithet = {0} {1}
|
||||||
|
msg.creature_fallen = {0} ci ha lasciati. Una lapide commemorativa ne custodisce il nome.
|
||||||
|
tile.memorial = Lapide commemorativa
|
||||||
|
tile.memorial_info = Qui giace {0} ({1}, livello {2}).
|
||||||
|
|
||||||
|
# hall of the fallen
|
||||||
|
hall.title = Sala dei caduti
|
||||||
|
hall.count = Creature cadute ricordate: {0}
|
||||||
|
hall.empty = Nessuno è ancora caduto. Che duri a lungo.
|
||||||
|
hall.kills = Eroi uccisi: {0}
|
||||||
|
ov.fallen = Caduti ({0})
|
||||||
|
ov.living = Vivi
|
||||||
|
cause.slain = morte per mano di: {0}
|
||||||
|
cause.lava = morte nella lava
|
||||||
|
cause.water = annegamento
|
||||||
|
cause.snare = morte in una tagliola
|
||||||
|
cause.lightning = colpo di fulmine
|
||||||
|
cause.fell = caduta in battaglia
|
||||||
@@ -53,17 +53,29 @@ msg.creature_arrived = Через портал пришёл новичок: {0}.
|
|||||||
msg.payday_partial = День выплат! {0} существ получили жалованье ({1} золота), {2} остались ни с чем и злятся!
|
msg.payday_partial = День выплат! {0} существ получили жалованье ({1} золота), {2} остались ни с чем и злятся!
|
||||||
msg.payday = День выплат! {0} существ получили {1} золота.
|
msg.payday = День выплат! {0} существ получили {1} золота.
|
||||||
msg.wave_boss = Волна {0}: Паладин Света ведёт в твои владения {1} героев!
|
msg.wave_boss = Волна {0}: Паладин Света ведёт в твои владения {1} героев!
|
||||||
|
boss.aldric = Сэр Алдрик Непреклонный
|
||||||
|
boss.seraphine = Дама Серафина Рассветная
|
||||||
|
boss.halvard = Брат Халвард Благочестивый
|
||||||
|
boss.rothgar = Капитан Ротгар Железный Бок
|
||||||
|
msg.boss_comes = Волну ведёт чемпион ордена: {0}!
|
||||||
|
msg.boss_slain = {0} пал! Добыча: {1} золота и чертежи башни рунной кузницы.
|
||||||
msg.wave = Волна {0}: {1} героев прорываются через врата!
|
msg.wave = Волна {0}: {1} героев прорываются через врата!
|
||||||
msg.gate_boss = Врата рушатся! Появляется Страж врат и {0} защитников!
|
msg.gate_boss = Врата рушатся! Появляется Страж врат и {0} защитников!
|
||||||
msg.gate_defenders = Врата призывают {0} защитников!
|
msg.gate_defenders = Врата призывают {0} защитников!
|
||||||
msg.level_up = {0}: достигнут уровень {1}.
|
msg.level_up = {0}: достигнут уровень {1}.
|
||||||
msg.hero_slain = Повержен: {0}! Добыча: {1} золота.
|
msg.hero_slain = Повержен: {0}! Добыча: {1} золота.
|
||||||
|
msg.thief_in_treasury = Вор в твоей сокровищнице!
|
||||||
|
msg.thief_escaped = Вор сбежал с {0} золота!
|
||||||
|
msg.snare_caught = Попал в капкан: {0}!
|
||||||
|
msg.converted = Инквизитор обратил одно из твоих существ: {0}!
|
||||||
msg.creature_died = Пал твой боец: {0}.
|
msg.creature_died = Пал твой боец: {0}.
|
||||||
msg.portal_claimed = Портал твой! Он привлекает существ.
|
msg.portal_claimed = Портал твой! Он привлекает существ.
|
||||||
msg.flag_removed = Флаг атаки убран. Существа возвращаются.
|
msg.flag_removed = Флаг атаки убран. Существа возвращаются.
|
||||||
msg.flag_set = Флаг атаки установлен! Готовые к бою существа собираются там.
|
msg.flag_set = Флаг атаки установлен! Готовые к бою существа собираются там.
|
||||||
msg.creature_leaves = Недовольное существо ({0}) покидает твои владения!
|
msg.creature_leaves = Недовольное существо ({0}) покидает твои владения!
|
||||||
msg.no_gold = Не хватает золота!
|
msg.no_gold = Не хватает золота!
|
||||||
|
msg.no_wood = Мало дерева! Рубите гигантские грибы (или деревья) киркой.
|
||||||
|
msg.siege_built = {0} готов — поставьте флаг атаки, чтобы отправить его.
|
||||||
msg.sold = Комнаты проданы: +{0} золота.
|
msg.sold = Комнаты проданы: +{0} золота.
|
||||||
msg.no_mana_bolt = Не хватает маны для молнии.
|
msg.no_mana_bolt = Не хватает маны для молнии.
|
||||||
msg.no_mana_heal = Не хватает маны для лечения.
|
msg.no_mana_heal = Не хватает маны для лечения.
|
||||||
@@ -73,6 +85,7 @@ msg.no_mana_cost = Не хватает маны (нужно {0}).
|
|||||||
hint.portal_no_lair = Портал открыт, но без логова существа не придут.
|
hint.portal_no_lair = Портал открыт, но без логова существа не придут.
|
||||||
hint.no_farm = Существа хотят есть: построй грибной сад.
|
hint.no_farm = Существа хотят есть: построй грибной сад.
|
||||||
hint.lair_full = Все места в логове заняты. Расширь логово.
|
hint.lair_full = Все места в логове заняты. Расширь логово.
|
||||||
|
hint.creature_max = В твоём подземелье 100 существ - больше через портал не придёт.
|
||||||
hint.gate_attack = Твои существа атакуют Врата героев!
|
hint.gate_attack = Твои существа атакуют Врата героев!
|
||||||
hint.treasury_full = Сокровищницы полны! Построй больше клеток сокровищницы.
|
hint.treasury_full = Сокровищницы полны! Построй больше клеток сокровищницы.
|
||||||
hint.flag_unreachable = Существа не могут добраться до флага атаки.
|
hint.flag_unreachable = Существа не могут добраться до флага атаки.
|
||||||
@@ -124,6 +137,7 @@ ui.key = Клавиша: {0}
|
|||||||
|
|
||||||
# ---- HUD
|
# ---- HUD
|
||||||
hud.gold = Золото
|
hud.gold = Золото
|
||||||
|
hud.wood = Дерево
|
||||||
hud.mana = Мана
|
hud.mana = Мана
|
||||||
hud.core = Ядро
|
hud.core = Ядро
|
||||||
hud.gate = Врата
|
hud.gate = Врата
|
||||||
@@ -145,6 +159,7 @@ help.k_right = Правый клик Опустить существо (пл
|
|||||||
help.k_slap = Shift+Левый Рука: шлепок своим, удар героям на своей земле
|
help.k_slap = Shift+Левый Рука: шлепок своим, удар героям на своей земле
|
||||||
help.k_pan = Тянуть правой Двигать карту (также WASD/стрелки, край экрана)
|
help.k_pan = Тянуть правой Двигать карту (также WASD/стрелки, край экрана)
|
||||||
help.k_rot = Тянуть средней Вращать и наклонять камеру (также Q/E, R/F)
|
help.k_rot = Тянуть средней Вращать и наклонять камеру (также Q/E, R/F)
|
||||||
|
help.k_reset = Средняя 2x щелчок Сбросить угол и поворот (место остаётся)
|
||||||
help.k_zoom = Колесо мыши Масштаб
|
help.k_zoom = Колесо мыши Масштаб
|
||||||
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Выбор инструмента
|
help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y,O Выбор инструмента
|
||||||
help.k_misc = P / Пробел Пауза M Звук вкл/выкл +/- Громкость
|
help.k_misc = P / Пробел Пауза M Звук вкл/выкл +/- Громкость
|
||||||
@@ -237,6 +252,7 @@ set.lights = Источники света
|
|||||||
set.ui_scale = Размер интерфейса
|
set.ui_scale = Размер интерфейса
|
||||||
set.game_speed = Скорость игры
|
set.game_speed = Скорость игры
|
||||||
set.hints = Подсказки
|
set.hints = Подсказки
|
||||||
|
set.events = Случайные события
|
||||||
set.keys_hint = Выбери действие и нажми клавишу. Занятые клавиши меняются местами.
|
set.keys_hint = Выбери действие и нажми клавишу. Занятые клавиши меняются местами.
|
||||||
set.press_key = Нажми клавишу для: {0}
|
set.press_key = Нажми клавишу для: {0}
|
||||||
set.esc_cancel = Esc - отмена
|
set.esc_cancel = Esc - отмена
|
||||||
@@ -248,6 +264,8 @@ val.medium = Средне
|
|||||||
val.high = Высоко
|
val.high = Высоко
|
||||||
val.normal = Обычный
|
val.normal = Обычный
|
||||||
val.large = Большой
|
val.large = Большой
|
||||||
|
val.small = Маленький
|
||||||
|
val.auto_plain = Авто
|
||||||
|
|
||||||
# ---- key binding names
|
# ---- key binding names
|
||||||
key.cam_forward = Камера вперёд
|
key.cam_forward = Камера вперёд
|
||||||
@@ -313,12 +331,30 @@ hint.tower_max = Эта башня улучшена до предела.
|
|||||||
hint.tower_where = Башни строятся только на захваченном пустом полу.
|
hint.tower_where = Башни строятся только на захваченном пустом полу.
|
||||||
hint.tower_mana = У магических башен кончилась мана.
|
hint.tower_mana = У магических башен кончилась мана.
|
||||||
job.repair = Чинит башню
|
job.repair = Чинит башню
|
||||||
|
job.mend_bridge = Чинит мост
|
||||||
|
job.raise_wall = Возводит стену
|
||||||
|
job.march = Идёт к мечу
|
||||||
job.attack_tower = Атакует башню
|
job.attack_tower = Атакует башню
|
||||||
|
|
||||||
unit.jailer = Тюремщик
|
unit.jailer = Тюремщик
|
||||||
unit.priestess = Жрица пламени
|
unit.priestess = Жрица пламени
|
||||||
msg.hero_captured = {0} оглушён и взят в плен! Добыча: {1} золота.
|
msg.hero_captured = {0} оглушён и взят в плен! Добыча: {1} золота.
|
||||||
msg.defected = {0} переходит на твою сторону!
|
msg.defected = {0} переходит на твою сторону!
|
||||||
|
msg.beast_tamed = Дикий {0} сдаётся и присоединяется к твоему подземелью!
|
||||||
|
gear.1 = железное снаряжение
|
||||||
|
gear.2 = стальное снаряжение
|
||||||
|
gear.3 = руническое снаряжение
|
||||||
|
msg.gear_given = Кузница вручает {0}: {1}.
|
||||||
|
msg.trader_arrives = Через твой портал пришёл торговец: {0} за {1} золота. Щёлкни по нему рукой, чтобы купить, — он ждёт минуту.
|
||||||
|
msg.trader_sold = Куплено: {0}. Приятно иметь дело, говорит торговец.
|
||||||
|
msg.trader_left = Торговец пожимает плечами и уходит через портал.
|
||||||
|
trader.info = Предлагает {0} за {1} золота (щёлкни рукой)
|
||||||
|
trader.left = Уйдёт через {0} с
|
||||||
|
trader.rune = чертежи башни рунной кузницы
|
||||||
|
trader.wyrm = огненного змея
|
||||||
|
trader.chainbreaker = разрушителя цепей
|
||||||
|
trader.wood = 30 дерева
|
||||||
|
trader.mana = зелье, восполняющее ману
|
||||||
msg.prisoner_died = {0} не пережил пыток.
|
msg.prisoner_died = {0} не пережил пыток.
|
||||||
job.carry = Несёт пленника
|
job.carry = Несёт пленника
|
||||||
job.pray = Молится
|
job.pray = Молится
|
||||||
@@ -329,6 +365,10 @@ info.tortured = его пытают
|
|||||||
info.prison = Пленники: {0}/{1}
|
info.prison = Пленники: {0}/{1}
|
||||||
|
|
||||||
msg.wave_warn_dir = Разведчики докладывают: волна героев {0} будет у врат через 30 секунд! Они хотят прорваться с направления: {1}.
|
msg.wave_warn_dir = Разведчики докладывают: волна героев {0} будет у врат через 30 секунд! Они хотят прорваться с направления: {1}.
|
||||||
|
msg.event_quake = Землетрясение! Туннели на {0} обрушились.
|
||||||
|
msg.event_lava = Лава выходит из берегов на {0}!
|
||||||
|
msg.event_flood = Наводнение! Река выходит из берегов на {0}.
|
||||||
|
msg.event_vein = Бесы докладывают о новой золотой жиле на {0}!
|
||||||
msg.wave_warn_two = Разведчики докладывают: волна героев {0} будет у врат через 30 секунд! Они разделились и прорвутся с направлений: {1} и {2}.
|
msg.wave_warn_two = Разведчики докладывают: волна героев {0} будет у врат через 30 секунд! Они разделились и прорвутся с направлений: {1} и {2}.
|
||||||
dir.n = север
|
dir.n = север
|
||||||
dir.ne = северо-восток
|
dir.ne = северо-восток
|
||||||
@@ -340,7 +380,8 @@ dir.w = запад
|
|||||||
dir.nw = северо-запад
|
dir.nw = северо-запад
|
||||||
|
|
||||||
val.auto = Авто ({0}x)
|
val.auto = Авто ({0}x)
|
||||||
set.render_scale = Разрешение 3D
|
set.render_scale = Пиксельный вид
|
||||||
|
set.pixel_rows_val = {0} строк
|
||||||
set.debug_fps = Показывать FPS
|
set.debug_fps = Показывать FPS
|
||||||
set.debug_frame_time = Время кадра
|
set.debug_frame_time = Время кадра
|
||||||
set.debug_ping = Показывать пинг
|
set.debug_ping = Показывать пинг
|
||||||
@@ -398,8 +439,25 @@ help.start_touch = Коснись, чтобы начать
|
|||||||
tool.bridge.label = Мост
|
tool.bridge.label = Мост
|
||||||
tool.bridge.name = Построить мост
|
tool.bridge.name = Построить мост
|
||||||
tool.bridge.desc = Строит мост через воду или лаву. Тяни от своей земли через реку; мост растёт клетка за клеткой от твоей территории.
|
tool.bridge.desc = Строит мост через воду или лаву. Тяни от своей земли через реку; мост растёт клетка за клеткой от твоей территории.
|
||||||
cost.bridge = {0} золота/клетка над водой, {1} над лавой
|
tool.drawbridge.label = Подъёмный
|
||||||
|
tool.drawbridge.name = Построить подъёмный мост
|
||||||
|
tool.drawbridge.desc = Мост, который можно поднять: протяните через реку, чтобы построить, щёлкните, чтобы переключить: опущен, поднят, автоматически (поднимается при приближении врагов). Дерево над водой горит, камень над лавой — нет.
|
||||||
|
tool.wall.label = Стена
|
||||||
|
tool.wall.name = Возвести стену
|
||||||
|
tool.wall.desc = Планируйте стены на своём пустом полу (повторное протягивание отменяет): бесы возводят их из укреплённой земли. Закрывайте туннели и ставьте двери там, где нужно.
|
||||||
|
tool.ram.label = Таран
|
||||||
|
tool.ram.name = Построить таран
|
||||||
|
tool.ram.desc = Таран выкатывается из кузницы (золото и дерево). Он идёт к флагу атаки и разбивает ворота и башни рыцарей.
|
||||||
|
tool.catapult.label = Катапульта
|
||||||
|
tool.catapult.name = Построить катапульту
|
||||||
|
tool.catapult.desc = Катапульта выкатывается из кузницы (золото и дерево). Она обстреливает башни рыцарей издалека и идёт к флагу атаки к их воротам.
|
||||||
|
cost.bridge = {0} золота + {1} дерева/клетка над водой, {2} золота над лавой
|
||||||
|
cost.drawbridge = {0} золота + {1} дерева/клетка над водой, {2} золота над лавой
|
||||||
|
cost.gold_wood = {0} золота + {1} дерева
|
||||||
hint.bridge_where = Мост должен начинаться у твоей земли: тяни от своей территории через реку.
|
hint.bridge_where = Мост должен начинаться у твоей земли: тяни от своей территории через реку.
|
||||||
|
hint.bridge_fire = Деревянные мосты горят: держите огненные ловушки, огненное дыхание и молнии подальше; каменные мосты над лавой не горят.
|
||||||
|
hint.wall_where = Стены можно возводить только на своём пустом полу (без комнат, ловушек и дверей).
|
||||||
|
hint.siege_where = Осадные машины ставятся на ваш свободный пол — и нужна кузница.
|
||||||
hint.bridge_river = Мосты строятся только над водой или лавой.
|
hint.bridge_river = Мосты строятся только над водой или лавой.
|
||||||
msg.hero_burned = Герой упал в лаву: {0}! +{1} золота.
|
msg.hero_burned = Герой упал в лаву: {0}! +{1} золота.
|
||||||
msg.creature_burned = Твой боец упал в лаву: {0}.
|
msg.creature_burned = Твой боец упал в лаву: {0}.
|
||||||
@@ -409,6 +467,22 @@ tile.lava = Лава
|
|||||||
tile.lava_info = Всё, что туда падает, сгорает. Её пересекают каменные мосты.
|
tile.lava_info = Всё, что туда падает, сгорает. Её пересекают каменные мосты.
|
||||||
tile.bridge = Деревянный мост
|
tile.bridge = Деревянный мост
|
||||||
tile.lavabridge = Каменный мост
|
tile.lavabridge = Каменный мост
|
||||||
|
tile.mushroom = Гигантский гриб
|
||||||
|
tile.mushroom_grown = Вырос: можно рубить.
|
||||||
|
tile.mushroom_growing = Растёт: {0}% - срубить можно только взрослый гриб.
|
||||||
|
tile.grove = Грибная роща
|
||||||
|
tile.grove_info = Этот мох нельзя захватить. Здесь снова вырастают гигантские грибы.
|
||||||
|
tile.tree = Дерево
|
||||||
|
tile.plant_info = Отметьте киркой: бес срубит его за {0} дерева.
|
||||||
|
tile.drawbridge = Подъёмный мост
|
||||||
|
tile.drawbridge_up = Поднят
|
||||||
|
tile.drawbridge_down = Опущен
|
||||||
|
tile.drawbridge_auto = Автоматически (опущен)
|
||||||
|
tile.drawbridge_auto_up = Автоматически (поднят)
|
||||||
|
tile.obsidian = Обсидиан
|
||||||
|
tile.obsidian_info = Застывшая лава — растает через {0} с
|
||||||
|
tile.bridge_burning = Горит!
|
||||||
|
tile.bridge_worn = Повреждён на {0}%
|
||||||
tile.shard = Осколок твоего ядра души
|
tile.shard = Осколок твоего ядра души
|
||||||
tile.shard_info = Захвати этот зал, чтобы вернуть осколок.
|
tile.shard_info = Захвати этот зал, чтобы вернуть осколок.
|
||||||
|
|
||||||
@@ -430,6 +504,11 @@ msg.enemy_tower_destroyed = Рыцарская постройка пала: {0}!
|
|||||||
msg.vault_taken = Рыцарское хранилище взято: сокровища на {0} твои.
|
msg.vault_taken = Рыцарское хранилище взято: сокровища на {0} твои.
|
||||||
msg.shard_taken = Осколок твоего ядра души снова в твоих руках!
|
msg.shard_taken = Осколок твоего ядра души снова в твоих руках!
|
||||||
msg.bridge_torn = Рыцари разрушили один из твоих мостов!
|
msg.bridge_torn = Рыцари разрушили один из твоих мостов!
|
||||||
|
msg.bridge_fire = Один из ваших мостов горит!
|
||||||
|
msg.drawbridge_up = Подъёмный мост поднят.
|
||||||
|
msg.drawbridge_down = Подъёмный мост опущен.
|
||||||
|
msg.drawbridge_auto = Подъёмный мост сам поднимается при приближении врагов.
|
||||||
|
msg.drawbridge_rises = Враги близко: подъёмный мост поднимается!
|
||||||
job.tear_bridge = Ломает мост
|
job.tear_bridge = Ломает мост
|
||||||
job.patrol = В дозоре
|
job.patrol = В дозоре
|
||||||
job.guard = На страже
|
job.guard = На страже
|
||||||
@@ -533,7 +612,7 @@ camp.m29.story = Лавовые острова, хозяин, посреди н
|
|||||||
camp.m20.title = Великий магистр
|
camp.m20.title = Великий магистр
|
||||||
camp.m20.story = Великий магистр Валериан Светлощит ждёт в своей цитадели за великой рекой, с последним осколком и вратами ордена. Сто лет он молился, полировал и готовился. | Разрушь его врата и верни осколок. Тогда твоё ядро станет целым - и все хозяева, что когда-либо его хотели, явятся за ним разом. Всё по порядку.
|
camp.m20.story = Великий магистр Валериан Светлощит ждёт в своей цитадели за великой рекой, с последним осколком и вратами ордена. Сто лет он молился, полировал и готовился. | Разрушь его врата и верни осколок. Тогда твоё ядро станет целым - и все хозяева, что когда-либо его хотели, явятся за ним разом. Всё по порядку.
|
||||||
camp.m30.title = Трон глубин
|
camp.m30.title = Трон глубин
|
||||||
camp.m30.story = Вышло, как я и говорил: твоё ядро целое, и теперь его хотят все. Моссгрим и леди Найтвелл идут вместе, а остатки Ордена в последний раз открывают свои врата. | Уничтожь оба ядра хозяев и врата ордена. После этого у глубин будет ровно один хозяин. Угадай кто. Подсказка: не я, я тут по части чая.
|
camp.m30.story = Вышло, как я говорил: ваше ядро цело, и теперь его хотят все. Моссгрим и леди Найтвелл прокопались на поверхность, а верховный маршал ждёт в своей крепости в лесу — дневной свет, птички, полный набор. Мерзость. | Выберитесь из скалы, уничтожьте оба ядра мастеров и врата ордена в крепости. После этого в мире будет ровно один мастер. Угадайте кто. Подсказка: не я, я чайный.
|
||||||
camp.epilogue = Зелёное ядро рассыпается, синее гаснет, и врата ордена умолкают. Где-то наверху Светлый Дозор пишет очень длинный отчёт, а письма леди Найтвелл больше не приходят. | Кривопал заварил чай. Ты снова целый, хозяин. Подземелье твоё.
|
camp.epilogue = Зелёное ядро рассыпается, синее гаснет, и врата ордена умолкают. Где-то наверху Светлый Дозор пишет очень длинный отчёт, а письма леди Найтвелл больше не приходят. | Кривопал заварил чай. Ты снова целый, хозяин. Подземелье твоё.
|
||||||
lair.arrivals = {0} свободных мест: новые существа приходят со скоростью {1}%
|
lair.arrivals = {0} свободных мест: новые существа приходят со скоростью {1}%
|
||||||
job.help = Помогает
|
job.help = Помогает
|
||||||
@@ -657,6 +736,7 @@ net.error = {0}
|
|||||||
net.rooms = Открытые матчи
|
net.rooms = Открытые матчи
|
||||||
net.no_rooms = Нет открытых матчей.
|
net.no_rooms = Нет открытых матчей.
|
||||||
net.create = Создать матч
|
net.create = Создать матч
|
||||||
|
net.map_size = Карта {0}x{1} · {2} игроков
|
||||||
net.coop_mission = Кооп-кампания: миссия {0} ({1})
|
net.coop_mission = Кооп-кампания: миссия {0} ({1})
|
||||||
net.coop_create = Кооп-кампания
|
net.coop_create = Кооп-кампания
|
||||||
net.join = Войти
|
net.join = Войти
|
||||||
@@ -687,6 +767,9 @@ net.ai_off = Выкл
|
|||||||
|
|
||||||
# rival masters (free game)
|
# rival masters (free game)
|
||||||
newgame.difficulty = Противники
|
newgame.difficulty = Противники
|
||||||
|
newgame.land = Местность
|
||||||
|
newgame.land0 = Под землёй
|
||||||
|
newgame.land1 = Наполовину наверху
|
||||||
diff.0 = Легко
|
diff.0 = Легко
|
||||||
diff.1 = Средне
|
diff.1 = Средне
|
||||||
diff.2 = Сложно
|
diff.2 = Сложно
|
||||||
@@ -699,9 +782,14 @@ msg.rival1_attack = Моссгрим, зелёный хозяин, шлёт на
|
|||||||
msg.rival2_attack = Леди Найтвелл, синяя хозяйка, шлёт на тебя {0} существ!
|
msg.rival2_attack = Леди Найтвелл, синяя хозяйка, шлёт на тебя {0} существ!
|
||||||
msg.rival1_out = Моссгрим повержен - его ядро души разбито!
|
msg.rival1_out = Моссгрим повержен - его ядро души разбито!
|
||||||
msg.rival2_out = Леди Найтвелл повержена - её ядро души разбито!
|
msg.rival2_out = Леди Найтвелл повержена - её ядро души разбито!
|
||||||
|
msg.rival3_attack = Саффракс, жёлтый хозяин, шлёт на тебя {0} существ!
|
||||||
|
msg.rival3_out = Саффракс повержен - его ядро души разбито!
|
||||||
msg.rival_unit_slain = Пал боец соперника: {0}! +{1} золота.
|
msg.rival_unit_slain = Пал боец соперника: {0}! +{1} золота.
|
||||||
msg.wave_on_rival1 = Волна {0}: {1} героев идут на зелёного хозяина.
|
msg.wave_on_rival1 = Волна {0}: {1} героев идут на зелёного хозяина.
|
||||||
msg.wave_on_rival2 = Волна {0}: {1} героев идут на синюю хозяйку.
|
msg.wave_on_rival2 = Волна {0}: {1} героев идут на синюю хозяйку.
|
||||||
|
msg.wave_warn_on_master = Разведчики докладывают: волна {0} будет через 30 секунд и идёт на мастера {1}.
|
||||||
|
msg.wave_on_master = Волна {0}: {1} героев идут на мастера {2}.
|
||||||
|
msg.partner_out = Сердце вашего ИИ-союзника пало – сражайтесь в одиночку!
|
||||||
hud.rival1 = Зелёный
|
hud.rival1 = Зелёный
|
||||||
hud.rival2 = Синяя
|
hud.rival2 = Синяя
|
||||||
msg.creature_freed = Существо рвёт цепи и присоединяется к тебе: {0}!
|
msg.creature_freed = Существо рвёт цепи и присоединяется к тебе: {0}!
|
||||||
@@ -790,6 +878,8 @@ best.b.torture.text = Сюда попадают пленные герои. Со
|
|||||||
best.b.temple.text = Молитвы лечат ядро души и поднимают дух. Жрица пламени приходит на храм.
|
best.b.temple.text = Молитвы лечат ядро души и поднимают дух. Жрица пламени приходит на храм.
|
||||||
best.b.aerie.text = Насесты и гнёзда для летунов. Нетопырям, горгульям, совам и даже огненному змию нужно здесь место.
|
best.b.aerie.text = Насесты и гнёзда для летунов. Нетопырям, горгульям, совам и даже огненному змию нужно здесь место.
|
||||||
best.b.bridge.text = Дерево над водой, камень над лавой. Рыцари ломают твои мосты - захватывай их мосты.
|
best.b.bridge.text = Дерево над водой, камень над лавой. Рыцари ломают твои мосты - захватывай их мосты.
|
||||||
|
best.b.drawbridge.text = Поднимите его — и река снова стена. В автоматическом режиме он поднимается при приближении врагов и опускается для ваших существ.
|
||||||
|
best.b.wall.text = Выкопанный туннель не навсегда: замуруйте его и оставьте героям один путь — через вашу дверь.
|
||||||
best.b.steel.text = Клёпаные железные листы на укреплённой стене. Быстро никто не прокопает, даже гномы.
|
best.b.steel.text = Клёпаные железные листы на укреплённой стене. Быстро никто не прокопает, даже гномы.
|
||||||
best.b.arrow.text = Стрела пробивает ещё до двух врагов за целью. Дёшево и надёжно.
|
best.b.arrow.text = Стрела пробивает ещё до двух врагов за целью. Дёшево и надёжно.
|
||||||
best.b.cannon.text = Урон по площади и отбрасывание - столкни рыцарей в лаву.
|
best.b.cannon.text = Урон по площади и отбрасывание - столкни рыцарей в лаву.
|
||||||
@@ -801,7 +891,7 @@ room.forge = Кузница
|
|||||||
tool.forge.label = Кузница
|
tool.forge.label = Кузница
|
||||||
tool.forge.desc = Здесь существа делают ловушки и двери - за работу, не за золото. Готовые изделия ждут в ящиках, пока бесёнок их не установит. На кузницу от 6 клеток приходят шестерёнки.
|
tool.forge.desc = Здесь существа делают ловушки и двери - за работу, не за золото. Готовые изделия ждут в ящиках, пока бесёнок их не установит. На кузницу от 6 клеток приходят шестерёнки.
|
||||||
unit.cogling = Шестерёнок
|
unit.cogling = Шестерёнок
|
||||||
cat.traps = Ловушки
|
cat.traps = Постройки
|
||||||
cost.work = {0} работы в кузнице
|
cost.work = {0} работы в кузнице
|
||||||
job.forge = Куёт
|
job.forge = Куёт
|
||||||
job.install = Ставит ловушку
|
job.install = Ставит ловушку
|
||||||
@@ -878,6 +968,7 @@ research.now = Исследуется: {0} ({1}%)
|
|||||||
research.next = затем: {0}
|
research.next = затем: {0}
|
||||||
research.done = Здесь больше нечего исследовать.
|
research.done = Здесь больше нечего исследовать.
|
||||||
msg.researched = Исследовано: {0}! Теперь это в меню строительства.
|
msg.researched = Исследовано: {0}! Теперь это в меню строительства.
|
||||||
|
msg.room_upgraded = Исследование: {0} достигает уровня {1} — работает на четверть лучше, и это видно.
|
||||||
msg.tool_research = Ещё не исследовано - над этим работают существа, что учатся в библиотеке.
|
msg.tool_research = Ещё не исследовано - над этим работают существа, что учатся в библиотеке.
|
||||||
|
|
||||||
msg.script = {0}
|
msg.script = {0}
|
||||||
@@ -915,6 +1006,9 @@ tool.plague.desc = Хворь, которая гложет врагов вокр
|
|||||||
tool.quake.label = Толчок
|
tool.quake.label = Толчок
|
||||||
tool.quake.name = Землетрясение
|
tool.quake.name = Землетрясение
|
||||||
tool.quake.desc = Земля дрожит: враги вокруг точки ранены и оглушены на две секунды. Твои существа стоят на ногах.
|
tool.quake.desc = Земля дрожит: враги вокруг точки ранены и оглушены на две секунды. Твои существа стоят на ногах.
|
||||||
|
tool.freeze.label = Мороз
|
||||||
|
tool.freeze.name = Мороз
|
||||||
|
tool.freeze.desc = Ледяной холод: лава вокруг на 40 секунд застывает в проходимый обсидиан, враги замерзают и замедляются.
|
||||||
tile.unexplored = Неизведанная тьма
|
tile.unexplored = Неизведанная тьма
|
||||||
|
|
||||||
# personality
|
# personality
|
||||||
@@ -967,6 +1061,9 @@ tool.arena.desc = Существа дерутся парами: вдвое бо
|
|||||||
tool.altar.label = Алтарь
|
tool.altar.label = Алтарь
|
||||||
tool.altar.desc = Опусти существо на алтарь, чтобы принести его в жертву. Некоторые пары жертв приносят редкое существо, иначе подземелье получает благословение: лучше настроение и мана.
|
tool.altar.desc = Опусти существо на алтарь, чтобы принести его в жертву. Некоторые пары жертв приносят редкое существо, иначе подземелье получает благословение: лучше настроение и мана.
|
||||||
unit.revenant = Восставший
|
unit.revenant = Восставший
|
||||||
|
unit.cavebear = Пещерный медведь
|
||||||
|
unit.beetle = Гигантский жук
|
||||||
|
unit.trader = Торговец
|
||||||
job.guard_post = На страже
|
job.guard_post = На страже
|
||||||
job.spar = Спаррингует
|
job.spar = Спаррингует
|
||||||
msg.revenant_rises = С кладбища встаёт восставший и служит тебе!
|
msg.revenant_rises = С кладбища встаёт восставший и служит тебе!
|
||||||
@@ -975,6 +1072,10 @@ msg.altar_gift = Алтарь принимает пару: появляется
|
|||||||
msg.altar_blessing = Принесён в жертву: {0}. Подземелье благословлено.
|
msg.altar_blessing = Принесён в жертву: {0}. Подземелье благословлено.
|
||||||
best.revenant.lure = встаёт с кладбища
|
best.revenant.lure = встаёт с кладбища
|
||||||
best.revenant.text = Восставший не ест, не спит и не жалуется - идеальный работник, если вытерпишь запах. | Набравшись опыта, пьёт силу тех, кого бьёт. Со жрицей они не ладят, по понятным причинам.
|
best.revenant.text = Восставший не ест, не спит и не жалуется - идеальный работник, если вытерпишь запах. | Набравшись опыта, пьёт силу тех, кого бьёт. Со жрицей они не ладят, по понятным причинам.
|
||||||
|
best.cavebear.lure = победить в логове, имея тюрьму
|
||||||
|
best.cavebear.text = Медведь, который никогда не видел солнца и не скучает. Лохматый, сильный и ворчливый до завтрака. | Победи его в логове, пока в тюрьме есть место, — он сдастся и присоединится к тебе. Любит грибной сад и пауков — на закуску.
|
||||||
|
best.beetle.lure = победить в логове, имея тюрьму
|
||||||
|
best.beetle.text = Жук размером с бочку, бронированный, как рыцарь, и вдвое вежливее. | Победи его в логове, пока в тюрьме есть место, — и он присоединится к тебе. Спит в логове и терпеть не может летучих мышей.
|
||||||
best.b.prison.text = Ряды камер. Герои тихо гниют здесь, пока кто-нибудь не решит, что с ними делать.
|
best.b.prison.text = Ряды камер. Герои тихо гниют здесь, пока кто-нибудь не решит, что с ними делать.
|
||||||
best.b.graveyard.text = Мох, камни и свежая земля. Здесь, внизу, никто не остаётся мёртвым надолго.
|
best.b.graveyard.text = Мох, камни и свежая земля. Здесь, внизу, никто не остаётся мёртвым надолго.
|
||||||
best.b.guardpost.text = Жаровня, знамя и скучающее существо с острым взглядом.
|
best.b.guardpost.text = Жаровня, знамя и скучающее существо с острым взглядом.
|
||||||
@@ -987,23 +1088,39 @@ ability.drain.desc = лечит себя на треть нанесённого
|
|||||||
unit.giant = Великан
|
unit.giant = Великан
|
||||||
unit.fairy = Фея-шпион
|
unit.fairy = Фея-шпион
|
||||||
unit.battlemage = Боевой маг
|
unit.battlemage = Боевой маг
|
||||||
|
unit.thief = Вор
|
||||||
|
unit.monk = Монах
|
||||||
|
unit.ranger = Следопыт
|
||||||
|
unit.inquisitor = Инквизитор
|
||||||
|
unit.ram = Таран
|
||||||
|
unit.catapult = Катапульта
|
||||||
msg.trap_wrecked = Герои сломали ловушку: {0}.
|
msg.trap_wrecked = Герои сломали ловушку: {0}.
|
||||||
best.giant.text = Великан - это то, что выходит, когда рыцарь триста лет ест овощи. Медленный, огромный, а двери для него - лишь пожелание. | Ломает ловушки, просто стоя рядом. Отвратительные манеры. Бей всем, что есть, лучше издалека.
|
best.giant.text = Великан - это то, что выходит, когда рыцарь триста лет ест овощи. Медленный, огромный, а двери для него - лишь пожелание. | Ломает ловушки, просто стоя рядом. Отвратительные манеры. Бей всем, что есть, лучше издалека.
|
||||||
best.fairy.text = Маленькая доносчица Ордена: светящаяся мошка с крылышками, которую шлют вперёд найти твои ловушки и всем разболтать. Стоит ей увидеть ловушку, как герои её ломают. | Ещё и посыпает героев целебной пыльцой. Прихлопни её первой. Дважды, для верности.
|
best.fairy.text = Маленькая доносчица Ордена: светящаяся мошка с крылышками, которую шлют вперёд найти твои ловушки и всем разболтать. Стоит ей увидеть ловушку, как герои её ломают. | Ещё и посыпает героев целебной пыльцой. Прихлопни её первой. Дважды, для верности.
|
||||||
best.battlemage.text = Маг в мантии Ордена, для которого стены - личное оскорбление. Укреплённые стены лопаются от его огненных шаров, и он без конца об этом болтает. | Вблизи хрупок. Больше всего его бесят стальные стены - и хорошо.
|
best.battlemage.text = Маг в мантии Ордена, для которого стены - личное оскорбление. Укреплённые стены лопаются от его огненных шаров, и он без конца об этом болтает. | Вблизи хрупок. Больше всего его бесят стальные стены - и хорошо.
|
||||||
|
best.thief.text = Быстрые пальцы, ещё более быстрые ноги. Он пришёл не драться: прокрадывается в твою сокровищницу, набивает мешок и бежит обратно к вратам. | Убей его, пока он не ушёл, и золото снова твоё - с процентами.
|
||||||
|
best.monk.text = Годы постов и молитв сделали его глухим к твоей магии: молния, мор и мороз стекают с него, как дождь с крыши. | Кулаки тоже бьют больно. Встречай его сталью, когтями и зубами.
|
||||||
|
best.ranger.text = Лесничий Ордена, который стреляет издалека и расставляет железные капканы в твоих коридорах. Существо, наступившее в капкан, ранено и надолго застревает. | Капканы лежат, пока в них кто-нибудь не наступит. Лучше кобольд, чем твой огр.
|
||||||
|
best.inquisitor.text = Дряхлый, но его проповеди опасны: каждые двадцать секунд он уговаривает одно из твоих самых слабых существ - кобольда или паука - перейти к героям. | Сильные существа над ним только смеются. Бей первым.
|
||||||
|
best.ram.text = Бревно с железным наконечником и крышей от стрел. Ваши существа ему безразличны — только двери, стены и башни. | Остановите его до двери или заведите новую дверь.
|
||||||
|
best.catapult.text = Деревянный рычаг, ведро камней и рыцарь с избытком свободного времени. Бьёт дальше ваших башен и целится в них первыми. | Атакуйте вплотную: вблизи она и мухи не обидит.
|
||||||
|
|
||||||
# orders
|
# orders
|
||||||
tool.order_attack.label = Атака
|
tool.order_attack.label = Атака
|
||||||
tool.order_attack.name = В атаку!
|
tool.order_attack.name = В атаку!
|
||||||
tool.order_attack.desc = Кликни врага или вражескую башню: все боевые существа в радиусе 18 клеток идут на неё. Правый клик инструментом приказа отменяет приказы охраны.
|
tool.order_attack.desc = Щёлкни по врагу или вражеской башне: все готовые к бою существа в радиусе 18 клеток атакуют. Щёлкни по свободной земле: метка-меч — готовые существа делятся между всеми мечами и сражаются там. Правый щелчок по метке убирает её.
|
||||||
tool.order_guard.label = Охрана
|
tool.order_guard.label = Охрана
|
||||||
tool.order_guard.name = Охранять комнату
|
tool.order_guard.name = Охранять комнату
|
||||||
tool.order_guard.desc = Кликни комнату: до трёх существ поблизости занимают там позиции на две минуты и атакуют всё, что увидят. Правый клик - отмена.
|
tool.order_guard.desc = Щёлкни по своей земле: метка-щит, которую держат и защищают три существа. До восьми щитов; правый щелчок по щиту убирает его.
|
||||||
hint.order_attack = Кликни врага или вражескую башню.
|
hint.order_attack = Кликни врага или вражескую башню.
|
||||||
hint.order_guard = Кликни одну из своих комнат (или свой пол).
|
hint.order_guard = Кликни одну из своих комнат (или свой пол).
|
||||||
hint.order_nobody = Нет свободных существ для этого приказа.
|
hint.order_nobody = Нет свободных существ для этого приказа.
|
||||||
msg.order_attack = Существ в атаке: {0}!
|
msg.order_attack = Существ в атаке: {0}!
|
||||||
msg.order_guard = Существ на позициях: {0}.
|
msg.order_guard = Существ на позициях: {0}.
|
||||||
|
msg.mark_attack = Меч отмечает место: твои готовые существа идут туда.
|
||||||
|
msg.mark_guard = Щит отмечает место: три существа будут его держать.
|
||||||
|
msg.mark_removed = Метка убрана — её существа снова свободны.
|
||||||
|
hint.marks_full = Восемь меток этого вида — максимум. Убери одну правым щелчком.
|
||||||
msg.orders_cancelled = Приказы охраны отменены.
|
msg.orders_cancelled = Приказы охраны отменены.
|
||||||
|
|
||||||
# creature overview
|
# creature overview
|
||||||
@@ -1013,6 +1130,8 @@ ov.summary = Существ: {0} - жалованье {1} золота за вы
|
|||||||
ov.none = Существ пока нет. Построй логово и грибной сад и захвати портал.
|
ov.none = Существ пока нет. Построй логово и грибной сад и захвати портал.
|
||||||
ov.hint = Ур., настроение, жалованье, дело, здоровье - клик: перейти
|
ov.hint = Ур., настроение, жалованье, дело, здоровье - клик: перейти
|
||||||
set.voice = Голос Кривопала
|
set.voice = Голос Кривопала
|
||||||
|
set.track = Мелодия
|
||||||
|
val.track_auto = Автоматически
|
||||||
|
|
||||||
# ---- campaign editor, block scripts, demo campaign
|
# ---- campaign editor, block scripts, demo campaign
|
||||||
menu.back_editor = Назад в редактор
|
menu.back_editor = Назад в редактор
|
||||||
@@ -1085,6 +1204,7 @@ ed.forts = Крепости
|
|||||||
ed.rivals = Соперники
|
ed.rivals = Соперники
|
||||||
ed.difficulty = Сложность
|
ed.difficulty = Сложность
|
||||||
ed.portals = Порталы
|
ed.portals = Порталы
|
||||||
|
ed.surface = Поверхность (восток)
|
||||||
ed.edit_map = Изменить карту
|
ed.edit_map = Изменить карту
|
||||||
ed.make_random = Сделать случайной
|
ed.make_random = Сделать случайной
|
||||||
ed.paint_map = Создать и нарисовать карту
|
ed.paint_map = Создать и нарисовать карту
|
||||||
@@ -1164,6 +1284,10 @@ ed.terrain.silver = Серебряная жила
|
|||||||
ed.terrain.ruby = Рубиновая жила
|
ed.terrain.ruby = Рубиновая жила
|
||||||
ed.terrain.diamond = Алмазная жила
|
ed.terrain.diamond = Алмазная жила
|
||||||
ed.terrain.steel = Стальная стена
|
ed.terrain.steel = Стальная стена
|
||||||
|
ed.terrain.mushroom = Гигантский гриб
|
||||||
|
ed.terrain.grove = Грибная роща
|
||||||
|
ed.terrain.grass = Трава (поверхность)
|
||||||
|
ed.terrain.tree = Дерево
|
||||||
ed.place.core = Ядро души
|
ed.place.core = Ядро души
|
||||||
ed.place.portal = Портал
|
ed.place.portal = Портал
|
||||||
ed.place.gate = Врата героев
|
ed.place.gate = Врата героев
|
||||||
@@ -1194,6 +1318,7 @@ ed.level = Уровень
|
|||||||
ed.zone_shape = Форма новой зоны
|
ed.zone_shape = Форма новой зоны
|
||||||
ed.zone_new_name = Имя новой зоны
|
ed.zone_new_name = Имя новой зоны
|
||||||
ed.zones = Зоны
|
ed.zones = Зоны
|
||||||
|
ed.zone_erase = Убрать клетки
|
||||||
ed.shape.rect = Прямоугольник
|
ed.shape.rect = Прямоугольник
|
||||||
ed.shape.circle = Круг
|
ed.shape.circle = Круг
|
||||||
ed.shape.tiles = Клетки
|
ed.shape.tiles = Клетки
|
||||||
@@ -1243,6 +1368,8 @@ blk.reveal = Открыть
|
|||||||
blk.camera = Показать
|
blk.camera = Показать
|
||||||
blk.gold = Золото +
|
blk.gold = Золото +
|
||||||
blk.mana = Мана +
|
blk.mana = Мана +
|
||||||
|
blk.weather = Погода на поверхности
|
||||||
|
blk.talentxp = Опыт талантов +
|
||||||
blk.if = Если
|
blk.if = Если
|
||||||
blk.repeat = Повторить
|
blk.repeat = Повторить
|
||||||
blk.trigger = Переключить триггер
|
blk.trigger = Переключить триггер
|
||||||
@@ -1262,6 +1389,8 @@ blk.help.reveal = Снимает туман войны с зоны.
|
|||||||
blk.help.camera = Переносит камеру к зоне.
|
blk.help.camera = Переносит камеру к зоне.
|
||||||
blk.help.gold = Даёт (или забирает) золото.
|
blk.help.gold = Даёт (или забирает) золото.
|
||||||
blk.help.mana = Даёт (или забирает) ману.
|
blk.help.mana = Даёт (или забирает) ману.
|
||||||
|
blk.help.weather = Задаёт погоду на поверхности (ясно, дождь, туман или гроза) на столько секунд; 0 оставляет её, пока другой блок её не изменит.
|
||||||
|
blk.help.talentxp = Даёт игроку опыт для талантов мастера.
|
||||||
blk.help.if = Выполняет вложенные блоки, только если условие верно, иначе - блоки под «иначе».
|
blk.help.if = Выполняет вложенные блоки, только если условие верно, иначе - блоки под «иначе».
|
||||||
blk.help.repeat = Выполняет вложенные блоки несколько раз.
|
blk.help.repeat = Выполняет вложенные блоки несколько раз.
|
||||||
blk.help.trigger = Включает триггер (он может сработать снова) или выключает его.
|
blk.help.trigger = Включает триггер (он может сработать снова) или выключает его.
|
||||||
@@ -1284,6 +1413,7 @@ blk.v.or = или
|
|||||||
blk.v.not = не
|
blk.v.not = не
|
||||||
blk.v.bool = Да/нет
|
blk.v.bool = Да/нет
|
||||||
blk.v.eventkind = Боец триггера:
|
blk.v.eventkind = Боец триггера:
|
||||||
|
blk.v.weatheris = Погода на поверхности:
|
||||||
blk.w_at = где:
|
blk.w_at = где:
|
||||||
blk.w_by = на
|
blk.w_by = на
|
||||||
blk.w_in = в
|
blk.w_in = в
|
||||||
@@ -1328,14 +1458,313 @@ demo.m2.title = Новый бросок
|
|||||||
demo.m2.story = Эта карта создаётся заново при каждом прохождении кампании. Победи всех героев.
|
demo.m2.story = Эта карта создаётся заново при каждом прохождении кампании. Победи всех героев.
|
||||||
demo.m2.start = Случайная карта. Даже я её раньше не видел.
|
demo.m2.start = Случайная карта. Даже я её раньше не видел.
|
||||||
demo.m2.wave = Две минуты прошли: идут герои.
|
demo.m2.wave = Две минуты прошли: идут герои.
|
||||||
net.coop_editor = Editor campaign
|
net.coop_editor = Кампания из редактора
|
||||||
net.coop_builtin = Built-in campaign
|
net.coop_builtin = Встроенная кампания
|
||||||
net.coop_editor_mission = Editor campaign {0}: mission {1} ({2})
|
net.coop_league = Союз против Светлого Дозора
|
||||||
net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters)
|
net.coop_solo = ИИ-союзник
|
||||||
|
net.coop_solo_started = Кооператив без сервера: ИИ-союзник строит своё подземелье, идёт к вашему флагу атаки и помогает золотом.
|
||||||
|
net.coop_resume = Дальше: M{0}
|
||||||
|
net.coop_editor_mission = Кампания из редактора {0}: миссия {1} ({2})
|
||||||
|
net.coop_editor_none = Ни одна миссия кампании из редактора не подходит для кооператива (карта не меньше 72x72, цель помимо мастеров-соперников)
|
||||||
blk.until = Повторять, пока не
|
blk.until = Повторять, пока не
|
||||||
blk.dountil = Повторять, затем проверить: пока не
|
blk.dountil = Повторять, затем проверить: пока не
|
||||||
blk.wait = Ждать
|
blk.wait = Ждать
|
||||||
blk.w_seconds = сек.
|
blk.w_seconds = сек.
|
||||||
|
blk.w_for = на
|
||||||
blk.help.until = Снова и снова выполняет блоки внутри, пока условие не станет верным; проверка перед каждым проходом, поэтому если оно уже верно, блоки не выполняются.
|
blk.help.until = Снова и снова выполняет блоки внутри, пока условие не станет верным; проверка перед каждым проходом, поэтому если оно уже верно, блоки не выполняются.
|
||||||
blk.help.dountil = Выполняет блоки внутри, затем проверяет условие и повторяет, пока оно не станет верным; блоки выполняются хотя бы один раз.
|
blk.help.dountil = Выполняет блоки внутри, затем проверяет условие и повторяет, пока оно не станет верным; блоки выполняются хотя бы один раз.
|
||||||
blk.help.wait = Приостанавливает этот скрипт на несколько секунд, затем выполняется следующий блок (и внутри циклов).
|
blk.help.wait = Приостанавливает этот скрипт на несколько секунд, затем выполняется следующий блок (и внутри циклов).
|
||||||
|
msg.master_out = {0} потерял ядро душ - его существа сражаются дальше без сердца.
|
||||||
|
msg.master_out_you = Твоё ядро душ разбито. Ты выбыл - смотри оставшуюся часть матча.
|
||||||
|
msg.match_won = Твоя команда выиграла матч!
|
||||||
|
msg.match_lost = Матч проигран - победила другая команда.
|
||||||
|
msg.gold_received = {1} присылает тебе {0} золота.
|
||||||
|
msg.gold_sent = {0} золота отправлено: {1}.
|
||||||
|
msg.gold_none = У тебя нет золота для отправки.
|
||||||
|
msg.gold_full = Сокровищница {0} полна.
|
||||||
|
gold.send_to = Золото: {0}
|
||||||
|
gold.all = Всё
|
||||||
|
net.spectating = Твоё сердце пало - ты наблюдаешь за матчем (чат: Enter).
|
||||||
|
net.team = Команда {0}
|
||||||
|
net.versus = Каждый за себя
|
||||||
|
net.coop_wait_host = Ждём, пока хозяин запустит следующую миссию...
|
||||||
|
net.coop_run_over = Назад в лобби
|
||||||
|
|
||||||
|
# ---- co-op campaign "League against the Lightwatch"
|
||||||
|
coop.name = Лига против Светлого Дозора
|
||||||
|
coop.raid = Существа Моссгрима выползают из тьмы!
|
||||||
|
coop.m01.title = Два сердца, один Кривопал
|
||||||
|
coop.m01.story = Повелители, во множественном числе. Я и сам не поверил. Орден Светлого Дозора заключил союз с Моссгримом, этим заплесневелым предателем, чтобы выкурить все подземелья под холмами.\n\nПоодиночке вас бы выкурили. Вместе вы, пожалуй, сами станете дымом. Копайте бок о бок и покажите патрулю по соседству, что такое общий подвал.
|
||||||
|
coop.m01.start = Два повелителя, один советник. Жалованье у меня, кстати, прежнее.
|
||||||
|
coop.m01.goal = Победите всех рыцарей.
|
||||||
|
coop.m01.t1 = Разделите работу: один копает, другой строит. Или копайте оба. Я человек гибкий. Ну, бес.
|
||||||
|
coop.m01.t2 = Патруль становится любопытным. Хорошо. Любопытство - отличная закуска.
|
||||||
|
coop.m02.title = Первый форпост
|
||||||
|
coop.m02.story = За рекой Светлый Дозор построил форпост с блестящими башнями. Блестящее положено ломать - это древнейшее правило подземного мира.\n\nПерекиньте мост через воду, снесите башни и сделайте это вместе, пока они не позвали друзей.
|
||||||
|
coop.m02.start = Башни. Начищенные, острые, оскорбительные. Исправим это.
|
||||||
|
coop.m02.goal = Снесите рыцарские башни.
|
||||||
|
coop.m02.t1 = Мосты как дружба, повелители: сначала их строят, потом по ним маршируют армией.
|
||||||
|
coop.m02.t2 = Одна снесённая башня - это начало. Все снесённые башни - это праздник.
|
||||||
|
coop.m03.title = Следы во мху
|
||||||
|
coop.m03.story = В туннелях затаилось что-то зелёное и мокрое. Моссгрим одолжил рыцарям своих существ, и они идут волнами через Врата героев.\n\nДержитесь. Держитесь вместе. Зажмите носы.
|
||||||
|
coop.m03.start = Огры Моссгрима пахнут мокрым ковром. Вы сразу поймёте, когда они придут.
|
||||||
|
coop.m03.goal = Переживите три волны.
|
||||||
|
coop.m03.t1 = Первые огры уже здесь. Моссгрим никогда не умел держать своих питомцев дома.
|
||||||
|
coop.m03.t2 = Держите сердца подальше друг от друга, повелители: один огр не должен добраться до обоих.
|
||||||
|
coop.m04.title = Украденные источники
|
||||||
|
coop.m04.story = На островах в озере бурлят два источника маны, и рыцари объявили их своей святой водой. Святой водой, подумать только.\n\nЗахватите оба источника. А если рыцарское хранилище раньше попадёт вам в руки, Кривопал знает короткий путь.
|
||||||
|
coop.m04.start = Мана вкуснее, когда она была чужой.
|
||||||
|
coop.m04.goal = Захватите два источника маны.
|
||||||
|
coop.m04.branch = Достаточно богаты для короткого пути
|
||||||
|
coop.m04.t1 = Островам нужны мосты. Или крылья. Нетопыри - это крылья с зубами.
|
||||||
|
coop.m04.t2 = Хранилище выглядит тяжёлым. Тяжёлое надо уносить домой.
|
||||||
|
coop.m05.title = Цепи в скале
|
||||||
|
coop.m05.story = Светлый Дозор держит в цепях, в запечатанных пещерах, существ, которые отказались склониться. Огр и маг, оба в ярости, оба свободны для найма.\n\nПрокопайте к ним, захватите их пещеры - и цепи падут.
|
||||||
|
coop.m05.start = Пленники! Скоро наши. То есть свободные. Свободные работать на вас.
|
||||||
|
coop.m05.goal = Освободите двух скованных существ.
|
||||||
|
coop.m05.t1 = Слышите? Огр ругается. Это благодарность авансом.
|
||||||
|
coop.m05.t2 = Снова пауки из мха. Питомник Моссгрима не простаивает.
|
||||||
|
coop.m06.title = Мост Дозора
|
||||||
|
coop.m06.story = Две реки, одна крепость и лучшие лучники Светлого Дозора на стенах. Они зовут это Мостом Дозора. Мы назовём его мостом, который был.\n\nСломайте их башни. Мост оставьте - он нам ещё пригодится.
|
||||||
|
coop.m06.start = Лучники на стенах как голуби на крышах: шумные и легко убираются.
|
||||||
|
coop.m06.goal = Снесите рыцарские башни.
|
||||||
|
coop.m06.t1 = Две реки - это два моста. Или одна очень мокрая армия.
|
||||||
|
coop.m06.t2 = Рыцари начищают доспехи. Дадим им повод.
|
||||||
|
coop.m07.title = Лавовая кузня
|
||||||
|
coop.m07.story = Между двумя реками лавы рыцари куют свои благословенные клинки. Их хранилище ломится от золота, оплаченного каждым подземельем, которое они опустошили.\n\nМы забираем его назад. Всё. И ещё немного сверху.
|
||||||
|
coop.m07.start = Тёплые ноги, холодные сердца, полные карманы: мой любимый вечер.
|
||||||
|
coop.m07.goal = Награбьте золото рыцарей.
|
||||||
|
coop.m07.t1 = Не подходите слишком близко к лаве. Ваши существа, я имею в виду. Вам-то ничего.
|
||||||
|
coop.m07.t2 = Ещё рыцари с востока. Как же они любят своё золото.
|
||||||
|
coop.m08.title = Полевой лазарет
|
||||||
|
coop.m08.story = Светлый Дозор устроил в пещерах полевой лазарет, и его жрецы латают каждого рыцаря, которого вы сбиваете с ног.\n\nПрекратите лечение. Навсегда.
|
||||||
|
coop.m08.start = Жрецы. Они лечат. Мы делаем обратное. Отношения вполне уравновешенные.
|
||||||
|
coop.m08.goal = Победите всех рыцарей.
|
||||||
|
coop.m08.t1 = Каждую минуту прибывает новый жрец. Убивайте быстрее, повелители.
|
||||||
|
coop.m08.t2 = Подземелье без больничных счетов. Вот это я называю эффективностью.
|
||||||
|
coop.m09.title = Посол Моссгрима
|
||||||
|
coop.m09.story = Моссгрим отправил к рыцарям посла: паладина в доспехах с зелёными пятнами, который несёт обещания своего хозяина.\n\nОбещания не должны доходить. Найдите посла и заставьте его замолчать.
|
||||||
|
coop.m09.start = Посол. Гонец в нарядной шляпе. Шляпы слетают.
|
||||||
|
coop.m09.goal = Победите посла Моссгрима.
|
||||||
|
coop.m09.boss = Посол пал. Его послание: не доставлено.
|
||||||
|
coop.m09.t1 = Посол ждёт на востоке в окружении огров, потерявших чувство юмора.
|
||||||
|
coop.m09.t2 = Где-то Моссгрим отправляет очередное письмо. Сделаем его коротким.
|
||||||
|
coop.m10.title = Башни-близнецы
|
||||||
|
coop.m10.story = Две крепости стерегут путь на север, и каждая прикрывает спину другой. Ударите по одной - ответит другая.\n\nУдарьте по обеим. Одновременно. Для этого и нужны два повелителя.
|
||||||
|
coop.m10.start = Две крепости, два повелителя. Всё по-честному.
|
||||||
|
coop.m10.goal = Снесите рыцарские башни.
|
||||||
|
coop.m10.t1 = Разделитесь, повелители: каждому по крепости. Я буду болеть за обоих.
|
||||||
|
coop.m10.t2 = Если обе падут разом, рыцари даже не поймут, кого винить.
|
||||||
|
coop.m11.title = Осада
|
||||||
|
coop.m11.story = Светлый Дозор нашёл ваши сердца и бросает всё: пять волн рыцарей и горгулий Моссгрима со скал.\n\nНикаких хитростей на этот раз. Только стены, ловушки, башни - и вы друг у друга.
|
||||||
|
coop.m11.start = Они идут. Все. Ну, почти все.
|
||||||
|
coop.m11.goal = Переживите пять волн.
|
||||||
|
coop.m11.t1 = Двери и ловушки, повелители. Осада - это просто гости, которые отказываются звонить в колокольчик.
|
||||||
|
coop.m11.t2 = Горгульи с юга. Камень с крыльями - никто о таком не просил.
|
||||||
|
coop.m12.title = Врата
|
||||||
|
coop.m12.story = Через Врата героев в этой пещере рыцари попадают в глубокие подземелья. Закройте их, и подкреплению придётся идти в обход.\n\nСломайте врата, и Светлый Дозор почувствует это до самого Верховного маршала.
|
||||||
|
coop.m12.start = Врата. Врата - это просто стены с амбициями.
|
||||||
|
coop.m12.goal = Разрушьте Врата героев.
|
||||||
|
coop.m12.t1 = Из них всё лезут и лезут волны. Раздражает, как капающий кран.
|
||||||
|
coop.m12.t2 = Давите, повелители! Врата трещат. Как и моё терпение.
|
||||||
|
coop.m13.title = Замшелые туннели
|
||||||
|
coop.m13.story = Теперь мы на заднем дворе Моссгрима. Его туннели мягкие и сырые, а два его клада лежат в запечатанных пещерах.\n\nКопайте, берите, уходите. Моссгрим заметит. В этом и смысл.
|
||||||
|
coop.m13.start = Пахнет грибами и предательством. Моссгрим где-то рядом.
|
||||||
|
coop.m13.goal = Награбьте золото.
|
||||||
|
coop.m13.t1 = Опять пауки. Моссгрим разводит их, как другие выращивают помидоры.
|
||||||
|
coop.m13.t2 = Его клады больше, чем я думал. А наши карманы ещё больше.
|
||||||
|
coop.m14.title = Яд в воде
|
||||||
|
coop.m14.story = Моссгрим превратил своих мертвецов в восставших и шлёт их с двух сторон. Рыцари называют это святым делом. Как обычно.\n\nПобедите всех рыцарей и всех ходячих мертвецов. Потом вымойте руки.
|
||||||
|
coop.m14.start = Восставшие. Наш трюк, украденный. Я оскорблён.
|
||||||
|
coop.m14.goal = Победите всех врагов.
|
||||||
|
coop.m14.t1 = Они идут с севера и с юга. Спина к спине, повелители.
|
||||||
|
coop.m14.t2 = Ходячий мертвец - это мертвец, который ещё не закончил свою работу.
|
||||||
|
coop.m15.title = Цеполом
|
||||||
|
coop.m15.story = Глубоко в скале рыцари держат Цеполома - существо, которого боятся даже они. Освободите его, и он будет сражаться за того, кто открыл его камеру.\n\nИли наберите достаточно золота для обходного пути - Кривопал знает одного контрабандиста.
|
||||||
|
coop.m15.start = Цеполом. Большой, злой, непонятый. Немного похож на меня.
|
||||||
|
coop.m15.goal = Освободите Цеполома и снесите башни.
|
||||||
|
coop.m15.branch = Карта контрабандиста куплена
|
||||||
|
coop.m15.t1 = Камера на северо-западе. Осторожнее с его нравом.
|
||||||
|
coop.m15.t2 = Контрабандист ждёт. Контрабандисты ненавидят ждать. И обожают золото.
|
||||||
|
coop.m16.title = Пылающий источник
|
||||||
|
coop.m16.story = Посреди лавового озера лежит остров с источником маны, который никогда не иссякает. Его стерегут маги Моссгрима.\n\nЗахватите источник, победите стражу. А потом сядьте на острове и наслаждайтесь теплом.
|
||||||
|
coop.m16.start = Источник в лаве. У того, кто это придумал, есть стиль. Жаль, что это Моссгрим.
|
||||||
|
coop.m16.goal = Захватите источник и победите всех врагов.
|
||||||
|
coop.m16.t1 = Маги с востока. Заклинания у них зелёные, и зубы тоже.
|
||||||
|
coop.m16.t2 = Как только источник станет нашим - мана для всех. Делитесь по-хорошему.
|
||||||
|
coop.m17.title = Сад Моссгрима
|
||||||
|
coop.m17.story = Вот оно: сад Моссгрима, пещера грибов и преданных ему существ. Огры на востоке, огненный змий на севере.\n\nВытопчите сад. Каждый гриб.
|
||||||
|
coop.m17.start = Сколько грибов. Жаль только хозяина.
|
||||||
|
coop.m17.goal = Победите всех врагов.
|
||||||
|
coop.m17.t1 = Огненный змий! Моссгрим, видно, копил на него годами.
|
||||||
|
coop.m17.t2 = Сад вянет. Садоводство - не ваша сильная сторона, повелители. Это комплимент.
|
||||||
|
coop.m18.title = Последняя крепость
|
||||||
|
coop.m18.story = Три крепости стерегут лестницу вниз, в зал Верховного маршала. Это последнее, что стоит между вами и концом крестового похода Светлого Дозора.\n\nСнесите их все. Тогда лестница ваша.
|
||||||
|
coop.m18.start = Три крепости. Самое лучшее они и правда оставили напоследок.
|
||||||
|
coop.m18.goal = Снесите рыцарские башни.
|
||||||
|
coop.m18.t1 = Каждому повелителю по крепости, а третью - вместе. Я буду считать.
|
||||||
|
coop.m18.t2 = Я уже вижу лестницу. Пыльная. Сюда никто не спускается по своей воле.
|
||||||
|
coop.m19.title = Верховный маршал
|
||||||
|
coop.m19.story = Сам Верховный маршал Светлого Дозора в доспехах, которые начищали сто лет. Его стража идёт с севера и с юга.\n\nСвалите его, и в Ордене не останется никого, кто отдаёт приказы.
|
||||||
|
coop.m19.start = Верховный маршал. Я мечтал об этом. Особенно о том моменте, когда он падает.
|
||||||
|
coop.m19.goal = Победите Верховного маршала.
|
||||||
|
coop.m19.boss = Верховный маршал пал! Его доспехи гремят, как опрокинутый ящик со столовыми приборами.
|
||||||
|
coop.m19.t1 = Он ждёт на востоке. Пусть ждёт. Сначала разберитесь со стражей.
|
||||||
|
coop.m19.t2 = Снова рыцари и лучницы. У Ордена кончаются идеи.
|
||||||
|
coop.m20.title = Падение Моссгрима
|
||||||
|
coop.m20.story = Без верховного маршала Моссгрим остался один — наверху, в лесу, на солнце, подумать только. Он принял облик огненного змея и собрал всех существ, что ещё его слушают.\n\nВыберитесь из скалы и покончите с предателем. Покончите с его армией. А потом, мастера, — чай.
|
||||||
|
coop.m20.start = Моссгрим, старый гриб. Лига шлёт тебе привет.
|
||||||
|
coop.m20.goal = Победите Моссгрима и всех его существ.
|
||||||
|
coop.m20.boss = Моссгрима больше нет. Мох отрастёт. Он - нет.
|
||||||
|
coop.m20.t1 = Этот змий на востоке - Моссгрим. Да, он себя запустил.
|
||||||
|
coop.m20.t2 = Каждое побеждённое существо - на одного меньше, кто перескажет эту историю неправильно.
|
||||||
|
|
||||||
|
# achievements and statistics
|
||||||
|
menu.achievements = Достижения
|
||||||
|
ach.count = Открыто достижений: {0} из {1} · Сыграно минут: {2}
|
||||||
|
ach.hidden = Секрет. Кривопал не расскажет.
|
||||||
|
msg.achievement = Достижение открыто: {0}!
|
||||||
|
end.stats2 = Герои {0} · Потеряно существ {1} · Выкопано клеток {2} · Золото {3} · Дерево {4}
|
||||||
|
ach.heroes.name = Гроза героев {0}
|
||||||
|
ach.heroes.desc = Победи героев: {0}.
|
||||||
|
ach.bosses.name = Сокрушитель чемпионов {0}
|
||||||
|
ach.bosses.desc = Повергни чемпионов ордена: {0}.
|
||||||
|
ach.dug.name = Глубокий копатель {0}
|
||||||
|
ach.dug.desc = Выкопай клеток: {0}.
|
||||||
|
ach.gold.name = Скопидом {0}
|
||||||
|
ach.gold.desc = Заработай {0} золота.
|
||||||
|
ach.wood.name = Лесной барон {0}
|
||||||
|
ach.wood.desc = Сруби {0} дерева.
|
||||||
|
ach.walls.name = Каменщик {0}
|
||||||
|
ach.walls.desc = Возведи стен: {0}.
|
||||||
|
ach.rooms.name = Зодчий теней {0}
|
||||||
|
ach.rooms.desc = Построй клеток комнат: {0}.
|
||||||
|
ach.spells.name = Тёмный чародей {0}
|
||||||
|
ach.spells.desc = Сотвори чар: {0}.
|
||||||
|
ach.slaps.name = Твёрдая рука {0}
|
||||||
|
ach.slaps.desc = Отшлёпай своих существ {0} раз. Им нравится. Наверное.
|
||||||
|
ach.traps.name = Мастер ловушек {0}
|
||||||
|
ach.traps.desc = Закажи в кузнице ловушек и дверей: {0}.
|
||||||
|
ach.towers.name = Владыка башен {0}
|
||||||
|
ach.towers.desc = Построй башен: {0}.
|
||||||
|
ach.drowned.name = Уроки плавания {0}
|
||||||
|
ach.drowned.desc = Пусть героев упадёт в воду или лаву: {0}.
|
||||||
|
ach.burnt.name = Поджигатель мостов {0}
|
||||||
|
ach.burnt.desc = Сожги досок мостов: {0}.
|
||||||
|
ach.siege.name = Мастер осады {0}
|
||||||
|
ach.siege.desc = Построй осадных машин: {0}.
|
||||||
|
ach.captured.name = Тюремщик {0}
|
||||||
|
ach.captured.desc = Возьми героев в плен: {0}.
|
||||||
|
ach.drawbridge.name = Поднять мост! {0}
|
||||||
|
ach.drawbridge.desc = Подними подъёмные мосты {0} раз.
|
||||||
|
ach.wins.name = Завоеватель {0}
|
||||||
|
ach.wins.desc = Выиграй свободных игр: {0}.
|
||||||
|
ach.missions.name = Искатель осколков {0}
|
||||||
|
ach.missions.desc = Выиграй миссий кампании: {0}.
|
||||||
|
ach.coop.name = Член лиги {0}
|
||||||
|
ach.coop.desc = Выиграй кооп-миссий: {0}.
|
||||||
|
ach.minutes.name = Полуночник {0}
|
||||||
|
ach.minutes.desc = Сыграй {0} минут.
|
||||||
|
ach.flawless.name = Ни царапины
|
||||||
|
ach.flawless.desc = Победи, не потеряв ни одного существа.
|
||||||
|
ach.speedrun.name = Пока чай не остыл
|
||||||
|
ach.speedrun.desc = Победи за пятнадцать минут.
|
||||||
|
ach.campaign.name = Единый Властелин
|
||||||
|
ach.campaign.desc = Выиграй последнюю миссию кампании.
|
||||||
|
ach.surface.name = Солнечный ожог
|
||||||
|
ach.surface.desc = Выиграй игру, которая доходит до поверхности.
|
||||||
|
ach.defeat.name = Чай и сочувствие
|
||||||
|
ach.defeat.desc = Проиграй игру. Кривопал ставит чайник.
|
||||||
|
ach.legend.name = Живая легенда
|
||||||
|
ach.legend.desc = Существо достигает десятого уровня.
|
||||||
|
ach.wyrm.name = Здесь водятся драконы
|
||||||
|
ach.wyrm.desc = Огненный змий присоединяется к твоему подземелью.
|
||||||
|
ach.fullhand.name = Полные руки
|
||||||
|
ach.fullhand.desc = Держи в руке восемь существ одновременно.
|
||||||
|
|
||||||
|
# tutorial
|
||||||
|
menu.tutorial = Обучение
|
||||||
|
tut.name = Первые шаги
|
||||||
|
tut.title = Кривопал вводит тебя в курс дела
|
||||||
|
tut.story = Так ты, значит, новый хозяин. Маловат, но ядро ещё бьётся, а это главное.\n\nЯ покажу тебе основы: копать, комнаты, существа и что делать с рыцарями. Делай, как я говорю, и никто не пострадает. Кроме рыцарей.
|
||||||
|
tut.start = Кривопал: Урок первый — копать. Возьми кирку (клавиша 1), протяни по земле к востоку от сердца, и твои бесята её выкопают.
|
||||||
|
tut.obj1 = Выкопай проход к востоку от сердца.
|
||||||
|
tut.dug = Кривопал: Гляди-ка, за ним зал! Твои бесята сами захватывают пол. Теперь открой «Комнаты» и построй логово на своём полу — там спят твои существа.
|
||||||
|
tut.obj2 = Построй логово (Комнаты).
|
||||||
|
tut.lair = Кривопал: Кровати есть. Существа ещё и едят: построй рядом грибной сад.
|
||||||
|
tut.obj3 = Построй грибной сад (Комнаты).
|
||||||
|
tut.farm = Кривопал: Стол и кров — теперь портал присылает тебе существ. Терпение, они придут сами. Дождись двоих.
|
||||||
|
tut.obj4 = Дождись, пока через портал придут два существа.
|
||||||
|
tut.creatures = Кривопал: Вот и они! Немного мотивации никогда не повредит: возьми руку (клавиша 2) и щёлкни по существу, удерживая Shift (на сенсорном экране: коснись дважды), чтобы отвесить ему шлепок. Какое-то время оно будет работать быстрее.
|
||||||
|
tut.obj5 = Отвесь шлепок одному из своих существ (рука, Shift + щелчок).
|
||||||
|
tut.slap = Кривопал: Ха! Слышал? С востока что-то идёт. Готовься: молния (Чары) бьёт сильно, а твои существа сражаются сами.
|
||||||
|
tut.obj6 = Победи двух рыцарей.
|
||||||
|
tut.knights = Кривопал: Два рыцаря! Пошли своих существ флагом атаки (Приказы) или порази их молнией.
|
||||||
|
tut.win = Кривопал: Молодец, хозяин! Вот и всё. Ну — почти. Остальное расскажет кампания. Пойду поставлю чайник.
|
||||||
|
|
||||||
|
# master talents
|
||||||
|
cat.master = Мастер
|
||||||
|
talent.title = Таланты мастера
|
||||||
|
talent.points = Свободные очки талантов: {0}
|
||||||
|
talent.next = {0}/{1} опыта до следующего очка
|
||||||
|
talent.all = Все таланты изучены.
|
||||||
|
talent.builder = Строитель
|
||||||
|
talent.warlord = Полководец
|
||||||
|
talent.warlock = Чернокнижник
|
||||||
|
talent.merchant = Торговец
|
||||||
|
talent.builder.desc = Бесята копают на {0}% быстрее, комнаты стоят на {1}% меньше.
|
||||||
|
talent.warlord.desc = Существа бьют на {0}% сильнее и получают на {1}% меньше урона.
|
||||||
|
talent.warlock.desc = Мана течёт на {0}% быстрее, чары стоят на {1}% меньше.
|
||||||
|
talent.merchant.desc = Жалованье на {0}% ниже, доставленные сокровища ценятся на {1}% выше.
|
||||||
|
talent.none = Ещё не изучено.
|
||||||
|
talent.then = Следующий уровень: {0}
|
||||||
|
msg.talent_point = Новое очко таланта! Потрать его в окне мастера.
|
||||||
|
msg.talent_learned = {0}: изучен уровень {1}.
|
||||||
|
|
||||||
|
# weather above ground
|
||||||
|
weather.fair = Ясно
|
||||||
|
weather.rain = Дождь
|
||||||
|
weather.fog = Туман
|
||||||
|
weather.storm = Гроза
|
||||||
|
msg.weather_clear = Наверху проясняется.
|
||||||
|
msg.weather_rain = Наверху начинается дождь - огонь не разгорается.
|
||||||
|
msg.weather_fog = Туман стелется над землёй - снаружи все видят хуже.
|
||||||
|
msg.weather_storm = Наверху надвигается гроза! Молнии бьют по бойцам и деревьям.
|
||||||
|
msg.lightning_unit = Удар молнии: {0}!
|
||||||
|
# creature names
|
||||||
|
names.imp = Крошка, Сопля, Копуша, Грязнуля, Чумазик, Шкет, Рылька, Замарашка, Козявка, Царапка, Комок, Хрюндель, Плюх, Пачкун, Лопатка, Шмыга, Нюхля, Мелочь, Ковырь, Бултых
|
||||||
|
names.kobold = Тявка, Щипун, Зубило, Паршивец, Хвать, Ржавчик, Шмыг, Кусака, Разиня, Крыс, Лохмотик, Колючка, Коготь, Склочник, Корешок, Хихикс, Пихало, Зазубрина, Хрясь, Царапыч
|
||||||
|
names.spider = Шуршик, Мохнолап, Паучок, Щекотун, Сеточка, Восьминог, Ниточка, Жуткоша, Перебирка, Шёлковка, Пряха, Прыгуля, Пушистик, Текла, Катушка, Хватайка, Клубочек, Липучка, Вертушка, Иголочка
|
||||||
|
names.mage = Варфоломей, Игнатий, Леопольд, Феофан, Евлампий, Иероним, Аполлинарий, Пафнутий, Корнилий, Онуфрий, Флорентий, Модест, Панкратий, Евстафий, Константин, Евсевий, Северин, Варнава, Акакий, Ферапонт
|
||||||
|
names.ogre = Громила, Чурбан, Бумбум, Валун, Тупица, Дубина, Бухтя, Колотун, Топтыга, Пень, Плюх, Брюхан, Балда, Пузан, Дубинка, Бугай, Грохот, Глыба, Барабан, Горбо
|
||||||
|
names.jailer = Хмурый, Засов, Кандальник, Замок, Темница, Железнобород, Скоба, Болт, Суровый, Решёткин, Насупленный, Крюк, Петля, Ключник, Мрачный, Звякун, Колодник, Стражник, Клеть, Горький
|
||||||
|
names.priestess = Жара, Пламя, Пепелина, Серафима, Полымя, Огнеслава, Морана, Брунгильда, Игния, Искра, Багряна, Жарка, Ворона, Лилит, Лучина, Изольда, Огнеяра, Никта, Вечерня, Купава
|
||||||
|
names.chainbreaker = Цепелом, Ульфгар, Железнодух, Браннок, Крагмор, Вальдрек, Терн, Горат, Несломленный, Рюрик, Торвальд, Хаген, Дурнок, Осколок, Пламень, Волкослав, Коррун, Арвак, Освободитель, Хальвард
|
||||||
|
names.bat = Писк, Порх, Пипа, Шорох, Эхо, Махалка, Сумрак, Свистун, Цвирк, Зубик, Вихрь, Нетопырь, Моль, Пискля, Шурша, Висюлька, Бархат, Шмыгун, Визгля, Молния
|
||||||
|
names.gargoyle = Гранит, Сланец, Базальт, Каменюка, Раствор, Галька, Щебень, Кремень, Шов, Руина, Кварц, Пемза, Карниз, Нарост, Каменобород, Угрюм, Фронтон, Водосток, Долото, Цоколь
|
||||||
|
names.owl = Филин, Умник, Пёрышко, Бартольд, Минерва, Товий, Ухух, Совушка, Пухлик, Вздох, Мыслитель, Старейшина, Сыч, Ноктуа, Совуслав, Пергамент, Сумеречник, Неясыть, Профессор, Пушок
|
||||||
|
names.wyrm = Вермитракс, Пеплопасть, Палёный, Кальдерон, Игнивар, Жаророг, Драккар, Пирр, Обсидиан, Жаропасть, Вулканор, Палезуб, Малграт, Тиракс, Инферно, Сера, Кальдор, Фафнир, Рагнор, Уголёк
|
||||||
|
names.cogling = Шестерёнка, Колёсико, Винтик, Трещотка, Заклёпка, Привод, Мастерок, Штуковина, Ключик, Шпиндель, Звякалка, Ковыряшка, Гаечка, Жужжа, Рычажок, Юла, Фланец, Щёлк, Тиктак, Шурупчик
|
||||||
|
names.revenant = Тоска, Горечь, Пепельный, Стон, Пустота, Скорбь, Бледный, Кручина, Тень, Прах, Могильщик, Сумрак, Склеп, Печаль, Бдение, Курган, Саван, Элегия, Осадок, Пустошь
|
||||||
|
names.beast = Лохмач, Мишка, Бруно, Косматый, Чавка, Мохоспин, Ворчун, Медвежонок, Кустик, Хрум, Щёлкун, Ползун, Щетина, Мох, Рокот, Ежевика, Стрекот, Бурчун, Топтыгин, Нюхач
|
||||||
|
names.human = Жерар, Роланд, Конрад, Альберт, Тристан, Экхарт, Гавейн, Ланселот, Парцифаль, Гуго, Бертрам, Готфрид, Ортвин, Балдуин, Рейнольд, Вильфрид, Дитрих, Жильбер, Руперт, Ратибор
|
||||||
|
epithets.mid = Костолом, Грязнопят, Крысогрыз, Чумазорыл, Камнежуй, Черепокол, Смердокулак, Ржавокост, Брюхобой, Лягун, Плеснебород, Грязекоготь, Соплемёт, Ухогрыз, Коленолом, Червеед
|
||||||
|
epithets.high = Цареубийца, Рокорук, Ужасозуб, Мирокрушитель, Героебой, Бурекулак, Ночестрах, Душежнец, Железнодлань, Градолом, Драконобой, Адоход, Теневладыка, Крововенец, Рокопад, Звездолом
|
||||||
|
name.epithet = {0} {1}
|
||||||
|
msg.creature_fallen = Пал твой боец: {0}. Памятный камень хранит имя.
|
||||||
|
tile.memorial = Памятный камень
|
||||||
|
tile.memorial_info = Здесь покоится {0} ({1}, уровень {2}).
|
||||||
|
|
||||||
|
# hall of the fallen
|
||||||
|
hall.title = Зал павших
|
||||||
|
hall.count = Павших существ в памяти: {0}
|
||||||
|
hall.empty = Пока никто не пал. Пусть так и будет.
|
||||||
|
hall.kills = Убито героев: {0}
|
||||||
|
ov.fallen = Павшие ({0})
|
||||||
|
ov.living = Живые
|
||||||
|
cause.slain = убийца: {0}
|
||||||
|
cause.lava = гибель в лаве
|
||||||
|
cause.water = утопление
|
||||||
|
cause.snare = гибель в капкане
|
||||||
|
cause.lightning = удар молнии
|
||||||
|
cause.fell = гибель в бою
|
||||||
@@ -4,6 +4,13 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
# test build: always reload fresh files
|
# test build: always reload fresh files
|
||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-cache";
|
||||||
|
# compressed downloads: the big files are packed once at build time (*.gz, see Dockerfile),
|
||||||
|
# the rest on the fly (the wasm goes from about 8.8 MB to 2.6 MB)
|
||||||
|
gzip_static on;
|
||||||
|
gzip on;
|
||||||
|
gzip_types application/wasm application/javascript text/css application/json application/manifest+json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_vary on;
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -562,6 +562,43 @@ async fn list_campaigns(State(st): State<AppState>, headers: HeaderMap) -> Respo
|
|||||||
Json(campaign_infos(&st.campaign_dir(&u.name)).await).into_response()
|
Json(campaign_infos(&st.campaign_dir(&u.name)).await).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the player's profile: lifetime statistics and achievements (an opaque JSON object; the client
|
||||||
|
// merges it with what it kept locally)
|
||||||
|
const MAX_PROFILE: usize = 64 * 1024;
|
||||||
|
|
||||||
|
async fn get_profile(State(st): State<AppState>, headers: HeaderMap) -> Response {
|
||||||
|
let u = match st.auth(&headers).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(r) => return r,
|
||||||
|
};
|
||||||
|
match tokio::fs::read(st.user_dir(&u.name).join("profile.json")).await {
|
||||||
|
Ok(b) => ([("content-type", "application/json")], b).into_response(),
|
||||||
|
Err(_) => ([("content-type", "application/json")], b"{}".to_vec()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_profile(State(st): State<AppState>, headers: HeaderMap, body: Bytes) -> Response {
|
||||||
|
let u = match st.auth(&headers).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(r) => return r,
|
||||||
|
};
|
||||||
|
if body.len() > MAX_PROFILE {
|
||||||
|
return err(StatusCode::PAYLOAD_TOO_LARGE, "profile too large");
|
||||||
|
}
|
||||||
|
if limited(&st.writes, WRITES_PER_MINUTE).await {
|
||||||
|
return err(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
||||||
|
}
|
||||||
|
if !serde_json::from_slice::<serde_json::Value>(&body).map_or(false, |v| v.is_object()) {
|
||||||
|
return err(StatusCode::BAD_REQUEST, "not a JSON object");
|
||||||
|
}
|
||||||
|
let dir = st.user_dir(&u.name);
|
||||||
|
let _ = tokio::fs::create_dir_all(&dir).await;
|
||||||
|
match tokio::fs::write(dir.join("profile.json"), &body).await {
|
||||||
|
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(_) => err(StatusCode::INTERNAL_SERVER_ERROR, "write failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_campaign(State(st): State<AppState>, headers: HeaderMap, Path(id): Path<String>) -> Response {
|
async fn get_campaign(State(st): State<AppState>, headers: HeaderMap, Path(id): Path<String>) -> Response {
|
||||||
let u = match st.auth(&headers).await {
|
let u = match st.auth(&headers).await {
|
||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
@@ -754,6 +791,7 @@ fn router(state: AppState) -> Router {
|
|||||||
.route("/api/users", get(list_users).post(create_user))
|
.route("/api/users", get(list_users).post(create_user))
|
||||||
.route("/api/users/{name}", delete(delete_user))
|
.route("/api/users/{name}", delete(delete_user))
|
||||||
.route("/api/netplay/ws", get(netplay::netplay_ws))
|
.route("/api/netplay/ws", get(netplay::netplay_ws))
|
||||||
|
.route("/api/profile", get(get_profile).put(put_profile))
|
||||||
.route("/api/saves", get(list).post(create))
|
.route("/api/saves", get(list).post(create))
|
||||||
.route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete_save))
|
.route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete_save))
|
||||||
.route("/api/campaigns", get(list_campaigns).post(create_campaign).layer(campaign_limit()))
|
.route("/api/campaigns", get(list_campaigns).post(create_campaign).layer(campaign_limit()))
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
//
|
//
|
||||||
// Lobby messages (JSON, one object per WebSocket frame):
|
// Lobby messages (JSON, one object per WebSocket frame):
|
||||||
// {"t":"rooms"} -> list of open matches
|
// {"t":"rooms"} -> list of open matches
|
||||||
// {"t":"create","sides":[0,2,3,4]?,"map_w":96?,"map_h":128?,"difficulty":1?,"mission":12?,"doc":{..}?}
|
// {"t":"create","sides":[0,2,3,4]?,"map_w":96?,"map_h":128?,"difficulty":1?,"mission":12?,"doc":{..}?,"surface":true?}
|
||||||
|
// -> "surface": a free match whose eastern half lies above ground
|
||||||
// -> "doc": an editor campaign (CampaignDoc JSON) for a co-op
|
// -> "doc": an editor campaign (CampaignDoc JSON) for a co-op
|
||||||
// match of its mission "mission"; it goes along with "start"
|
// match of its mission "mission"; it goes along with "start"
|
||||||
|
// "seed":77?,"carry":[..]? with a doc: an interrupted run goes
|
||||||
|
// on - "start" then has this seed, the carry and "resume":true
|
||||||
// {"t":"join","code":"ABC123"}
|
// {"t":"join","code":"ABC123"}
|
||||||
// {"t":"take_side","side":2}
|
// {"t":"take_side","side":2}
|
||||||
// {"t":"ready","ready":true}
|
// {"t":"ready","ready":true}
|
||||||
@@ -19,9 +22,14 @@
|
|||||||
// {"t":"resume"} -> side 0 resumes a paused match
|
// {"t":"resume"} -> side 0 resumes a paused match
|
||||||
// {"t":"abort"} -> side 0 closes the match
|
// {"t":"abort"} -> side 0 closes the match
|
||||||
// {"t":"set_ai","side":2,"ai":true} -> the host marks an empty seat as AI, or clears it again
|
// {"t":"set_ai","side":2,"ai":true} -> the host marks an empty seat as AI, or clears it again
|
||||||
|
// {"t":"set_team","team":1,"side":3?} -> team 0..3 (A-D) of the sender's seat before the start; the
|
||||||
|
// host may name an empty or AI seat with "side"
|
||||||
|
// {"t":"next","mission":3,"seed":77?,"carry":[..]?} -> the host starts the next mission of an editor campaign
|
||||||
|
// co-op match; every client gets "next" (no campaign: they
|
||||||
|
// have it) with the seed, sides, AI seats and the carry
|
||||||
// {"t":"leave"} -> a player leaves; a started human seat becomes AI-controlled while anyone is connected
|
// {"t":"leave"} -> a player leaves; a started human seat becomes AI-controlled while anyone is connected
|
||||||
//
|
//
|
||||||
// Server messages include: hello, rooms, room, start, turn, sum, chat, closed, error, pong.
|
// Server messages include: hello, rooms, room, start, next, turn, sum, chat, closed, error, pong.
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -80,6 +88,9 @@ struct Room {
|
|||||||
difficulty: u8,
|
difficulty: u8,
|
||||||
mission: Option<usize>,
|
mission: Option<usize>,
|
||||||
doc: Option<Value>, // editor campaign of a co-op match (sent to every client at the start)
|
doc: Option<Value>, // editor campaign of a co-op match (sent to every client at the start)
|
||||||
|
resume: Option<(u32, Value)>, // a resumed editor campaign run: its seed and what the masters carry
|
||||||
|
surface: bool, // a free match half above ground
|
||||||
|
events: bool, // random events in a free match
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -88,6 +99,15 @@ struct Seat {
|
|||||||
name: String,
|
name: String,
|
||||||
ready: bool,
|
ready: bool,
|
||||||
ai: bool,
|
ai: bool,
|
||||||
|
team: u8, // 0..3 (A-D) in a versus match; by default every seat is a team of its own
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_team(side: u8) -> u8 {
|
||||||
|
MASTER_SIDES.iter().position(|s| *s == side).unwrap_or(0) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_seat(side: u8, ai: bool) -> Seat {
|
||||||
|
Seat { conn: None, name: String::new(), ready: false, ai, team: default_team(side) }
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Room {
|
impl Room {
|
||||||
@@ -126,6 +146,19 @@ impl Room {
|
|||||||
self.desired_ai_sides()
|
self.desired_ai_sides()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the team of every side of `sides`, in the same order
|
||||||
|
fn teams_of(&self, sides: &[u8]) -> Vec<u8> {
|
||||||
|
sides.iter().map(|s| self.seats.get(s).map_or(default_team(*s), |seat| seat.team)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the name of the editor campaign and the title of its current mission (room list)
|
||||||
|
fn campaign_names(&self) -> (Option<String>, Option<String>) {
|
||||||
|
let Some(doc) = &self.doc else { return (None, None) };
|
||||||
|
let name = doc.get("name").and_then(|n| n.as_str()).map(|n| n.chars().take(40).collect::<String>());
|
||||||
|
let title = self.mission.and_then(|m| doc.get("missions").and_then(|ms| ms.get(m))).and_then(|m| m.get("title")).and_then(|t| t.as_str()).map(|t| t.chars().take(40).collect::<String>());
|
||||||
|
(name, title)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -269,6 +302,8 @@ impl Netplay {
|
|||||||
"take_side" => self.take_side(conn, &msg),
|
"take_side" => self.take_side(conn, &msg),
|
||||||
"ready" => self.ready(conn, &msg),
|
"ready" => self.ready(conn, &msg),
|
||||||
"set_ai" => self.set_ai(conn, &msg),
|
"set_ai" => self.set_ai(conn, &msg),
|
||||||
|
"set_team" => self.set_team(conn, &msg),
|
||||||
|
"next" => self.next(conn, &msg),
|
||||||
"start" => self.start(conn),
|
"start" => self.start(conn),
|
||||||
"cmds" => self.cmds(conn, &msg),
|
"cmds" => self.cmds(conn, &msg),
|
||||||
"sum" => self.sum(conn, &msg),
|
"sum" => self.sum(conn, &msg),
|
||||||
@@ -329,6 +364,10 @@ impl Netplay {
|
|||||||
let difficulty = msg.get("difficulty").and_then(|v| v.as_u64()).unwrap_or(1).clamp(0, 2) as u8;
|
let difficulty = msg.get("difficulty").and_then(|v| v.as_u64()).unwrap_or(1).clamp(0, 2) as u8;
|
||||||
let mission = mission_of(msg.get("mission"));
|
let mission = mission_of(msg.get("mission"));
|
||||||
let doc = msg.get("doc").filter(|d| !d.is_null()).cloned();
|
let doc = msg.get("doc").filter(|d| !d.is_null()).cloned();
|
||||||
|
let resume = match (&doc, msg.get("seed").and_then(|v| v.as_u64())) {
|
||||||
|
(Some(_), Some(seed)) => Some((seed as u32, msg.get("carry").filter(|c| c.is_array()).cloned().unwrap_or_else(|| json!([])))),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
match &doc {
|
match &doc {
|
||||||
Some(d) => {
|
Some(d) => {
|
||||||
let count = d.get("missions").and_then(|m| m.as_array()).map_or(0, |m| m.len());
|
let count = d.get("missions").and_then(|m| m.as_array()).map_or(0, |m| m.len());
|
||||||
@@ -345,8 +384,8 @@ impl Netplay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into());
|
let name = self.clients.get(&conn).map(|c| c.name.clone()).unwrap_or_else(|| "Guest".into());
|
||||||
let seats = sides.iter().map(|s| (*s, Seat { conn: None, name: String::new(), ready: false, ai: false })).collect::<HashMap<_, _>>();
|
let seats = sides.iter().map(|s| (*s, new_seat(*s, false))).collect::<HashMap<_, _>>();
|
||||||
let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty, mission, doc };
|
let room = Room { code: code.clone(), sides, seats, pending: HashMap::new(), turn: 0, started: false, paused: false, missing: false, seed: None, map_w, map_h, difficulty, mission, doc, resume, surface: msg.get("surface").and_then(|v| v.as_bool()).unwrap_or(false), events: msg.get("events").and_then(|v| v.as_bool()).unwrap_or(false) };
|
||||||
self.rooms.insert(code.clone(), room);
|
self.rooms.insert(code.clone(), room);
|
||||||
if let Some(c) = self.clients.get_mut(&conn) {
|
if let Some(c) = self.clients.get_mut(&conn) {
|
||||||
c.room = Some(code.clone());
|
c.room = Some(code.clone());
|
||||||
@@ -493,7 +532,7 @@ impl Netplay {
|
|||||||
}
|
}
|
||||||
r.sides.push(side);
|
r.sides.push(side);
|
||||||
sort_sides(&mut r.sides);
|
sort_sides(&mut r.sides);
|
||||||
r.seats.insert(side, Seat { conn: None, name: String::new(), ready: false, ai: true });
|
r.seats.insert(side, new_seat(side, true));
|
||||||
} else {
|
} else {
|
||||||
if r.seats[&side].conn.is_some() {
|
if r.seats[&side].conn.is_some() {
|
||||||
return self.error(conn, "seat is taken");
|
return self.error(conn, "seat is taken");
|
||||||
@@ -512,6 +551,68 @@ impl Netplay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_team(&mut self, conn: u64, msg: &Value) {
|
||||||
|
let team = match msg.get("team").and_then(|v| v.as_u64()) {
|
||||||
|
Some(n) if n < MASTER_SIDES.len() as u64 => n as u8,
|
||||||
|
_ => return self.error(conn, "unknown team"),
|
||||||
|
};
|
||||||
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return self.error(conn, "not in a match"),
|
||||||
|
};
|
||||||
|
let Some(own) = self.side_of(&code, conn) else { return self.error(conn, "no seat") };
|
||||||
|
let side = msg.get("side").and_then(|v| v.as_u64()).map_or(own, |n| n as u8);
|
||||||
|
if let Some(r) = self.rooms.get_mut(&code) {
|
||||||
|
if r.started {
|
||||||
|
return self.error(conn, "teams can only be changed before the start");
|
||||||
|
}
|
||||||
|
let Some(seat) = r.seats.get(&side) else { return self.error(conn, "side not in match") };
|
||||||
|
if side != own && (own != 0 || seat.conn.is_some()) {
|
||||||
|
return self.error(conn, "only the red master can set the team of a free or AI seat");
|
||||||
|
}
|
||||||
|
if let Some(seat) = r.seats.get_mut(&side) {
|
||||||
|
seat.team = team;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.room_msg_all(&code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the next mission of an editor campaign co-op match: the host decided it (the branch follows
|
||||||
|
// the reached goal) and hands the carry-over of every side along; the clients already have the
|
||||||
|
// campaign. The turn clock starts again.
|
||||||
|
fn next(&mut self, conn: u64, msg: &Value) {
|
||||||
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return self.error(conn, "not in a match"),
|
||||||
|
};
|
||||||
|
let mission = mission_of(msg.get("mission"));
|
||||||
|
let seed = msg.get("seed").and_then(|v| v.as_u64()).map(|s| s as u32);
|
||||||
|
let carry = msg.get("carry").filter(|c| c.is_array()).cloned().unwrap_or(Value::Array(Vec::new()));
|
||||||
|
let out = match self.rooms.get_mut(&code) {
|
||||||
|
Some(r) => {
|
||||||
|
if !r.started {
|
||||||
|
return self.error(conn, "match not started");
|
||||||
|
}
|
||||||
|
if r.seats.get(&0).and_then(|s| s.conn) != Some(conn) {
|
||||||
|
return self.error(conn, "only the red master can start the next mission");
|
||||||
|
}
|
||||||
|
let count = r.doc.as_ref().and_then(|d| d.get("missions")).and_then(|m| m.as_array()).map_or(0, |m| m.len());
|
||||||
|
let Some(m) = mission.filter(|m| *m < count) else { return self.error(conn, "unknown campaign mission") };
|
||||||
|
r.mission = Some(m);
|
||||||
|
r.seed = Some(seed.unwrap_or_else(random_seed));
|
||||||
|
r.turn = 0;
|
||||||
|
r.pending.clear();
|
||||||
|
r.paused = false;
|
||||||
|
r.missing = false;
|
||||||
|
r.sides = r.active_sides();
|
||||||
|
json!({"t":"next","seed":r.seed.unwrap_or(0),"difficulty":r.difficulty,"sides":r.sides,"teams":r.teams_of(&r.sides),"mission":m,"ai":r.started_ai_sides(),"carry":carry})
|
||||||
|
}
|
||||||
|
None => return self.error(conn, "not in a match"),
|
||||||
|
};
|
||||||
|
self.room_msg_all(&code);
|
||||||
|
self.broadcast_room(&code, out);
|
||||||
|
}
|
||||||
|
|
||||||
fn start(&mut self, conn: u64) {
|
fn start(&mut self, conn: u64) {
|
||||||
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
let code = match self.clients.get(&conn).and_then(|c| c.room.clone()) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
@@ -539,16 +640,26 @@ impl Netplay {
|
|||||||
r.started = true;
|
r.started = true;
|
||||||
r.paused = false;
|
r.paused = false;
|
||||||
r.missing = false;
|
r.missing = false;
|
||||||
r.seed = Some(random_seed());
|
r.seed = Some(r.resume.as_ref().map_or_else(random_seed, |x| x.0));
|
||||||
r.turn = 0;
|
r.turn = 0;
|
||||||
r.sides = active;
|
r.sides = active;
|
||||||
}
|
}
|
||||||
let msg = match self.rooms.get(&code) {
|
let msg = match self.rooms.get(&code) {
|
||||||
Some(r) => {
|
Some(r) => {
|
||||||
let mut m = json!({"t":"start","seed":r.seed.unwrap_or(0),"map_w":r.map_w,"map_h":r.map_h,"difficulty":r.difficulty,"sides":r.sides,"mission":r.mission,"ai":r.started_ai_sides()});
|
let mut m = json!({"t":"start","seed":r.seed.unwrap_or(0),"map_w":r.map_w,"map_h":r.map_h,"difficulty":r.difficulty,"sides":r.sides,"teams":r.teams_of(&r.sides),"mission":r.mission,"ai":r.started_ai_sides()});
|
||||||
if let Some(doc) = &r.doc {
|
if let Some(doc) = &r.doc {
|
||||||
m["doc"] = doc.clone();
|
m["doc"] = doc.clone();
|
||||||
}
|
}
|
||||||
|
if r.surface {
|
||||||
|
m["surface"] = json!(true);
|
||||||
|
}
|
||||||
|
if r.events {
|
||||||
|
m["events"] = json!(true);
|
||||||
|
}
|
||||||
|
if let Some((_, carry)) = &r.resume {
|
||||||
|
m["carry"] = carry.clone();
|
||||||
|
m["resume"] = json!(true);
|
||||||
|
}
|
||||||
m
|
m
|
||||||
}
|
}
|
||||||
None => return,
|
None => return,
|
||||||
@@ -763,9 +874,10 @@ impl Netplay {
|
|||||||
let sides: Vec<u8> = if r.started { r.active_sides() } else { r.sides.clone() };
|
let sides: Vec<u8> = if r.started { r.active_sides() } else { r.sides.clone() };
|
||||||
let seats: Vec<Value> = sides
|
let seats: Vec<Value> = sides
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|side| r.seats.get(side).map(|s| json!({"side":side,"name":s.name,"ready":s.ready,"connected":s.conn.is_some(),"ai":s.ai})))
|
.filter_map(|side| r.seats.get(side).map(|s| json!({"side":side,"name":s.name,"ready":s.ready,"connected":s.conn.is_some(),"ai":s.ai,"team":s.team})))
|
||||||
.collect();
|
.collect();
|
||||||
let ai: Vec<u8> = r.room_ai_sides();
|
let ai: Vec<u8> = r.room_ai_sides();
|
||||||
|
let (campaign, title) = r.campaign_names();
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"t":"room",
|
"t":"room",
|
||||||
"code":r.code,
|
"code":r.code,
|
||||||
@@ -781,7 +893,9 @@ impl Netplay {
|
|||||||
"difficulty":r.difficulty,
|
"difficulty":r.difficulty,
|
||||||
"seed":r.seed,
|
"seed":r.seed,
|
||||||
"mission":r.mission,
|
"mission":r.mission,
|
||||||
"editor":r.doc.is_some()
|
"editor":r.doc.is_some(),
|
||||||
|
"campaign":campaign,
|
||||||
|
"title":title
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,7 +905,8 @@ impl Netplay {
|
|||||||
.values()
|
.values()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
let players = r.seats.values().filter(|s| s.conn.is_some()).count();
|
let players = r.seats.values().filter(|s| s.conn.is_some()).count();
|
||||||
json!({"code":r.code,"players":players,"sides":r.sides,"started":r.started,"paused":r.paused,"mission":r.mission,"editor":r.doc.is_some()})
|
let (campaign, title) = r.campaign_names();
|
||||||
|
json!({"code":r.code,"players":players,"sides":r.sides,"teams":r.teams_of(&r.sides),"started":r.started,"paused":r.paused,"mission":r.mission,"editor":r.doc.is_some(),"campaign":campaign,"title":title})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
self.send(conn, json!({"t":"rooms","rooms":rooms}));
|
self.send(conn, json!({"t":"rooms","rooms":rooms}));
|
||||||
@@ -876,6 +991,22 @@ mod tests {
|
|||||||
assert_eq!(start_red["mission"].as_u64(), Some(2));
|
assert_eq!(start_red["mission"].as_u64(), Some(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resumed_run_starts_with_its_seed_and_carry() {
|
||||||
|
let mut net = Netplay::new();
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
let red = net.connect(tx, Some("red".into()), None);
|
||||||
|
let doc = json!({"format":"dungeoncraft-campaign","name":"Mine","missions":[{"key":"a","title":"First"},{"key":"b","title":"Second","size":[96,96]}]});
|
||||||
|
let carry = json!([[0, {"gold": 100.0, "creatures": []}]]);
|
||||||
|
net.handle(red, json!({"t":"create","sides":[0,2],"mission":1,"doc":doc,"seed":4242,"carry":carry}));
|
||||||
|
net.handle(red, json!({"t":"ready"}));
|
||||||
|
net.handle(red, json!({"t":"start"}));
|
||||||
|
let start = until(&mut rx, "start");
|
||||||
|
assert_eq!(start["seed"].as_u64(), Some(4242));
|
||||||
|
assert_eq!(start["carry"], carry);
|
||||||
|
assert_eq!(start["resume"].as_bool(), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_editor_campaign_goes_from_the_create_to_every_start() {
|
fn an_editor_campaign_goes_from_the_create_to_every_start() {
|
||||||
let mut net = Netplay::new();
|
let mut net = Netplay::new();
|
||||||
@@ -917,6 +1048,97 @@ mod tests {
|
|||||||
assert!(start.get("doc").is_none());
|
assert!(start.get("doc").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn players_choose_their_teams_and_the_start_carries_them() {
|
||||||
|
let mut net = Netplay::new();
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
let red = net.connect(tx, Some("red".into()), None);
|
||||||
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2,3]}));
|
||||||
|
let code = rooms[0].clone();
|
||||||
|
let room = until(&mut rx, "room");
|
||||||
|
let teams: Vec<u64> = room["seats"].as_array().unwrap().iter().map(|s| s["team"].as_u64().unwrap()).collect();
|
||||||
|
assert_eq!(teams, vec![0, 1, 2], "every seat is its own team at first");
|
||||||
|
|
||||||
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
||||||
|
let green = net.connect(tx2, Some("green".into()), None);
|
||||||
|
net.handle(green, json!({"t":"join","code":&code}));
|
||||||
|
// green joins red's team; green may not decide for blue's seat, red (host) may
|
||||||
|
net.handle(green, json!({"t":"set_team","team":0}));
|
||||||
|
net.handle(green, json!({"t":"set_team","team":3,"side":3}));
|
||||||
|
assert_eq!(until(&mut rx2, "error")["why"].as_str(), Some("only the red master can set the team of a free or AI seat"));
|
||||||
|
net.handle(red, json!({"t":"set_ai","side":3,"ai":true}));
|
||||||
|
net.handle(red, json!({"t":"set_team","team":3,"side":3}));
|
||||||
|
net.handle(red, json!({"t":"set_team","team":9}));
|
||||||
|
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown team"));
|
||||||
|
let room = net.room_json(&code).unwrap();
|
||||||
|
let team = |side: u64| room["seats"].as_array().unwrap().iter().find(|s| s["side"].as_u64() == Some(side)).unwrap()["team"].as_u64();
|
||||||
|
assert_eq!((team(0), team(2), team(3)), (Some(0), Some(0), Some(3)));
|
||||||
|
|
||||||
|
net.handle(red, json!({"t":"rooms"}));
|
||||||
|
let list = until(&mut rx, "rooms");
|
||||||
|
assert_eq!(list["rooms"][0]["teams"], json!([0, 0, 3]));
|
||||||
|
|
||||||
|
net.handle(red, json!({"t":"ready"}));
|
||||||
|
net.handle(green, json!({"t":"ready"}));
|
||||||
|
net.handle(red, json!({"t":"start"}));
|
||||||
|
let start = until(&mut rx2, "start");
|
||||||
|
assert_eq!(start["sides"], json!([0, 2, 3]));
|
||||||
|
assert_eq!(start["teams"], json!([0, 0, 3]));
|
||||||
|
net.handle(red, json!({"t":"set_team","team":1}));
|
||||||
|
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("teams can only be changed before the start"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_host_starts_the_next_editor_mission_for_everybody() {
|
||||||
|
let mut net = Netplay::new();
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
let red = net.connect(tx, Some("red".into()), None);
|
||||||
|
let doc = json!({"name":"Deep Run","missions":[{"key":"a","title":"First"},{"key":"b","title":"Second"}]});
|
||||||
|
let rooms = net.handle(red, json!({"t":"create","sides":[0,2],"map_w":96,"map_h":96,"mission":0,"doc":doc}));
|
||||||
|
let code = rooms[0].clone();
|
||||||
|
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
||||||
|
let green = net.connect(tx2, Some("green".into()), None);
|
||||||
|
net.handle(green, json!({"t":"rooms"}));
|
||||||
|
let list = until(&mut rx2, "rooms");
|
||||||
|
assert_eq!(list["rooms"][0]["campaign"].as_str(), Some("Deep Run"), "the room list names the campaign");
|
||||||
|
assert_eq!(list["rooms"][0]["title"].as_str(), Some("First"));
|
||||||
|
net.handle(green, json!({"t":"join","code":&code}));
|
||||||
|
net.handle(red, json!({"t":"ready"}));
|
||||||
|
net.handle(green, json!({"t":"ready"}));
|
||||||
|
net.handle(red, json!({"t":"start"}));
|
||||||
|
until(&mut rx, "start");
|
||||||
|
until(&mut rx2, "start");
|
||||||
|
net.handle(red, json!({"t":"cmds","cmds":[{"side":0,"seq":0,"act":"CancelOrders"}]}));
|
||||||
|
net.tick(&code);
|
||||||
|
net.tick(&code);
|
||||||
|
assert_eq!(net.rooms[&code].turn, 2);
|
||||||
|
|
||||||
|
// only the host decides; the mission must exist
|
||||||
|
net.handle(green, json!({"t":"next","mission":1}));
|
||||||
|
assert_eq!(until(&mut rx2, "error")["why"].as_str(), Some("only the red master can start the next mission"));
|
||||||
|
net.handle(red, json!({"t":"next","mission":5}));
|
||||||
|
assert_eq!(until(&mut rx, "error")["why"].as_str(), Some("unknown campaign mission"));
|
||||||
|
|
||||||
|
net.handle(red, json!({"t":"cmds","cmds":[{"side":0,"seq":1,"act":"CancelOrders"}]}));
|
||||||
|
let carry = json!([[0, {"creatures":[["Ogre",3,10.0]],"gold":300.0}], [2, {"creatures":[],"gold":40.0}]]);
|
||||||
|
net.handle(red, json!({"t":"next","mission":1,"seed":4711,"carry":carry}));
|
||||||
|
let next_red = until(&mut rx, "next");
|
||||||
|
let next_green = until(&mut rx2, "next");
|
||||||
|
assert_eq!(next_green["mission"].as_u64(), Some(1));
|
||||||
|
assert_eq!(next_green["seed"].as_u64(), Some(4711), "the run seed of the host");
|
||||||
|
assert_eq!(next_green["sides"], json!([0, 2]));
|
||||||
|
assert_eq!(next_green["carry"], carry, "the carry goes to everybody");
|
||||||
|
assert_eq!(next_red, next_green);
|
||||||
|
assert!(next_green.get("doc").is_none(), "the clients have the campaign already");
|
||||||
|
let room = net.room_json(&code).unwrap();
|
||||||
|
assert_eq!(room["title"].as_str(), Some("Second"));
|
||||||
|
assert_eq!(room["turn"].as_u64(), Some(0), "the turn clock starts again");
|
||||||
|
net.tick(&code);
|
||||||
|
let turn = until(&mut rx2, "turn");
|
||||||
|
assert_eq!(turn["turn"].as_u64(), Some(1));
|
||||||
|
assert_eq!(turn["cmds"], json!([]), "orders of the old mission are dropped");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_non_host_cannot_start_a_match() {
|
fn a_non_host_cannot_start_a_match() {
|
||||||
let mut net = Netplay::new();
|
let mut net = Netplay::new();
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
// Achievements: a lifetime profile of the player (totals of every game's statistics, wins,
|
||||||
|
// minutes played) and 64 achievements - tiers of counters (bronze, silver, gold, ...) and a few
|
||||||
|
// special, partly hidden ones. Kept in the browser; after a login merged with the server's copy
|
||||||
|
// (the larger counter wins, unlocked stays unlocked) and stored there too.
|
||||||
|
|
||||||
|
use crate::game::*;
|
||||||
|
use crate::stats as k;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
pub const PROFILE_KEY: &str = "dc.profile";
|
||||||
|
|
||||||
|
// counters of the profile: the game statistics (stats.rs) and a few of its own
|
||||||
|
pub const STAT_NAMES: [&str; k::COUNTERS] = [
|
||||||
|
"heroes", "bosses", "lost", "dug", "wood", "walls", "rooms", "spells", "slaps", "traps", "towers", "drowned", "burnt", "siege", "captured", "gold", "drawbridge",
|
||||||
|
];
|
||||||
|
|
||||||
|
// tiered achievements: id (= counter), thresholds, badge (badges.rs)
|
||||||
|
pub const TIERS: [(&str, &[f64], u32); 20] = [
|
||||||
|
("heroes", &[1.0, 50.0, 250.0, 1000.0], crate::badges::HEROES),
|
||||||
|
("bosses", &[1.0, 5.0, 20.0], crate::badges::BOSSES),
|
||||||
|
("dug", &[100.0, 1000.0, 10000.0], crate::badges::DUG),
|
||||||
|
("gold", &[10000.0, 100000.0, 1000000.0], crate::badges::GOLD),
|
||||||
|
("wood", &[50.0, 500.0, 2000.0], crate::badges::WOOD),
|
||||||
|
("walls", &[10.0, 100.0], crate::badges::WALLS),
|
||||||
|
("rooms", &[50.0, 500.0, 2000.0], crate::badges::ROOMS),
|
||||||
|
("spells", &[10.0, 100.0, 500.0], crate::badges::SPELLS),
|
||||||
|
("slaps", &[10.0, 100.0, 1000.0], crate::badges::SLAPS),
|
||||||
|
("traps", &[5.0, 50.0, 200.0], crate::badges::TRAPS),
|
||||||
|
("towers", &[5.0, 50.0], crate::badges::TOWERS),
|
||||||
|
("drowned", &[1.0, 25.0, 100.0], crate::badges::DROWNED),
|
||||||
|
("burnt", &[5.0, 50.0], crate::badges::BURNT),
|
||||||
|
("siege", &[1.0, 10.0], crate::badges::SIEGE),
|
||||||
|
("captured", &[1.0, 25.0, 100.0], crate::badges::CAPTURED),
|
||||||
|
("drawbridge", &[1.0, 50.0], crate::badges::DRAWBRIDGE),
|
||||||
|
("wins", &[1.0, 10.0, 50.0], crate::badges::WINS),
|
||||||
|
("missions", &[1.0, 10.0, 30.0], crate::badges::MISSIONS),
|
||||||
|
("coop", &[1.0, 10.0, 20.0], crate::badges::COOP),
|
||||||
|
("minutes", &[60.0, 600.0, 3000.0], crate::badges::MINUTES),
|
||||||
|
];
|
||||||
|
|
||||||
|
// special achievements: id, hidden until unlocked, badge (badges.rs)
|
||||||
|
pub const SPECIAL: [(&str, bool, u32); 8] = [
|
||||||
|
("flawless", false, crate::badges::FLAWLESS),
|
||||||
|
("speedrun", false, crate::badges::SPEEDRUN),
|
||||||
|
("campaign", false, crate::badges::CAMPAIGN),
|
||||||
|
("surface", false, crate::badges::SURFACE),
|
||||||
|
("defeat", true, crate::badges::DEFEAT),
|
||||||
|
("legend", false, crate::badges::LEGEND),
|
||||||
|
("wyrm", true, crate::badges::WYRM),
|
||||||
|
("fullhand", true, crate::badges::FULLHAND),
|
||||||
|
];
|
||||||
|
|
||||||
|
// number of achievements in all
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn count() -> usize {
|
||||||
|
TIERS.iter().map(|t| t.1.len()).sum::<usize>() + SPECIAL.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Profile {
|
||||||
|
#[serde(default)]
|
||||||
|
pub totals: BTreeMap<String, f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub unlocked: BTreeMap<String, u64>, // achievement id ("heroes.2", "flawless") -> unix time
|
||||||
|
#[serde(default)]
|
||||||
|
pub hall: Vec<crate::names::Fallen>, // the hall of the fallen over all games (newest last)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the hall of the fallen keeps this many
|
||||||
|
pub const HALL_MAX: usize = 200;
|
||||||
|
|
||||||
|
impl Profile {
|
||||||
|
pub fn total(&self, id: &str) -> f64 {
|
||||||
|
self.totals.get(id).copied().unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the larger counter wins, unlocked stays unlocked (with the earlier date)
|
||||||
|
pub fn merge(&mut self, other: &Profile) {
|
||||||
|
for (k, v) in &other.totals {
|
||||||
|
let e = self.totals.entry(k.clone()).or_insert(0.0);
|
||||||
|
*e = e.max(*v);
|
||||||
|
}
|
||||||
|
for (k, t) in &other.unlocked {
|
||||||
|
let e = self.unlocked.entry(k.clone()).or_insert(*t);
|
||||||
|
*e = (*e).min(*t);
|
||||||
|
}
|
||||||
|
// the fallen of both, once each, in the order they fell
|
||||||
|
for f in &other.hall {
|
||||||
|
if !self.hall.iter().any(|h| h.name == f.name && h.kind == f.kind && h.when == f.when) {
|
||||||
|
self.hall.push(f.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.hall.sort_by_key(|h| h.when);
|
||||||
|
let extra = self.hall.len().saturating_sub(HALL_MAX);
|
||||||
|
self.hall.drain(..extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlocks what the totals have reached; returns the new ones
|
||||||
|
pub fn check_tiers(&mut self, now: u64) -> Vec<String> {
|
||||||
|
let mut new = Vec::new();
|
||||||
|
for (id, steps, _) in TIERS.iter() {
|
||||||
|
let v = self.total(id);
|
||||||
|
for (t, &need) in steps.iter().enumerate() {
|
||||||
|
let key = format!("{}.{}", id, t + 1);
|
||||||
|
if v >= need && !self.unlocked.contains_key(&key) {
|
||||||
|
self.unlocked.insert(key.clone(), now);
|
||||||
|
new.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unlock(&mut self, id: &str, now: u64) -> bool {
|
||||||
|
if self.unlocked.contains_key(id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.unlocked.insert(id.to_string(), now);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// what the tracker needs to know about the running game besides the game itself
|
||||||
|
pub struct Context {
|
||||||
|
pub coop: bool,
|
||||||
|
pub editor_test: bool, // test play from the editor counts nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follows the running game: adds what happened to the profile every few seconds and unlocks
|
||||||
|
pub struct Tracker {
|
||||||
|
pub profile: Profile,
|
||||||
|
flushed: Vec<f32>,
|
||||||
|
hall_seen: usize, // entries of the game's hall already in the profile
|
||||||
|
last_time: f32,
|
||||||
|
ended: bool,
|
||||||
|
t: f32,
|
||||||
|
pub dirty: bool, // changed since the last store
|
||||||
|
pub upload: bool, // something new for the server
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tracker {
|
||||||
|
pub fn new(profile: Profile) -> Tracker {
|
||||||
|
Tracker { profile, flushed: vec![0.0; k::COUNTERS], hall_seen: 0, last_time: 0.0, ended: false, t: 0.0, dirty: false, upload: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// a different game started (new, loaded): nothing of it has been counted yet
|
||||||
|
fn restart(&mut self, g: &Game) {
|
||||||
|
self.flushed = (0..k::COUNTERS).map(|i| g.stats.get(i)).collect();
|
||||||
|
self.hall_seen = g.hall.len();
|
||||||
|
self.ended = g.state != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// called every frame; returns achievements unlocked just now
|
||||||
|
pub fn update(&mut self, g: &Game, ctx: &Context, dt: f32, now: u64) -> Vec<String> {
|
||||||
|
if g.demo.is_some() || ctx.editor_test {
|
||||||
|
self.last_time = 0.0;
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// a loaded or new game: its old counters are not counted again
|
||||||
|
if g.time + 0.5 < self.last_time || self.last_time == 0.0 {
|
||||||
|
self.restart(g);
|
||||||
|
}
|
||||||
|
self.last_time = g.time.max(0.01);
|
||||||
|
self.t += dt;
|
||||||
|
let ended_now = g.state != 0 && !self.ended;
|
||||||
|
if self.t < 5.0 && !ended_now {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let played = self.t;
|
||||||
|
self.t = 0.0;
|
||||||
|
let mut new = Vec::new();
|
||||||
|
// the newly fallen go into the profile's hall
|
||||||
|
if g.hall.len() > self.hall_seen {
|
||||||
|
for f in &g.hall[self.hall_seen..] {
|
||||||
|
self.profile.hall.push(crate::names::Fallen { when: now, ..f.clone() });
|
||||||
|
}
|
||||||
|
self.hall_seen = g.hall.len();
|
||||||
|
let extra = self.profile.hall.len().saturating_sub(HALL_MAX);
|
||||||
|
self.profile.hall.drain(..extra);
|
||||||
|
self.dirty = true;
|
||||||
|
self.upload = true;
|
||||||
|
}
|
||||||
|
// the counters of the game
|
||||||
|
for i in 0..k::COUNTERS {
|
||||||
|
let cur = g.stats.get(i);
|
||||||
|
let d = cur - self.flushed[i];
|
||||||
|
if d > 0.0 {
|
||||||
|
*self.profile.totals.entry(STAT_NAMES[i].to_string()).or_insert(0.0) += d as f64;
|
||||||
|
self.flushed[i] = cur;
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g.state == 0 {
|
||||||
|
*self.profile.totals.entry("minutes".into()).or_insert(0.0) += (played / 60.0) as f64;
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
// the special ones that can happen any time
|
||||||
|
if g.ents.iter().any(|e| e.alive && e.creature() && e.level >= 10) && self.profile.unlock("legend", now) {
|
||||||
|
new.push("legend".to_string());
|
||||||
|
}
|
||||||
|
if g.ents.iter().any(|e| e.alive && e.creature() && e.kind == EK::Wyrm) && self.profile.unlock("wyrm", now) {
|
||||||
|
new.push("wyrm".to_string());
|
||||||
|
}
|
||||||
|
if g.hand.len() >= 8 && self.profile.unlock("fullhand", now) {
|
||||||
|
new.push("fullhand".to_string());
|
||||||
|
}
|
||||||
|
// the end of the game
|
||||||
|
if ended_now {
|
||||||
|
self.ended = true;
|
||||||
|
if g.state == 1 {
|
||||||
|
let key = if ctx.coop {
|
||||||
|
"coop"
|
||||||
|
} else if g.mission.is_some() {
|
||||||
|
"missions"
|
||||||
|
} else {
|
||||||
|
"wins"
|
||||||
|
};
|
||||||
|
*self.profile.totals.entry(key.into()).or_insert(0.0) += 1.0;
|
||||||
|
if g.stats.get(k::LOST) == 0.0 && self.profile.unlock("flawless", now) {
|
||||||
|
new.push("flawless".into());
|
||||||
|
}
|
||||||
|
if g.time < 15.0 * 60.0 && self.profile.unlock("speedrun", now) {
|
||||||
|
new.push("speedrun".into());
|
||||||
|
}
|
||||||
|
let last = crate::campaign::missions().len().saturating_sub(1);
|
||||||
|
if !ctx.coop && g.mission.as_ref().map_or(false, |m| m.index == last) && self.profile.unlock("campaign", now) {
|
||||||
|
new.push("campaign".into());
|
||||||
|
}
|
||||||
|
if !g.map.sky.is_empty() && self.profile.unlock("surface", now) {
|
||||||
|
new.push("surface".into());
|
||||||
|
}
|
||||||
|
} else if self.profile.unlock("defeat", now) {
|
||||||
|
new.push("defeat".into());
|
||||||
|
}
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
new.extend(self.profile.check_tiers(now));
|
||||||
|
if !new.is_empty() {
|
||||||
|
self.dirty = true;
|
||||||
|
self.upload = true;
|
||||||
|
}
|
||||||
|
new
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the name and description of an achievement ("heroes.2" or "flawless") in the current language
|
||||||
|
pub fn texts(id: &str) -> (String, String) {
|
||||||
|
use crate::i18n::{tr, trf};
|
||||||
|
match id.split_once('.') {
|
||||||
|
Some((cat, tier)) => {
|
||||||
|
let t: usize = tier.parse().unwrap_or(1);
|
||||||
|
let need = TIERS.iter().find(|x| x.0 == cat).and_then(|x| x.1.get(t - 1)).copied().unwrap_or(0.0);
|
||||||
|
let roman = ["I", "II", "III", "IV", "V"][(t - 1).min(4)];
|
||||||
|
(trf(&format!("ach.{}.name", cat), &[roman.to_string()]), trf(&format!("ach.{}.desc", cat), &[fmt_count(need)]))
|
||||||
|
}
|
||||||
|
None => (tr(&format!("ach.{}.name", id)), tr(&format!("ach.{}.desc", id))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fmt_count(v: f64) -> String {
|
||||||
|
let n = v.round() as i64;
|
||||||
|
let s = n.to_string();
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, c) in s.chars().enumerate() {
|
||||||
|
if i > 0 && (s.len() - i) % 3 == 0 {
|
||||||
|
out.push('.');
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// every achievement in display order: (id, hidden, badge, tier 0..)
|
||||||
|
pub fn all() -> Vec<(String, bool, u32, usize)> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
for (id, steps, icon) in TIERS.iter() {
|
||||||
|
for t in 0..steps.len() {
|
||||||
|
v.push((format!("{}.{}", id, t + 1), false, *icon, t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (id, hidden, icon) in SPECIAL.iter() {
|
||||||
|
v.push((id.to_string(), *hidden, *icon, 9));
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn more_than_fifty_achievements() {
|
||||||
|
assert!(count() >= 50, "{}", count());
|
||||||
|
assert_eq!(all().len(), count());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_achievement_has_its_own_badge() {
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
for b in TIERS.iter().map(|t| t.2).chain(SPECIAL.iter().map(|s| s.2)) {
|
||||||
|
assert!(b < crate::badges::COUNT, "badge {} out of range", b);
|
||||||
|
assert!(seen.insert(b), "badge {} used twice", b);
|
||||||
|
}
|
||||||
|
assert_eq!(seen.len() as u32, crate::badges::COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_tracker_counts_a_game_and_unlocks() {
|
||||||
|
let mut g = Game::new(3, 64, 64);
|
||||||
|
let mut t = Tracker::new(Profile::default());
|
||||||
|
let ctx = Context { coop: false, editor_test: false };
|
||||||
|
g.time = 1.0;
|
||||||
|
t.update(&g, &ctx, 0.1, 1);
|
||||||
|
g.count(k::HEROES, 3.0);
|
||||||
|
g.count(k::DUG, 150.0);
|
||||||
|
g.time = 10.0;
|
||||||
|
let new = t.update(&g, &ctx, 6.0, 2);
|
||||||
|
assert!(new.contains(&"heroes.1".to_string()) && new.contains(&"dug.1".to_string()), "{:?}", new);
|
||||||
|
assert_eq!(t.profile.total("heroes"), 3.0);
|
||||||
|
// the same counters are not counted twice
|
||||||
|
g.time = 20.0;
|
||||||
|
t.update(&g, &ctx, 6.0, 3);
|
||||||
|
assert_eq!(t.profile.total("heroes"), 3.0);
|
||||||
|
// a won free game without losses
|
||||||
|
g.state = 1;
|
||||||
|
let new = t.update(&g, &ctx, 0.1, 4);
|
||||||
|
assert!(new.contains(&"wins.1".to_string()) && new.contains(&"flawless".to_string()), "{:?}", new);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profiles_merge() {
|
||||||
|
let mut a = Profile::default();
|
||||||
|
a.totals.insert("heroes".into(), 10.0);
|
||||||
|
a.unlocked.insert("heroes.1".into(), 5);
|
||||||
|
let mut b = Profile::default();
|
||||||
|
b.totals.insert("heroes".into(), 7.0);
|
||||||
|
b.totals.insert("dug".into(), 100.0);
|
||||||
|
b.unlocked.insert("heroes.1".into(), 3);
|
||||||
|
b.unlocked.insert("dug.1".into(), 4);
|
||||||
|
a.merge(&b);
|
||||||
|
assert_eq!(a.total("heroes"), 10.0);
|
||||||
|
assert_eq!(a.total("dug"), 100.0);
|
||||||
|
assert_eq!(a.unlocked["heroes.1"], 3);
|
||||||
|
assert!(a.unlocked.contains_key("dug.1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
// AI dungeon masters: a rival (free play, campaign), an AI seat of a match or the co-op partner is
|
||||||
|
// a real master with gold, mana, imps, rooms and research - the same rules as the player. This
|
||||||
|
// brain gives him the player's orders every few seconds: summon imps, mark earth and veins to dig
|
||||||
|
// in growing rings, build rooms in a sensible order, put towers at the border, set the attack
|
||||||
|
// flag on an enemy heart once the army is strong enough (and take it back after heavy losses),
|
||||||
|
// guard its own heart with a shield. The difficulty sets how often it thinks and how bold it is.
|
||||||
|
// Everything runs in the shared step with the shared random numbers (the same on every client).
|
||||||
|
|
||||||
|
use crate::game::*;
|
||||||
|
use crate::map::*;
|
||||||
|
|
||||||
|
// per difficulty (easy, medium, hard)
|
||||||
|
const THINK: [f32; 3] = [5.2, 3.25, 1.95]; // seconds between two thoughts (a calm pace)
|
||||||
|
const MAX_RADIUS: [i32; 3] = [11, 15, 19]; // how far the dungeon grows around the heart
|
||||||
|
const MAX_IMPS: [usize; 3] = [5, 7, 9];
|
||||||
|
const FIRST_ATTACK: [f32; 3] = [940.0, 625.0, 430.0]; // no attack before (seconds)
|
||||||
|
const BOLDNESS: [f32; 3] = [1.0, 1.2, 1.35]; // own army / enemy army needed to attack (easy is reckless, hard waits)
|
||||||
|
const MARK_REACH: i32 = 5; // an AI master marks only this close to its own ground
|
||||||
|
const CREATURE_TARGET: [usize; 3] = [24, 50, 80]; // beds an AI master builds up to
|
||||||
|
const MAX_TOWERS: [usize; 3] = [1, 2, 4];
|
||||||
|
const START_GOLD: [f32; 3] = [1500.0, 2500.0, 3500.0];
|
||||||
|
|
||||||
|
// the rooms a master builds, in this order: (room index, width, height, how many of them)
|
||||||
|
const BUILD: [(usize, i32, i32, usize); 10] = [
|
||||||
|
(0, 3, 3, 1), // treasury
|
||||||
|
(1, 4, 3, 1), // lair
|
||||||
|
(2, 4, 3, 1), // mushroom garden
|
||||||
|
(3, 3, 3, 1), // training hall
|
||||||
|
(4, 3, 3, 1), // library
|
||||||
|
(1, 4, 3, 2), // a second lair
|
||||||
|
(2, 4, 3, 2), // a second garden
|
||||||
|
(8, 3, 3, 1), // forge
|
||||||
|
(6, 3, 3, 1), // temple
|
||||||
|
(9, 3, 2, 1), // prison
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn start_gold(difficulty: u8) -> f32 {
|
||||||
|
START_GOLD[difficulty.min(2) as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
// the brain of rival k (its master is the one being processed)
|
||||||
|
pub(crate) fn ai_think(&mut self, k: usize, dt: f32) {
|
||||||
|
let d = self.rivals[k].difficulty.min(2) as usize;
|
||||||
|
self.rivals[k].think_t -= dt;
|
||||||
|
if self.rivals[k].think_t > 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.rivals[k].think_t = THINK[d] * (0.85 + self.rng.f() * 0.3);
|
||||||
|
let side = self.rivals[k].side;
|
||||||
|
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
|
||||||
|
self.ai_imps(d, cx, cy);
|
||||||
|
self.ai_dig(k, d, cx, cy);
|
||||||
|
self.ai_gold(cx, cy);
|
||||||
|
self.ai_rooms(d, cx, cy);
|
||||||
|
self.ai_towers(d, cx, cy);
|
||||||
|
self.ai_fight(k, d, side, cx, cy);
|
||||||
|
let focus = self.rivals[k].focus;
|
||||||
|
self.ai_talents(focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the tiles an AI master may mark: within MARK_REACH tiles of its own ground
|
||||||
|
fn ai_reach_mask(&self) -> Vec<bool> {
|
||||||
|
let me = self.me;
|
||||||
|
let mut dist = vec![u8::MAX; self.map.t.len()];
|
||||||
|
let mut queue = std::collections::VecDeque::new();
|
||||||
|
for (i, t) in self.map.t.iter().enumerate() {
|
||||||
|
if t.owner == me {
|
||||||
|
dist[i] = 0;
|
||||||
|
queue.push_back(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Some(i) = queue.pop_front() {
|
||||||
|
let d = dist[i];
|
||||||
|
if d as i32 >= MARK_REACH {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
for &(dx, dy) in DIR8.iter() {
|
||||||
|
if inb(x + dx, y + dy) {
|
||||||
|
let j = idx(x + dx, y + dy);
|
||||||
|
if dist[j] > d + 1 {
|
||||||
|
dist[j] = d + 1;
|
||||||
|
queue.push_back(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dist.into_iter().map(|d| d as i32 <= MARK_REACH).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// marks a tile for this master's imps (only near its own ground)
|
||||||
|
fn ai_mark(&mut self, i: usize, mask: &[bool]) -> bool {
|
||||||
|
if !mask.get(i).copied().unwrap_or(false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let side = self.me.side().min(7);
|
||||||
|
let t = &mut self.map.t[i];
|
||||||
|
if t.marked && t.mark_by != side {
|
||||||
|
return false; // another master's plan
|
||||||
|
}
|
||||||
|
t.marked = true;
|
||||||
|
t.mark_by = side;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// enough imps for the dungeon's size
|
||||||
|
fn ai_imps(&mut self, d: usize, cx: i32, cy: i32) {
|
||||||
|
let claimed = self.map.t.iter().filter(|t| t.owner == self.me).count();
|
||||||
|
let want = (4 + claimed / 30).min(MAX_IMPS[d]);
|
||||||
|
if self.worker_count() < want && self.mana >= self.summon_cost() {
|
||||||
|
self.summon(cx as f32 + 0.5, cy as f32 + 2.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dig: rings of earth around the heart, veins first; the ring grows when it is dug out
|
||||||
|
fn ai_dig(&mut self, k: usize, d: usize, cx: i32, cy: i32) {
|
||||||
|
let me = self.me;
|
||||||
|
let side = me.side().min(7);
|
||||||
|
let r = self.rivals[k].radius.max(5);
|
||||||
|
let marked_mine = |g: &Game| {
|
||||||
|
let mut n = 0;
|
||||||
|
for y in cy - r - 4..=cy + r + 4 {
|
||||||
|
for x in cx - r - 4..=cx + r + 4 {
|
||||||
|
if g.map.get(x, y).map_or(false, |t| t.marked && t.mark_by == side) {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n
|
||||||
|
};
|
||||||
|
let open = marked_mine(self);
|
||||||
|
if open > self.worker_count() * 4 {
|
||||||
|
return; // the imps have enough to do
|
||||||
|
}
|
||||||
|
let mut added = 0;
|
||||||
|
let mask = self.ai_reach_mask();
|
||||||
|
let diggable = |t: &Tile| t.diggable() && !t.plant() && !t.reinforced && t.kind != Kind::Steel && (t.owner == Owner::None || t.owner == me);
|
||||||
|
// veins a little beyond the ring, then the ring itself
|
||||||
|
for pass in 0..2 {
|
||||||
|
let reach = if pass == 0 { r + 3 } else { r };
|
||||||
|
for y in cy - reach..=cy + reach {
|
||||||
|
for x in cx - reach..=cx + reach {
|
||||||
|
if added >= 11 || !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let i = idx(x, y);
|
||||||
|
let t = self.map.t[i];
|
||||||
|
if t.marked || !diggable(&t) || (pass == 0 && t.vein().is_none()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.ai_mark(i, &mask) {
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the ring is done: grow
|
||||||
|
if added == 0 && open == 0 && r < MAX_RADIUS[d] {
|
||||||
|
self.rivals[k].radius = r + 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gold running low: a tunnel to the nearest vein that nobody works on yet
|
||||||
|
fn ai_gold(&mut self, cx: i32, cy: i32) {
|
||||||
|
// gold for one and a half paydays and a little more: enough
|
||||||
|
if self.gold > self.next_payday_cost() * 1.5 + 1000.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let me = self.me;
|
||||||
|
// marked veins the imps can reach (next to own ground) are being worked already; one vein
|
||||||
|
// for every three imps at a time
|
||||||
|
let side = me.side().min(7);
|
||||||
|
let busy = (0..self.map.t.len())
|
||||||
|
.filter(|&i| {
|
||||||
|
let t = &self.map.t[i];
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
t.marked && t.mark_by == side && t.vein().is_some() && DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == me && n.walkable()))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
if busy >= (self.worker_count() / 3).max(1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.ai_mark_tunnel(cx, cy, |g: &Game, i: usize| {
|
||||||
|
let t = &g.map.t[i];
|
||||||
|
t.vein().is_some() && !t.marked && (t.owner == Owner::None || t.owner == me)
|
||||||
|
}, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
// marks the diggable tiles on the cheapest way from the heart to a goal tile (and the goal
|
||||||
|
// with the veins next to it); false if there is none within `reach` tiles
|
||||||
|
fn ai_mark_tunnel(&mut self, cx: i32, cy: i32, goal: impl Fn(&Game, usize) -> bool, reach: i32) -> bool {
|
||||||
|
let start = [(cx, cy + 2), (cx, cy - 2), (cx + 2, cy), (cx - 2, cy)].into_iter().find(|&(x, y)| self.map.walk(x, y));
|
||||||
|
let Some((sx, sy)) = start else { return false };
|
||||||
|
let found = {
|
||||||
|
let cost = self.map.hero_cost_as(false);
|
||||||
|
let near = |i: usize| {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
(x - cx).abs().max((y - cy).abs()) <= reach
|
||||||
|
};
|
||||||
|
self.map.search(idx(sx, sy), |i| if near(i) { cost(i) } else { None }, |i| goal(self, i), false)
|
||||||
|
};
|
||||||
|
let Some(path) = found else { return false };
|
||||||
|
let me = self.me;
|
||||||
|
let mask = self.ai_reach_mask();
|
||||||
|
let mut cluster = Vec::new();
|
||||||
|
for &i in &path {
|
||||||
|
let t = self.map.t[i];
|
||||||
|
if t.diggable() && !t.plant() && t.kind != Kind::Steel && (t.owner == Owner::None || t.owner == me || t.reinforced) && self.ai_mark(i, &mask) {
|
||||||
|
if t.vein().is_some() {
|
||||||
|
cluster.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the rest of the vein
|
||||||
|
for i in cluster {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
for &(dx, dy) in DIR8.iter() {
|
||||||
|
if let Some(t) = self.map.get(x + dx, y + dy) {
|
||||||
|
if t.vein().is_some() && t.owner == Owner::None {
|
||||||
|
self.ai_mark(idx(x + dx, y + dy), &mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// the next room of the build order, where there is free own floor for it
|
||||||
|
fn ai_rooms(&mut self, d: usize, cx: i32, cy: i32) {
|
||||||
|
for &(room, w, h, n) in BUILD.iter() {
|
||||||
|
// only what this master has researched (the same rules as the player)
|
||||||
|
if !self.ai_knows(crate::ui::Tool::Room(room)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let tiles = self.counts[room];
|
||||||
|
if tiles >= (w * h) as usize * n {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cost = ROOM_COST[room] as f32 * (w * h) as f32;
|
||||||
|
if self.gold < cost + 200.0 {
|
||||||
|
return; // saving for it
|
||||||
|
}
|
||||||
|
if let Some((x0, y0)) = self.ai_free_rect(cx, cy, w, h) {
|
||||||
|
self.build_rect(room, x0, y0, x0 + w - 1, y0 + h - 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// the base is done: a bigger treasury when it is nearly full
|
||||||
|
if self.gold > self.gold_cap() * 0.85 {
|
||||||
|
let cost = ROOM_COST[0] as f32 * 9.0;
|
||||||
|
if self.gold >= cost + 400.0 {
|
||||||
|
if let Some((x0, y0)) = self.ai_free_rect(cx, cy, 3, 3) {
|
||||||
|
self.build_rect(0, x0, y0, x0 + 2, y0 + 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// more beds and food while the army may still grow (by difficulty)
|
||||||
|
if self.counts[1] >= CREATURE_TARGET[d] || self.free_beds() > 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let room = if self.counts[2] * 10 < self.counts[1] * 6 { 2 } else { 1 };
|
||||||
|
let (w, h) = (4, 3);
|
||||||
|
let cost = ROOM_COST[room] as f32 * (w * h) as f32;
|
||||||
|
if self.gold < cost + 400.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((x0, y0)) = self.ai_free_rect(cx, cy, w, h) {
|
||||||
|
self.build_rect(room, x0, y0, x0 + w - 1, y0 + h - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// has this master researched the tool (rooms, towers)?
|
||||||
|
fn ai_knows(&self, tool: crate::ui::Tool) -> bool {
|
||||||
|
crate::ui::TOOLS.iter().position(|&t| t == tool).map_or(false, |i| self.tool_allowed(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
// a rectangle of own plain floor, as near the heart as possible (not right next to it)
|
||||||
|
fn ai_free_rect(&self, cx: i32, cy: i32, w: i32, h: i32) -> Option<(i32, i32)> {
|
||||||
|
let me = self.me;
|
||||||
|
let free = |x: i32, y: i32| self.map.get(x, y).map_or(false, |t| t.kind == Kind::Floor && t.owner == me) && !self.ents.iter().any(|e| e.alive && e.tile() == idx(x, y) && e.kind == EK::Worker && false);
|
||||||
|
let mut best: Option<((i32, i32), i32)> = None;
|
||||||
|
for y0 in cy - 22..=cy + 22 {
|
||||||
|
for x0 in cx - 22..=cx + 22 {
|
||||||
|
let (mx, my) = (x0 + w / 2, y0 + h / 2);
|
||||||
|
let dist = (mx - cx).abs() + (my - cy).abs();
|
||||||
|
if dist < 4 || best.map_or(false, |b| b.1 <= dist) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (y0..y0 + h).all(|y| (x0..x0 + w).all(|x| free(x, y))) {
|
||||||
|
best = Some(((x0, y0), dist));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|b| b.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a tower or two at the border of the dungeon (towards the open ground)
|
||||||
|
fn ai_towers(&mut self, d: usize, cx: i32, cy: i32) {
|
||||||
|
let me = self.me;
|
||||||
|
let mine = self.towers.iter().filter(|t| !t.hero && self.map.t[t.tile].owner == me).count();
|
||||||
|
if mine >= MAX_TOWERS[d] || self.gold < 1800.0 || self.counts[0] == 0 || !self.ai_knows(crate::ui::Tool::Tower(TowerKind::Arrow)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let spot = (0..self.map.t.len()).filter(|&i| {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
let t = &self.map.t[i];
|
||||||
|
t.kind == Kind::Floor && t.owner == me && (x - cx).abs().max((y - cy).abs()) >= 4
|
||||||
|
&& DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner != me && n.walkable()))
|
||||||
|
}).min_by_key(|&i| {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
(x - cx).abs() + (y - cy).abs()
|
||||||
|
});
|
||||||
|
if let Some(i) = spot {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
self.build_tower(TowerKind::Arrow, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fight: a shield at the heart; the attack flag on the nearest enemy heart once strong enough,
|
||||||
|
// taken back after heavy losses. A co-op partner follows the attack flag of his allies.
|
||||||
|
fn ai_fight(&mut self, k: usize, d: usize, side: u8, cx: i32, cy: i32) {
|
||||||
|
if self.guard_marks.is_empty() && self.creature_count() >= 3 && d >= 1 {
|
||||||
|
self.place_mark(cx + 1, cy + 3, false);
|
||||||
|
}
|
||||||
|
let army = self.side_army(side);
|
||||||
|
// heroes or enemy units in the dungeon: everybody goes for them (the rally flag) and the
|
||||||
|
// master casts lightning on them and heals his hurt creatures
|
||||||
|
if !self.is_partner(side) && self.ai_defend(k) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// co-op: go where the allies go
|
||||||
|
if self.is_partner(side) {
|
||||||
|
let lead = self.master_sides().into_iter().find(|&s| s != side && !self.is_out(s) && self.allied(side, s));
|
||||||
|
let flag = lead.and_then(|s| self.masters.iter().find(|m| m.side == s).and_then(|m| m.flag));
|
||||||
|
if flag != self.flag {
|
||||||
|
self.flag = flag;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.rivals[k].attacking {
|
||||||
|
// too many lost, or the target is gone: back home
|
||||||
|
let target_gone = self.flag.map_or(true, |f| !self.ai_enemy_heart_near(side, f));
|
||||||
|
if army < self.rivals[k].army0 * 0.45 || target_gone {
|
||||||
|
self.rivals[k].attacking = false;
|
||||||
|
self.flag = None;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.time < FIRST_ATTACK[d] || self.creature_count() < 4 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// the nearest enemy heart (players, other AI masters)
|
||||||
|
let target = self
|
||||||
|
.master_sides()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|&s| s != side && !self.is_out(s) && !self.allied(side, s) && self.core_hp_of(s) > 0.0)
|
||||||
|
.min_by_key(|&s| {
|
||||||
|
let (x, y) = self.core_of_side(s);
|
||||||
|
(x - cx).abs() + (y - cy).abs()
|
||||||
|
});
|
||||||
|
let Some(t) = target else { return };
|
||||||
|
if army < self.side_army(t) * BOLDNESS[d] {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (tx, ty) = self.core_of_side(t);
|
||||||
|
let spot = DIR8.iter().map(|&(dx, dy)| (tx + 1 + dx * 2, ty + 1 + dy * 2)).find(|&(x, y)| self.map.walk(x, y));
|
||||||
|
// creatures only walk: first a tunnel to the enemy (the imps dig it)
|
||||||
|
let from = [(cx, cy + 2), (cx, cy - 2), (cx + 2, cy), (cx - 2, cy)].into_iter().find(|&(x, y)| self.map.walk(x, y));
|
||||||
|
let open = match (from, spot) {
|
||||||
|
(Some((fx, fy)), Some((x, y))) => self.map.creature_path(false, idx(fx, fy), idx(x, y)).is_some(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !open {
|
||||||
|
let target_owner = Owner::of_side(t);
|
||||||
|
if !self.map.t.iter().any(|tl| tl.marked && tl.reinforced) {
|
||||||
|
self.ai_mark_tunnel(cx, cy, |g: &Game, i: usize| {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
g.map.t[i].owner == target_owner && g.map.t[i].walkable() || ((x - tx - 1).abs() <= 2 && (y - ty - 1).abs() <= 2 && g.map.t[i].walkable())
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((x, y)) = spot {
|
||||||
|
self.flag = Some(idx(x, y));
|
||||||
|
self.rivals[k].attacking = true;
|
||||||
|
self.rivals[k].army0 = army;
|
||||||
|
if t == self.local_side {
|
||||||
|
let key = match side {
|
||||||
|
2 => "msg.rival1_attack",
|
||||||
|
3 => "msg.rival2_attack",
|
||||||
|
_ => "msg.rival3_attack",
|
||||||
|
};
|
||||||
|
let n = self.creature_count().to_string();
|
||||||
|
self.tell_local(key, vec![n], Some(Sfx::WaveAlarm));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the defence of the dungeon; true while there are intruders
|
||||||
|
fn ai_defend(&mut self, k: usize) -> bool {
|
||||||
|
let me = self.me;
|
||||||
|
let intruders: Vec<(f32, f32)> = self
|
||||||
|
.ents
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.alive && !e.held && e.captive == 0 && self.is_hostile(e) && self.map.get(e.x as i32, e.y as i32).map_or(false, |t| t.owner == me))
|
||||||
|
.map(|e| (e.x, e.y))
|
||||||
|
.collect();
|
||||||
|
if intruders.is_empty() {
|
||||||
|
if self.rivals[k].defending {
|
||||||
|
self.rivals[k].defending = false;
|
||||||
|
self.flag = None;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// the middle of the intruders, on walkable ground
|
||||||
|
let n = intruders.len() as f32;
|
||||||
|
let (mx, my) = intruders.iter().fold((0.0, 0.0), |a, p| (a.0 + p.0 / n, a.1 + p.1 / n));
|
||||||
|
let near = intruders.iter().min_by(|a, b| ((a.0 - mx).powi(2) + (a.1 - my).powi(2)).partial_cmp(&((b.0 - mx).powi(2) + (b.1 - my).powi(2))).unwrap()).copied().unwrap();
|
||||||
|
let tile = idx(near.0 as i32, near.1 as i32);
|
||||||
|
if self.rivals[k].attacking {
|
||||||
|
self.rivals[k].attacking = false; // home first
|
||||||
|
}
|
||||||
|
self.rivals[k].defending = true;
|
||||||
|
if self.map.t[tile].walkable() && self.flag != Some(tile) {
|
||||||
|
self.flag = Some(tile);
|
||||||
|
}
|
||||||
|
// lightning on the thickest of them, healing for the hurt
|
||||||
|
let bolt = 50.0 * self.talents.spell_cost();
|
||||||
|
if self.mana >= bolt + 40.0 && self.known(crate::game::LIGHTNING_TOOL) {
|
||||||
|
self.lightning(near.0, near.1);
|
||||||
|
}
|
||||||
|
let heal = 60.0 * self.talents.spell_cost();
|
||||||
|
let hurt = self.ents.iter().filter(|e| e.alive && e.creature() && e.hp < e.max_hp * 0.5 && (e.x - near.0).powi(2) + (e.y - near.1).powi(2) < 36.0).map(|e| (e.x, e.y)).next();
|
||||||
|
if let Some((hx, hy)) = hurt {
|
||||||
|
if self.mana >= heal + 40.0 && self.known(crate::game::HEAL_TOOL) {
|
||||||
|
self.heal(hx, hy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// is an enemy heart near tile f?
|
||||||
|
fn ai_enemy_heart_near(&self, side: u8, f: usize) -> bool {
|
||||||
|
let (fx, fy) = xy(f);
|
||||||
|
self.master_sides().into_iter().any(|s| {
|
||||||
|
s != side && !self.is_out(s) && self.core_hp_of(s) > 0.0 && {
|
||||||
|
let (x, y) = self.core_of_side(s);
|
||||||
|
(x + 1 - fx).abs() <= 4 && (y + 1 - fy).abs() <= 4
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Creature equipment: when no trap or door is ordered, the forge makes armour and weapons in
|
||||||
|
// three tiers - iron, steel (once steel walls are known) and rune (once the magic door, the
|
||||||
|
// last discovery of the library, is known). Every finished piece goes to the strongest creature without anything that good yet:
|
||||||
|
// more health and harder blows. The tier shows as a little coloured mark at the health bar.
|
||||||
|
|
||||||
|
use crate::game::*;
|
||||||
|
|
||||||
|
// tier 1..3: forge work, gold, health and attack factor
|
||||||
|
pub const GEAR_WORK: [f32; 3] = [40.0, 70.0, 110.0];
|
||||||
|
pub const GEAR_GOLD: [f32; 3] = [60.0, 140.0, 280.0];
|
||||||
|
pub const GEAR_FACTOR: [f32; 4] = [1.0, 1.1, 1.22, 1.38];
|
||||||
|
|
||||||
|
pub fn gear_factor(gear: u8) -> f32 {
|
||||||
|
GEAR_FACTOR[(gear as usize).min(3)]
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
// the best tier the forge can make now (1..3)
|
||||||
|
pub fn gear_tier(&self) -> u8 {
|
||||||
|
if self.known(31) {
|
||||||
|
3
|
||||||
|
} else if self.known(19) {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// is a creature waiting for a piece of the best tier (and none in stock)?
|
||||||
|
pub fn gear_wanted(&self) -> bool {
|
||||||
|
let tier = self.gear_tier();
|
||||||
|
self.gear_stock.get(tier as usize - 1).copied().unwrap_or(0) == 0 && self.ents.iter().any(|e| e.alive && e.creature() && e.gear < tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forge work without an order: the next piece of equipment
|
||||||
|
pub(crate) fn forge_gear(&mut self, work: f32) {
|
||||||
|
if !self.gear_wanted() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let tier = self.gear_tier();
|
||||||
|
let k = tier as usize - 1;
|
||||||
|
self.gear_work += work;
|
||||||
|
if self.gear_work >= GEAR_WORK[k] && self.gold >= GEAR_GOLD[k] {
|
||||||
|
self.gear_work -= GEAR_WORK[k];
|
||||||
|
self.gold -= GEAR_GOLD[k];
|
||||||
|
if self.gear_stock.len() < 3 {
|
||||||
|
self.gear_stock.resize(3, 0);
|
||||||
|
}
|
||||||
|
self.gear_stock[k] += 1;
|
||||||
|
}
|
||||||
|
self.equip_from_stock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// finished pieces go to the strongest creatures that have nothing that good
|
||||||
|
pub(crate) fn equip_from_stock(&mut self) {
|
||||||
|
for k in (0..self.gear_stock.len().min(3)).rev() {
|
||||||
|
while self.gear_stock[k] > 0 {
|
||||||
|
let tier = k as u8 + 1;
|
||||||
|
let best = (0..self.ents.len())
|
||||||
|
.filter(|&j| {
|
||||||
|
let e = &self.ents[j];
|
||||||
|
e.alive && e.creature() && e.gear < tier
|
||||||
|
})
|
||||||
|
.max_by(|&a, &b| {
|
||||||
|
let (ea, eb) = (&self.ents[a], &self.ents[b]);
|
||||||
|
(ea.level, ea.max_hp as i32).cmp(&(eb.level, eb.max_hp as i32))
|
||||||
|
});
|
||||||
|
let Some(j) = best else { break };
|
||||||
|
self.gear_stock[k] -= 1;
|
||||||
|
let e = &mut self.ents[j];
|
||||||
|
let f = gear_factor(tier) / gear_factor(e.gear);
|
||||||
|
e.max_hp *= f;
|
||||||
|
e.hp *= f;
|
||||||
|
e.gear = tier;
|
||||||
|
let (x, y, kind) = (e.x, e.y, e.kind);
|
||||||
|
self.fx(7, x, y, 0.0, 0.0, 0.6, 0xff40c0ff);
|
||||||
|
self.msg("msg.gear_given", vec![format!("@gear.{}", tier), format!("@{}", st(kind).name)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_forge_equips_the_strongest_creature() {
|
||||||
|
let mut g = Game::new(4, 64, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let weak = g.spawn(EK::Kobold, false, cx as f32 + 4.5, cy as f32 + 1.5, 1);
|
||||||
|
let strong = g.spawn(EK::Ogre, false, cx as f32 + 5.5, cy as f32 + 1.5, 5);
|
||||||
|
g.gold = 1000.0;
|
||||||
|
assert!(g.gear_wanted());
|
||||||
|
let hp0 = g.ents.iter().find(|e| e.id == strong).unwrap().max_hp;
|
||||||
|
g.forge_gear(GEAR_WORK[0] + 1.0);
|
||||||
|
let s = g.ents.iter().find(|e| e.id == strong).unwrap();
|
||||||
|
let w = g.ents.iter().find(|e| e.id == weak).unwrap();
|
||||||
|
assert_eq!(s.gear, 1, "the ogre got the first piece");
|
||||||
|
assert_eq!(w.gear, 0);
|
||||||
|
assert!(s.max_hp > hp0 && s.atk() > st(EK::Ogre).atk * (1.0 + 0.12 * 4.0));
|
||||||
|
assert!(g.gold < 1000.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Audio engine: music (XM players with crossfades) and synthesised sound effects.
|
// Audio engine: music (XM players with crossfades) and synthesised sound effects.
|
||||||
// The browser pulls samples through dc_audio_render() from our JS plugin (www/dc_plugin.js),
|
// The browser pulls samples through dc_audio_render() from our JS plugin (www/dc_plugin.js),
|
||||||
// which starts the AudioContext on the first user interaction.
|
// which starts the AudioContext on the first user interaction. Desktop builds play through
|
||||||
|
// the system's sound output (module `native` below).
|
||||||
|
|
||||||
use crate::sfx;
|
use crate::sfx;
|
||||||
use crate::tracks::TRACKS;
|
use crate::tracks::TRACKS;
|
||||||
@@ -21,6 +22,8 @@ pub enum Category {
|
|||||||
Temple,
|
Temple,
|
||||||
Victory,
|
Victory,
|
||||||
Defeat,
|
Defeat,
|
||||||
|
Surface, // above ground: flute, harp, birdsong
|
||||||
|
SurfaceBattle, // fighting above ground
|
||||||
}
|
}
|
||||||
|
|
||||||
// indices of all tracks of a category, in play order
|
// indices of all tracks of a category, in play order
|
||||||
@@ -28,6 +31,36 @@ pub fn tracks_in(c: Category) -> Vec<usize> {
|
|||||||
TRACKS.iter().enumerate().filter(|(_, t)| t.1 == c).map(|(i, _)| i).collect()
|
TRACKS.iter().enumerate().filter(|(_, t)| t.1 == c).map(|(i, _)| i).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the calm tracks the music player offers (no battle, victory or defeat music)
|
||||||
|
pub fn player_tracks() -> Vec<usize> {
|
||||||
|
use Category as C;
|
||||||
|
TRACKS.iter().enumerate().filter(|(_, t)| matches!(t.1, C::Title | C::Build | C::Surface | C::Torture | C::Temple)).map(|(i, _)| i).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_id(t: usize) -> &'static str {
|
||||||
|
TRACKS.get(t).map_or("", |t| t.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_by_id(id: &str) -> Option<usize> {
|
||||||
|
if id.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
TRACKS.iter().position(|t| t.0 == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the song title stored in the XM module (without the "DungeonCraft - " prefix)
|
||||||
|
pub fn track_title(t: usize) -> String {
|
||||||
|
let Some(d) = TRACKS.get(t).map(|t| t.2) else { return String::new() };
|
||||||
|
let raw = d.get(17..37).unwrap_or(&[]);
|
||||||
|
let name: String = raw.iter().take_while(|&&b| b != 0).map(|&b| b as char).collect();
|
||||||
|
let name = name.trim().trim_start_matches("DungeonCraft - ").trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
track_id(t).to_string()
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MUSIC_GAIN: f32 = 0.45; // our replayer is ~4x louder than libopenmpt; leaves headroom for effects
|
const MUSIC_GAIN: f32 = 0.45; // our replayer is ~4x louder than libopenmpt; leaves headroom for effects
|
||||||
const SFX_GAIN: f32 = 0.55;
|
const SFX_GAIN: f32 = 0.55;
|
||||||
|
|
||||||
@@ -212,16 +245,135 @@ pub extern "C" fn dc_audio_render(frames: u32) -> *const f32 {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ desktop sound output
|
||||||
|
|
||||||
|
// The game renders a little ahead into a queue each frame; the sound card's thread takes from it.
|
||||||
|
// The engine itself stays on the game thread, like in the browser.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod native {
|
||||||
|
use super::STATE;
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
// seconds rendered ahead: covers a slow frame or two
|
||||||
|
const AHEAD: f32 = 0.07;
|
||||||
|
|
||||||
|
struct Out {
|
||||||
|
_stream: cpal::Stream,
|
||||||
|
queue: Arc<Mutex<VecDeque<f32>>>, // interleaved stereo
|
||||||
|
rate: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static OUT: RefCell<Option<Out>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// opens the default output device and starts the engine; without one the game stays silent
|
||||||
|
pub fn start() {
|
||||||
|
match open() {
|
||||||
|
Ok(out) => {
|
||||||
|
super::dc_audio_init(out.rate);
|
||||||
|
OUT.with(|o| *o.borrow_mut() = Some(out));
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("DungeonCraft: no sound ({})", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open() -> Result<Out, String> {
|
||||||
|
let dev = cpal::default_host().default_output_device().ok_or("no output device")?;
|
||||||
|
let cfg = dev.default_output_config().map_err(|e| e.to_string())?;
|
||||||
|
let rate = cfg.sample_rate() as f32;
|
||||||
|
let channels = cfg.channels() as usize;
|
||||||
|
let queue = Arc::new(Mutex::new(VecDeque::new()));
|
||||||
|
let sc: cpal::StreamConfig = cfg.config();
|
||||||
|
use cpal::SampleFormat as F;
|
||||||
|
let stream = match cfg.sample_format() {
|
||||||
|
F::F32 => build::<f32>(&dev, &sc, channels, queue.clone()),
|
||||||
|
F::I16 => build::<i16>(&dev, &sc, channels, queue.clone()),
|
||||||
|
F::U16 => build::<u16>(&dev, &sc, channels, queue.clone()),
|
||||||
|
F::I32 => build::<i32>(&dev, &sc, channels, queue.clone()),
|
||||||
|
f => Err(format!("sample format {:?}", f)),
|
||||||
|
}?;
|
||||||
|
stream.play().map_err(|e| e.to_string())?;
|
||||||
|
Ok(Out { _stream: stream, queue, rate })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build<T: cpal::SizedSample + cpal::FromSample<f32>>(dev: &cpal::Device, sc: &cpal::StreamConfig, channels: usize, queue: Arc<Mutex<VecDeque<f32>>>) -> Result<cpal::Stream, String> {
|
||||||
|
let channels = channels.max(1);
|
||||||
|
dev.build_output_stream(
|
||||||
|
sc.clone(),
|
||||||
|
move |out: &mut [T], _: &cpal::OutputCallbackInfo| {
|
||||||
|
let mut q = queue.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
for frame in out.chunks_mut(channels) {
|
||||||
|
// an empty queue (a stalled frame) plays silence
|
||||||
|
let (l, r) = if q.len() >= 2 { (q.pop_front().unwrap_or(0.0), q.pop_front().unwrap_or(0.0)) } else { (0.0, 0.0) };
|
||||||
|
for (c, s) in frame.iter_mut().enumerate() {
|
||||||
|
let v = match (channels, c) {
|
||||||
|
(1, _) => (l + r) * 0.5,
|
||||||
|
(_, 0) => l,
|
||||||
|
(_, 1) => r,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
*s = T::from_sample(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|e| eprintln!("DungeonCraft: sound output: {}", e),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// called once per frame: tops the queue up to AHEAD seconds
|
||||||
|
pub fn pump() {
|
||||||
|
OUT.with(|o| {
|
||||||
|
let o = o.borrow();
|
||||||
|
let Some(o) = o.as_ref() else { return };
|
||||||
|
let want = (o.rate * AHEAD) as usize;
|
||||||
|
let have = o.queue.lock().unwrap_or_else(|e| e.into_inner()).len() / 2;
|
||||||
|
if have >= want {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
STATE.with(|s| {
|
||||||
|
let mut s = s.borrow_mut();
|
||||||
|
let (mv, sv, muted) = (s.music_vol, s.sfx_vol, s.muted);
|
||||||
|
if let Some(e) = s.engine.as_mut() {
|
||||||
|
e.render(want - have, mv, sv, muted);
|
||||||
|
o.queue.lock().unwrap_or_else(|e| e.into_inner()).extend(e.buf.iter().copied());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_music_player_offers_only_calm_tracks_with_titles() {
|
||||||
|
let l = player_tracks();
|
||||||
|
assert!(l.len() > 20);
|
||||||
|
for t in l {
|
||||||
|
let id = track_id(t);
|
||||||
|
assert!(!id.starts_with("battle") && !id.starts_with("victory") && id != "defeat" && !id.starts_with("assault"), "{}", id);
|
||||||
|
assert!(!track_title(t).is_empty());
|
||||||
|
assert_eq!(track_by_id(id), Some(t));
|
||||||
|
}
|
||||||
|
assert_eq!(track_by_id(""), None);
|
||||||
|
assert_eq!(track_title(track_by_id("build13").unwrap()), "Underground River");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn engine_crossfade_and_sfx() {
|
fn engine_crossfade_and_sfx() {
|
||||||
dc_audio_init(48000.0);
|
dc_audio_init(48000.0);
|
||||||
let build = tracks_in(Category::Build);
|
let build = tracks_in(Category::Build);
|
||||||
let battle = tracks_in(Category::Battle);
|
let battle = tracks_in(Category::Battle);
|
||||||
assert_eq!(build.len(), 15);
|
assert_eq!(build.len(), 19);
|
||||||
|
assert_eq!(tracks_in(Category::Surface).len(), 4);
|
||||||
|
assert_eq!(tracks_in(Category::SurfaceBattle).len(), 2);
|
||||||
play_music(build[0], 0.0);
|
play_music(build[0], 0.0);
|
||||||
let mut peak = 0.0f32;
|
let mut peak = 0.0f32;
|
||||||
for k in 0..200 {
|
for k in 0..200 {
|
||||||
|
|||||||
@@ -0,0 +1,929 @@
|
|||||||
|
// Achievement badges: one 32x32 pixel-art symbol per achievement, generated at start into a
|
||||||
|
// texture of its own (the main atlas is full). Each symbol is painted from simple shapes in
|
||||||
|
// materials (steel, gold, wood, ...); every material has a ramp of four shades, and the shapes
|
||||||
|
// are bevelled automatically (lit from the top left) and get a dark outline.
|
||||||
|
|
||||||
|
use macroquad::prelude::*;
|
||||||
|
|
||||||
|
pub const SIZE: u32 = 32;
|
||||||
|
const COLS: u32 = 8;
|
||||||
|
|
||||||
|
// badge indices (the icon field of achievements::TIERS / SPECIAL)
|
||||||
|
pub const HEROES: u32 = 0;
|
||||||
|
pub const BOSSES: u32 = 1;
|
||||||
|
pub const DUG: u32 = 2;
|
||||||
|
pub const GOLD: u32 = 3;
|
||||||
|
pub const WOOD: u32 = 4;
|
||||||
|
pub const WALLS: u32 = 5;
|
||||||
|
pub const ROOMS: u32 = 6;
|
||||||
|
pub const SPELLS: u32 = 7;
|
||||||
|
pub const SLAPS: u32 = 8;
|
||||||
|
pub const TRAPS: u32 = 9;
|
||||||
|
pub const TOWERS: u32 = 10;
|
||||||
|
pub const DROWNED: u32 = 11;
|
||||||
|
pub const BURNT: u32 = 12;
|
||||||
|
pub const SIEGE: u32 = 13;
|
||||||
|
pub const CAPTURED: u32 = 14;
|
||||||
|
pub const DRAWBRIDGE: u32 = 15;
|
||||||
|
pub const WINS: u32 = 16;
|
||||||
|
pub const MISSIONS: u32 = 17;
|
||||||
|
pub const COOP: u32 = 18;
|
||||||
|
pub const MINUTES: u32 = 19;
|
||||||
|
pub const FLAWLESS: u32 = 20;
|
||||||
|
pub const SPEEDRUN: u32 = 21;
|
||||||
|
pub const CAMPAIGN: u32 = 22;
|
||||||
|
pub const SURFACE: u32 = 23;
|
||||||
|
pub const DEFEAT: u32 = 24;
|
||||||
|
pub const LEGEND: u32 = 25;
|
||||||
|
pub const WYRM: u32 = 26;
|
||||||
|
pub const FULLHAND: u32 = 27;
|
||||||
|
pub const COUNT: u32 = 28;
|
||||||
|
|
||||||
|
// the source rectangle of a badge in the badge texture
|
||||||
|
pub fn badge_rect(i: u32) -> Rect {
|
||||||
|
Rect::new(((i % COLS) * SIZE) as f32, ((i / COLS) * SIZE) as f32, SIZE as f32, SIZE as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ materials
|
||||||
|
|
||||||
|
const EMPTY: u8 = 0;
|
||||||
|
const STEEL: u8 = 1;
|
||||||
|
const GOLDM: u8 = 2;
|
||||||
|
const WOODM: u8 = 3;
|
||||||
|
const STONE: u8 = 4;
|
||||||
|
const RED: u8 = 5;
|
||||||
|
const FIRE: u8 = 6;
|
||||||
|
const WATER: u8 = 7;
|
||||||
|
const CRYSTAL: u8 = 8;
|
||||||
|
const GREEN: u8 = 9;
|
||||||
|
const BONE: u8 = 10;
|
||||||
|
const PURPLE: u8 = 11;
|
||||||
|
const IRON: u8 = 12; // dark iron, also slits and holes
|
||||||
|
const BRICK: u8 = 13;
|
||||||
|
const SKIN: u8 = 14; // imp green (the keeper's hand)
|
||||||
|
const DKWOOD: u8 = 15;
|
||||||
|
const GLASS: u8 = 16;
|
||||||
|
const CYAN: u8 = 17; // magic glow
|
||||||
|
const SUN: u8 = 18;
|
||||||
|
const BLUE: u8 = 19;
|
||||||
|
const SPARK: u8 = 20; // white sparkles and motion lines, no outline
|
||||||
|
const EMBER: u8 = 21; // floating embers and steam, no outline
|
||||||
|
const TEA: u8 = 22;
|
||||||
|
|
||||||
|
fn ramp(m: u8) -> [(u8, u8, u8); 4] {
|
||||||
|
match m {
|
||||||
|
STEEL => [(66, 72, 90), (116, 124, 144), (166, 174, 190), (222, 228, 238)],
|
||||||
|
GOLDM => [(124, 76, 22), (196, 138, 38), (238, 192, 70), (255, 240, 150)],
|
||||||
|
WOODM => [(78, 46, 26), (120, 76, 42), (156, 106, 62), (196, 146, 92)],
|
||||||
|
STONE => [(58, 54, 64), (90, 84, 96), (124, 118, 130), (160, 154, 166)],
|
||||||
|
RED => [(96, 18, 24), (156, 34, 36), (204, 62, 50), (240, 122, 92)],
|
||||||
|
FIRE => [(196, 54, 18), (240, 124, 30), (255, 192, 60), (255, 244, 170)],
|
||||||
|
WATER => [(22, 44, 96), (38, 84, 148), (66, 134, 198), (150, 200, 240)],
|
||||||
|
CRYSTAL => [(60, 34, 120), (110, 66, 196), (164, 120, 244), (226, 206, 255)],
|
||||||
|
GREEN => [(34, 66, 30), (58, 108, 42), (98, 150, 58), (152, 202, 92)],
|
||||||
|
BONE => [(120, 104, 84), (178, 160, 128), (216, 202, 170), (244, 236, 214)],
|
||||||
|
PURPLE => [(50, 26, 60), (88, 42, 100), (128, 68, 140), (176, 110, 182)],
|
||||||
|
IRON => [(22, 18, 24), (40, 34, 42), (62, 54, 64), (88, 80, 90)],
|
||||||
|
BRICK => [(88, 36, 30), (134, 60, 44), (170, 88, 62), (204, 126, 90)],
|
||||||
|
SKIN => [(56, 84, 44), (98, 138, 72), (140, 184, 102), (184, 222, 140)],
|
||||||
|
DKWOOD => [(50, 30, 20), (78, 48, 30), (104, 68, 42), (134, 94, 60)],
|
||||||
|
GLASS => [(92, 128, 150), (136, 176, 196), (180, 214, 228), (232, 246, 252)],
|
||||||
|
CYAN => [(30, 110, 150), (60, 180, 220), (130, 230, 250), (220, 255, 255)],
|
||||||
|
SUN => [(220, 120, 30), (250, 180, 50), (255, 222, 90), (255, 250, 200)],
|
||||||
|
BLUE => [(24, 30, 80), (40, 52, 128), (66, 84, 172), (110, 132, 214)],
|
||||||
|
SPARK => [(200, 200, 210), (236, 236, 244), (255, 255, 255), (255, 255, 255)],
|
||||||
|
EMBER => [(200, 80, 30), (255, 150, 50), (255, 210, 110), (255, 250, 220)],
|
||||||
|
TEA => [(70, 36, 20), (104, 58, 30), (134, 82, 44), (170, 112, 66)],
|
||||||
|
_ => [(0, 0, 0); 4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn outlined(m: u8) -> bool {
|
||||||
|
m != EMPTY && m != SPARK && m != EMBER
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTLINE: (u8, u8, u8) = (18, 12, 16);
|
||||||
|
const AUTO: u8 = 255;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct P {
|
||||||
|
m: u8,
|
||||||
|
s: u8, // shade 0..3, or AUTO (bevelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn a(m: u8) -> P {
|
||||||
|
P { m, s: AUTO }
|
||||||
|
}
|
||||||
|
fn f(m: u8, s: u8) -> P {
|
||||||
|
P { m, s }
|
||||||
|
}
|
||||||
|
const CUT: P = P { m: EMPTY, s: AUTO };
|
||||||
|
|
||||||
|
// one badge being painted
|
||||||
|
struct Pad {
|
||||||
|
m: [u8; 1024],
|
||||||
|
s: [u8; 1024],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pad {
|
||||||
|
fn new() -> Pad {
|
||||||
|
Pad { m: [EMPTY; 1024], s: [AUTO; 1024] }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn px(&mut self, x: i32, y: i32, p: P) {
|
||||||
|
if (0..32).contains(&x) && (0..32).contains(&y) {
|
||||||
|
let i = (y * 32 + x) as usize;
|
||||||
|
self.m[i] = p.m;
|
||||||
|
self.s[i] = p.s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, x: i32, y: i32) -> u8 {
|
||||||
|
if (0..32).contains(&x) && (0..32).contains(&y) {
|
||||||
|
self.m[(y * 32 + x) as usize]
|
||||||
|
} else {
|
||||||
|
EMPTY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// every pixel whose centre passes the test
|
||||||
|
fn fill(&mut self, p: P, test: impl Fn(f32, f32) -> bool) {
|
||||||
|
for y in 0..32 {
|
||||||
|
for x in 0..32 {
|
||||||
|
if test(x as f32 + 0.5, y as f32 + 0.5) {
|
||||||
|
self.px(x, y, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inclusive pixel rectangle
|
||||||
|
fn rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, p: P) {
|
||||||
|
for y in y0..=y1 {
|
||||||
|
for x in x0..=x1 {
|
||||||
|
self.px(x, y, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disc(&mut self, cx: f32, cy: f32, r: f32, p: P) {
|
||||||
|
self.fill(p, |x, y| (x - cx).powi(2) + (y - cy).powi(2) <= r * r);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ring(&mut self, cx: f32, cy: f32, r0: f32, r1: f32, p: P) {
|
||||||
|
self.fill(p, |x, y| {
|
||||||
|
let d = (x - cx).powi(2) + (y - cy).powi(2);
|
||||||
|
d <= r1 * r1 && d >= r0 * r0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ellipse turned by `ang` (radians)
|
||||||
|
fn oval(&mut self, cx: f32, cy: f32, rx: f32, ry: f32, ang: f32, p: P) {
|
||||||
|
let (s, c) = ang.sin_cos();
|
||||||
|
self.fill(p, |x, y| {
|
||||||
|
let (dx, dy) = (x - cx, y - cy);
|
||||||
|
let u = dx * c + dy * s;
|
||||||
|
let v = -dx * s + dy * c;
|
||||||
|
(u / rx).powi(2) + (v / ry).powi(2) <= 1.0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// a thick line (round ends)
|
||||||
|
fn line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, w: f32, p: P) {
|
||||||
|
let (dx, dy) = (x1 - x0, y1 - y0);
|
||||||
|
let len2 = (dx * dx + dy * dy).max(1e-6);
|
||||||
|
let r = w * 0.5;
|
||||||
|
self.fill(p, |x, y| {
|
||||||
|
let t = (((x - x0) * dx + (y - y0) * dy) / len2).clamp(0.0, 1.0);
|
||||||
|
let (qx, qy) = (x0 + t * dx - x, y0 + t * dy - y);
|
||||||
|
qx * qx + qy * qy <= r * r
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// a line through several points
|
||||||
|
fn path(&mut self, pts: &[(f32, f32)], w: f32, p: P) {
|
||||||
|
for s in pts.windows(2) {
|
||||||
|
self.line(s[0].0, s[0].1, s[1].0, s[1].1, w, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poly(&mut self, pts: &[(f32, f32)], p: P) {
|
||||||
|
self.fill(p, |x, y| {
|
||||||
|
let mut inside = false;
|
||||||
|
let n = pts.len();
|
||||||
|
let mut j = n - 1;
|
||||||
|
for i in 0..n {
|
||||||
|
let (xi, yi) = pts[i];
|
||||||
|
let (xj, yj) = pts[j];
|
||||||
|
if (yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi {
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
j = i;
|
||||||
|
}
|
||||||
|
inside
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// a five-pointed star
|
||||||
|
fn star(&mut self, cx: f32, cy: f32, r0: f32, r1: f32, p: P) {
|
||||||
|
let pts: Vec<(f32, f32)> = (0..10)
|
||||||
|
.map(|k| {
|
||||||
|
let ang = -std::f32::consts::FRAC_PI_2 + k as f32 * std::f32::consts::PI / 5.0;
|
||||||
|
let r = if k % 2 == 0 { r1 } else { r0 };
|
||||||
|
(cx + r * ang.cos(), cy + r * ang.sin())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
self.poly(&pts, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a small four-pointed sparkle
|
||||||
|
fn sparkle(&mut self, x: i32, y: i32) {
|
||||||
|
let p = f(SPARK, 2);
|
||||||
|
self.px(x, y, p);
|
||||||
|
for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
|
||||||
|
self.px(x + dx, y + dy, f(SPARK, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bevel, outline and write into the image at cell `i`
|
||||||
|
fn blit(&self, img: &mut Image, i: u32) {
|
||||||
|
let (ox, oy) = ((i % COLS) * SIZE, (i / COLS) * SIZE);
|
||||||
|
for y in 0..32i32 {
|
||||||
|
for x in 0..32i32 {
|
||||||
|
let m = self.get(x, y);
|
||||||
|
let col = if m == EMPTY {
|
||||||
|
let near = [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| outlined(self.get(x + dx, y + dy)));
|
||||||
|
if !near {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
OUTLINE
|
||||||
|
} else {
|
||||||
|
let mut s = self.s[(y * 32 + x) as usize];
|
||||||
|
if s == AUTO {
|
||||||
|
let lit = self.get(x - 1, y) != m || self.get(x, y - 1) != m;
|
||||||
|
let dark = self.get(x + 1, y) != m || self.get(x, y + 1) != m;
|
||||||
|
s = match (lit, dark) {
|
||||||
|
(true, false) => 2,
|
||||||
|
(false, true) => 0,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
// the top-left corner catches the light
|
||||||
|
if lit && !dark && self.get(x - 1, y) != m && self.get(x, y - 1) != m {
|
||||||
|
s = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ramp(m)[s.min(3) as usize]
|
||||||
|
};
|
||||||
|
img.set_pixel(ox + x as u32, oy + y as u32, Color::from_rgba(col.0, col.1, col.2, 255));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ the symbols
|
||||||
|
|
||||||
|
fn heroes(p: &mut Pad) {
|
||||||
|
// a knight's great helm, split by a blow
|
||||||
|
p.poly(&[(16.0, 1.5), (21.0, 4.0), (19.0, 7.0), (16.0, 6.0)], a(RED)); // plume
|
||||||
|
p.disc(16.0, 13.0, 8.5, a(STEEL));
|
||||||
|
p.rect(8, 13, 24, 25, a(STEEL));
|
||||||
|
p.rect(9, 14, 23, 15, f(IRON, 0)); // visor slit
|
||||||
|
p.line(16.0, 6.0, 16.0, 25.0, 1.0, f(STEEL, 0)); // ridge
|
||||||
|
for (x, y) in [(11, 19), (13, 21), (11, 22), (20, 19), (22, 21)] {
|
||||||
|
p.px(x, y, f(IRON, 1));
|
||||||
|
}
|
||||||
|
// the crack and the broken-off piece
|
||||||
|
p.poly(&[(19.0, 3.0), (26.0, 3.0), (26.0, 12.0), (21.0, 9.0)], CUT);
|
||||||
|
p.path(&[(21.0, 9.0), (18.0, 13.0), (20.0, 17.0), (17.5, 22.0)], 1.0, f(IRON, 0));
|
||||||
|
p.poly(&[(23.0, 26.0), (28.0, 24.0), (29.5, 29.5), (24.0, 29.5)], a(STEEL));
|
||||||
|
p.line(24.0, 26.5, 28.0, 25.0, 0.8, f(STEEL, 3));
|
||||||
|
p.px(27, 7, f(STEEL, 2));
|
||||||
|
p.px(29, 11, f(STEEL, 1));
|
||||||
|
p.sparkle(28, 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bosses(p: &mut Pad) {
|
||||||
|
// a skull wearing a cracked crown
|
||||||
|
p.disc(16.0, 19.0, 9.0, a(BONE));
|
||||||
|
p.rect(11, 24, 21, 29, a(BONE));
|
||||||
|
p.disc(12.0, 19.0, 2.6, f(IRON, 0));
|
||||||
|
p.disc(20.0, 19.0, 2.6, f(IRON, 0));
|
||||||
|
p.px(12, 19, f(RED, 3));
|
||||||
|
p.px(20, 19, f(RED, 3));
|
||||||
|
p.poly(&[(16.0, 21.5), (14.5, 24.5), (17.5, 24.5)], f(IRON, 0));
|
||||||
|
for x in [12, 14, 16, 18, 20] {
|
||||||
|
p.line(x as f32 + 0.5, 26.0, x as f32 + 0.5, 29.0, 0.8, f(BONE, 0));
|
||||||
|
}
|
||||||
|
p.poly(&[(6.0, 13.0), (6.0, 3.0), (10.5, 8.0), (16.0, 1.0), (21.5, 8.0), (26.0, 3.0), (26.0, 13.0)], a(GOLDM));
|
||||||
|
p.rect(6, 11, 25, 13, f(GOLDM, 0));
|
||||||
|
p.disc(16.0, 8.5, 1.8, a(RED));
|
||||||
|
p.disc(9.0, 10.0, 1.2, f(CRYSTAL, 2));
|
||||||
|
p.disc(23.0, 10.0, 1.2, f(CRYSTAL, 2));
|
||||||
|
p.path(&[(13.0, 3.0), (14.5, 7.0), (12.5, 10.0), (13.5, 13.5)], 1.2, CUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dug(p: &mut Pad) {
|
||||||
|
// a pickaxe struck into a rock
|
||||||
|
p.poly(&[(2.0, 30.0), (4.0, 21.0), (10.0, 16.0), (18.0, 17.0), (25.0, 20.0), (30.0, 30.0)], a(STONE));
|
||||||
|
p.path(&[(8.0, 22.0), (12.0, 25.0), (11.0, 29.0)], 1.0, f(STONE, 0));
|
||||||
|
p.path(&[(21.0, 23.0), (24.0, 27.0)], 1.0, f(STONE, 0));
|
||||||
|
p.rect(5, 26, 6, 27, a(GOLDM));
|
||||||
|
p.rect(16, 27, 17, 28, a(GOLDM));
|
||||||
|
p.px(26, 26, f(GOLDM, 2));
|
||||||
|
p.line(25.0, 5.0, 13.0, 23.0, 2.6, a(WOODM));
|
||||||
|
p.path(&[(10.0, 7.0), (16.0, 3.0), (23.0, 4.0)], 3.2, a(STEEL));
|
||||||
|
p.path(&[(23.0, 4.0), (28.0, 9.0), (29.0, 15.0)], 3.0, a(STEEL));
|
||||||
|
p.line(9.0, 7.5, 10.0, 7.0, 1.6, a(STEEL));
|
||||||
|
p.disc(24.0, 5.5, 2.4, a(IRON));
|
||||||
|
// chips flying
|
||||||
|
p.rect(5, 12, 6, 13, a(STONE));
|
||||||
|
p.rect(8, 9, 8, 10, a(STONE));
|
||||||
|
p.px(3, 16, f(STONE, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gold(p: &mut Pad) {
|
||||||
|
// a pile of coins
|
||||||
|
p.poly(&[(3.0, 29.0), (7.0, 21.0), (13.0, 17.0), (20.0, 17.0), (26.0, 21.0), (29.0, 29.0)], a(GOLDM));
|
||||||
|
for (x, y) in [(8, 25), (14, 22), (20, 25), (24, 23), (11, 27), (17, 27)] {
|
||||||
|
p.oval(x as f32 + 0.5, y as f32, 2.6, 1.2, 0.0, f(GOLDM, 0));
|
||||||
|
p.oval(x as f32 + 0.5, y as f32 - 0.5, 2.0, 0.8, 0.0, f(GOLDM, 2));
|
||||||
|
}
|
||||||
|
// a standing coin
|
||||||
|
p.disc(12.0, 13.0, 7.0, a(GOLDM));
|
||||||
|
p.ring(12.0, 13.0, 4.6, 5.6, f(GOLDM, 0));
|
||||||
|
p.rect(11, 10, 12, 16, f(GOLDM, 0));
|
||||||
|
p.rect(10, 10, 13, 10, f(GOLDM, 0));
|
||||||
|
p.rect(10, 16, 13, 16, f(GOLDM, 0));
|
||||||
|
// another coin flying
|
||||||
|
p.oval(24.0, 9.0, 4.0, 4.0, 0.0, a(GOLDM));
|
||||||
|
p.ring(24.0, 9.0, 2.0, 2.9, f(GOLDM, 0));
|
||||||
|
p.sparkle(27, 16);
|
||||||
|
p.sparkle(5, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wood(p: &mut Pad) {
|
||||||
|
// an axe buried in a log
|
||||||
|
p.rect(3, 18, 23, 27, a(WOODM));
|
||||||
|
for (x0, x1, y) in [(5, 12, 20), (9, 19, 23), (4, 9, 25), (14, 21, 26)] {
|
||||||
|
p.rect(x0, y, x1, y, f(WOODM, 0));
|
||||||
|
}
|
||||||
|
p.oval(24.5, 22.5, 4.0, 5.2, 0.0, f(BONE, 1));
|
||||||
|
p.ring(24.5, 22.5, 1.5, 2.3, f(WOODM, 2));
|
||||||
|
p.ring(24.5, 22.5, 3.2, 3.9, f(WOODM, 2));
|
||||||
|
p.px(24, 22, f(WOODM, 1));
|
||||||
|
p.line(5.0, 3.0, 15.0, 17.0, 2.6, a(DKWOOD));
|
||||||
|
p.poly(&[(10.0, 14.0), (17.0, 11.0), (20.5, 17.5), (13.0, 19.0)], a(STEEL));
|
||||||
|
p.line(12.0, 18.6, 20.0, 17.6, 1.0, f(STEEL, 3));
|
||||||
|
p.rect(9, 17, 21, 18, a(WOODM));
|
||||||
|
p.rect(12, 17, 19, 17, f(IRON, 0));
|
||||||
|
// chips
|
||||||
|
p.rect(23, 9, 24, 10, f(WOODM, 2));
|
||||||
|
p.rect(26, 13, 26, 14, f(WOODM, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walls(p: &mut Pad) {
|
||||||
|
// a brick wall with battlements
|
||||||
|
p.rect(3, 9, 28, 28, f(IRON, 1));
|
||||||
|
for (x0, x1) in [(3, 8), (13, 18), (23, 28)] {
|
||||||
|
p.rect(x0, 3, x1, 8, f(IRON, 1));
|
||||||
|
}
|
||||||
|
for (row, y) in [8, 13, 18, 23].iter().enumerate() {
|
||||||
|
let off = if row % 2 == 0 { 0 } else { 4 };
|
||||||
|
let mut x = 3 - off;
|
||||||
|
while x <= 28 {
|
||||||
|
p.rect(x.max(3), y + 1, (x + 6).min(28), y + 4, a(BRICK));
|
||||||
|
x += 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (x0, x1) in [(3, 8), (13, 18), (23, 28)] {
|
||||||
|
p.rect(x0, 4, x1, 7, a(BRICK));
|
||||||
|
}
|
||||||
|
p.rect(3, 9, 28, 9, f(IRON, 1));
|
||||||
|
// a crack and a loose brick
|
||||||
|
p.path(&[(19.0, 14.0), (17.0, 18.0), (19.0, 21.0)], 1.0, f(IRON, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rooms(p: &mut Pad) {
|
||||||
|
// a stone arch with a barred wooden door
|
||||||
|
p.disc(16.0, 15.0, 13.0, a(STONE));
|
||||||
|
p.rect(3, 15, 28, 29, a(STONE));
|
||||||
|
p.disc(16.0, 15.0, 8.5, f(IRON, 0));
|
||||||
|
p.rect(8, 15, 23, 29, f(IRON, 0));
|
||||||
|
for k in 0..7 {
|
||||||
|
let ang = std::f32::consts::PI * (1.0 + k as f32 / 6.0);
|
||||||
|
p.line(16.0 + 9.0 * ang.cos(), 15.0 + 9.0 * ang.sin(), 16.0 + 13.0 * ang.cos(), 15.0 + 13.0 * ang.sin(), 0.9, f(STONE, 0));
|
||||||
|
}
|
||||||
|
p.rect(3, 22, 7, 22, f(STONE, 0));
|
||||||
|
p.rect(24, 22, 28, 22, f(STONE, 0));
|
||||||
|
p.disc(16.0, 15.5, 7.5, a(WOODM));
|
||||||
|
p.rect(9, 15, 22, 29, a(WOODM));
|
||||||
|
for x in [12, 16, 19] {
|
||||||
|
p.line(x as f32 + 0.5, 8.5, x as f32 + 0.5, 29.0, 0.9, f(WOODM, 0));
|
||||||
|
}
|
||||||
|
p.rect(9, 13, 22, 14, a(IRON));
|
||||||
|
p.rect(9, 23, 22, 24, a(IRON));
|
||||||
|
p.ring(20.0, 19.0, 1.2, 2.2, a(GOLDM));
|
||||||
|
p.rect(2, 29, 29, 30, a(STONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spells(p: &mut Pad) {
|
||||||
|
// an open spell book with a swirl of magic
|
||||||
|
p.poly(&[(2.0, 15.0), (16.0, 19.0), (30.0, 15.0), (30.0, 28.0), (16.0, 31.0), (2.0, 28.0)], a(PURPLE));
|
||||||
|
p.poly(&[(3.5, 13.0), (15.5, 17.0), (15.5, 28.5), (3.5, 25.5)], a(BONE));
|
||||||
|
p.poly(&[(16.5, 17.0), (28.5, 13.0), (28.5, 25.5), (16.5, 28.5)], a(BONE));
|
||||||
|
for k in 0..3 {
|
||||||
|
let y = 18.0 + k as f32 * 3.0;
|
||||||
|
p.line(5.5, y, 13.5, y + 2.4, 0.8, f(BONE, 0));
|
||||||
|
p.line(18.5, y + 2.4, 26.5, y, 0.8, f(BONE, 0));
|
||||||
|
}
|
||||||
|
p.px(5, 16, f(RED, 2));
|
||||||
|
p.px(6, 16, f(RED, 2));
|
||||||
|
// the swirl rising from the pages
|
||||||
|
let mut prev = (16.0, 15.0);
|
||||||
|
for k in 1..22 {
|
||||||
|
let t = k as f32 / 21.0;
|
||||||
|
let ang = t * 9.0;
|
||||||
|
let r = 1.0 + t * 6.5;
|
||||||
|
let pt = (16.0 + r * ang.cos(), 14.0 - t * 8.0 + r * 0.5 * ang.sin());
|
||||||
|
p.line(prev.0, prev.1, pt.0, pt.1, 1.0 + t * 1.2, f(CYAN, if t > 0.6 { 1 } else { 2 }));
|
||||||
|
prev = pt;
|
||||||
|
}
|
||||||
|
p.disc(16.0, 9.0, 1.4, f(CYAN, 3));
|
||||||
|
p.sparkle(5, 4);
|
||||||
|
p.sparkle(27, 5);
|
||||||
|
p.sparkle(26, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slaps(p: &mut Pad) {
|
||||||
|
// the keeper's open hand, swinging
|
||||||
|
let tilt = 0.25f32;
|
||||||
|
let (s, c) = tilt.sin_cos();
|
||||||
|
let rot = |x: f32, y: f32| (19.0 + (x - 19.0) * c - (y - 19.0) * s, 19.0 + (x - 19.0) * s + (y - 19.0) * c);
|
||||||
|
let fingers = [(13.5, 6.5), (17.0, 4.5), (20.5, 5.0), (24.0, 8.0)];
|
||||||
|
for (x, y) in fingers {
|
||||||
|
let (x0, y0) = rot(x, 18.0);
|
||||||
|
let (x1, y1) = rot(x, y);
|
||||||
|
p.line(x0, y0, x1, y1, 3.2, a(SKIN));
|
||||||
|
p.px((x1 - 0.5) as i32, (y1 - 1.6) as i32, f(BONE, 2));
|
||||||
|
}
|
||||||
|
let (px0, py0) = rot(19.0, 20.0);
|
||||||
|
p.oval(px0, py0, 6.5, 7.0, tilt, a(SKIN));
|
||||||
|
let (tx0, ty0) = rot(13.5, 21.0);
|
||||||
|
let (tx1, ty1) = rot(8.5, 15.0);
|
||||||
|
p.line(tx0, ty0, tx1, ty1, 3.2, a(SKIN));
|
||||||
|
p.px((tx1 - 1.0) as i32, (ty1 - 1.0) as i32, f(BONE, 2));
|
||||||
|
let (wx0, wy0) = rot(17.0, 26.0);
|
||||||
|
let (wx1, wy1) = rot(17.0, 31.0);
|
||||||
|
p.line(wx0, wy0, wx1, wy1, 6.0, a(SKIN));
|
||||||
|
p.line(wx0 - 3.0, wy0 + 2.5, wx0 + 3.5, wy0 + 1.0, 1.6, a(PURPLE)); // sleeve cuff
|
||||||
|
p.line(15.5, 15.0, 19.5, 22.0, 0.8, f(SKIN, 0));
|
||||||
|
// motion lines
|
||||||
|
for (x0, y, x1) in [(1, 9, 6), (2, 14, 7), (1, 19, 5)] {
|
||||||
|
p.rect(x0, y, x1, y, f(SPARK, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn traps(p: &mut Pad) {
|
||||||
|
// spikes shooting from a floor slab, one of them bloodied
|
||||||
|
p.rect(2, 23, 29, 29, a(STONE));
|
||||||
|
p.rect(2, 26, 29, 26, f(STONE, 0));
|
||||||
|
p.rect(15, 23, 15, 26, f(STONE, 0));
|
||||||
|
p.rect(8, 26, 8, 29, f(STONE, 0));
|
||||||
|
p.rect(23, 26, 23, 29, f(STONE, 0));
|
||||||
|
for (cx, tip) in [(6.0, 11.0), (11.0, 7.0), (16.0, 3.0), (21.0, 7.0), (26.0, 11.0)] {
|
||||||
|
p.poly(&[(cx - 2.6, 23.0), (cx, tip), (cx + 2.6, 23.0)], a(STEEL));
|
||||||
|
p.line(cx - 0.6, tip + 3.0, cx - 1.6, 22.0, 0.8, f(STEEL, 3));
|
||||||
|
p.rect(cx as i32 - 3, 22, cx as i32 + 2, 23, f(IRON, 1));
|
||||||
|
}
|
||||||
|
p.poly(&[(14.6, 8.0), (16.0, 3.0), (17.4, 8.0)], a(RED));
|
||||||
|
p.rect(16, 9, 16, 11, f(RED, 1));
|
||||||
|
p.px(16, 13, f(RED, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn towers(p: &mut Pad) {
|
||||||
|
// a dark tower with a horned roof and a glowing window
|
||||||
|
p.poly(&[(9.0, 30.0), (11.5, 12.0), (20.5, 12.0), (23.0, 30.0)], a(IRON));
|
||||||
|
for y in [16, 21, 26] {
|
||||||
|
p.line(11.0, y as f32 + 0.5, 21.5, y as f32 + 0.5, 0.8, f(IRON, 0));
|
||||||
|
}
|
||||||
|
p.rect(8, 9, 23, 12, a(PURPLE));
|
||||||
|
for x in [8, 13, 18, 22] {
|
||||||
|
p.rect(x, 7, x + 1, 8, a(PURPLE));
|
||||||
|
}
|
||||||
|
p.poly(&[(9.0, 8.0), (16.0, 0.5), (23.0, 8.0)], a(IRON));
|
||||||
|
p.path(&[(8.5, 10.0), (5.0, 7.0), (4.0, 2.0)], 1.6, a(BONE));
|
||||||
|
p.path(&[(23.5, 10.0), (27.0, 7.0), (28.0, 2.0)], 1.6, a(BONE));
|
||||||
|
p.disc(16.0, 18.0, 2.2, f(FIRE, 1));
|
||||||
|
p.rect(14, 18, 18, 22, f(FIRE, 1));
|
||||||
|
p.rect(15, 17, 17, 21, f(FIRE, 2));
|
||||||
|
p.px(16, 18, f(FIRE, 3));
|
||||||
|
p.rect(14, 27, 18, 30, f(IRON, 0));
|
||||||
|
p.disc(16.0, 27.0, 2.4, f(IRON, 0));
|
||||||
|
p.rect(3, 30, 28, 30, a(STONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drowned(p: &mut Pad) {
|
||||||
|
// a helmet sinking between waves
|
||||||
|
p.poly(&[(17.0, 2.0), (22.0, 4.0), (20.0, 6.5), (16.5, 6.0)], a(RED));
|
||||||
|
p.disc(15.5, 14.0, 8.0, a(STEEL));
|
||||||
|
p.rect(9, 12, 21, 13, f(IRON, 0));
|
||||||
|
p.line(15.5, 6.0, 15.5, 20.0, 1.0, f(STEEL, 0));
|
||||||
|
p.fill(a(WATER), |x, y| y >= 18.0 + 1.6 * (x * 0.55).sin() && y < 31.0 && (1.0..31.0).contains(&x));
|
||||||
|
for (x0, y0) in [(4.0, 23.0), (14.0, 25.0), (22.0, 22.0), (9.0, 28.0), (20.0, 28.5)] {
|
||||||
|
p.line(x0, y0, x0 + 3.5, y0, 1.0, f(WATER, 3));
|
||||||
|
}
|
||||||
|
p.ring(25.0, 12.0, 1.0, 2.0, f(GLASS, 3));
|
||||||
|
p.ring(27.0, 6.0, 0.6, 1.5, f(GLASS, 3));
|
||||||
|
p.px(23, 2, f(GLASS, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn burnt(p: &mut Pad) {
|
||||||
|
// a burning bridge plank
|
||||||
|
p.poly(&[(2.0, 23.0), (28.0, 16.5), (30.0, 22.0), (4.0, 28.5)], a(WOODM));
|
||||||
|
p.line(5.0, 25.5, 28.0, 19.5, 0.8, f(WOODM, 0));
|
||||||
|
p.poly(&[(19.0, 18.8), (28.0, 16.5), (30.0, 22.0), (21.0, 24.3)], a(IRON));
|
||||||
|
p.rect(6, 23, 7, 24, f(IRON, 2));
|
||||||
|
p.rect(13, 21, 14, 22, f(IRON, 2));
|
||||||
|
for (x, y) in [(20, 20), (23, 21), (25, 19), (27, 20)] {
|
||||||
|
p.px(x, y, f(FIRE, 1));
|
||||||
|
}
|
||||||
|
// flames
|
||||||
|
p.poly(&[(14.0, 21.0), (13.0, 14.0), (16.0, 9.0), (17.0, 13.0), (20.0, 4.0), (22.0, 11.0), (24.5, 8.0), (25.5, 14.0), (28.0, 17.0)], f(FIRE, 0));
|
||||||
|
p.poly(&[(16.0, 20.0), (16.0, 15.0), (18.0, 12.5), (19.0, 15.0), (20.5, 9.0), (22.0, 14.0), (24.0, 12.0), (24.5, 16.0), (26.0, 18.0)], f(FIRE, 1));
|
||||||
|
p.poly(&[(18.5, 19.0), (19.0, 16.0), (20.5, 13.5), (21.5, 16.0), (23.0, 15.5), (23.5, 18.0)], f(FIRE, 2));
|
||||||
|
p.px(21, 17, f(FIRE, 3));
|
||||||
|
for (x, y) in [(11, 6), (14, 3), (26, 4), (29, 10), (8, 12)] {
|
||||||
|
p.px(x, y, f(EMBER, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn siege(p: &mut Pad) {
|
||||||
|
// a catapult with a boulder
|
||||||
|
p.line(13.0, 24.0, 17.0, 13.0, 2.0, a(DKWOOD));
|
||||||
|
p.line(21.0, 24.0, 17.0, 13.0, 2.0, a(DKWOOD));
|
||||||
|
p.line(6.0, 22.0, 24.0, 7.0, 2.2, a(WOODM));
|
||||||
|
p.oval(24.5, 6.5, 3.2, 2.0, -0.7, a(DKWOOD));
|
||||||
|
p.disc(25.0, 3.5, 2.8, a(STONE));
|
||||||
|
p.rect(3, 22, 28, 24, a(WOODM));
|
||||||
|
p.rect(3, 23, 28, 23, f(WOODM, 0));
|
||||||
|
p.disc(17.0, 13.0, 1.4, a(IRON));
|
||||||
|
p.line(6.0, 22.0, 10.0, 22.0, 1.0, f(IRON, 1)); // the rope
|
||||||
|
for cx in [8.0, 23.0] {
|
||||||
|
p.disc(cx, 25.5, 4.0, a(DKWOOD));
|
||||||
|
p.ring(cx, 25.5, 2.6, 3.3, f(DKWOOD, 0));
|
||||||
|
p.disc(cx, 25.5, 1.2, a(STEEL));
|
||||||
|
}
|
||||||
|
p.rect(1, 30, 30, 30, a(STONE));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn captured(p: &mut Pad) {
|
||||||
|
// two iron shackles joined by a chain
|
||||||
|
p.ring(9.0, 20.0, 3.5, 6.5, a(STEEL));
|
||||||
|
p.ring(23.0, 20.0, 3.5, 6.5, a(STEEL));
|
||||||
|
p.rect(7, 13, 11, 15, a(IRON));
|
||||||
|
p.rect(21, 13, 25, 15, a(IRON));
|
||||||
|
p.disc(9.0, 24.5, 1.2, f(IRON, 0));
|
||||||
|
p.disc(23.0, 24.5, 1.2, f(IRON, 0));
|
||||||
|
// the chain, links turned alternately
|
||||||
|
let links = [(9.0, 11.0, true), (12.0, 7.5, false), (16.0, 6.0, true), (20.0, 7.5, false), (23.0, 11.0, true)];
|
||||||
|
for (x, y, upright) in links {
|
||||||
|
if upright {
|
||||||
|
p.oval(x, y, 1.6, 2.6, 0.0, a(STEEL));
|
||||||
|
p.rect(x as i32, y as i32 - 1, x as i32, y as i32, f(IRON, 0));
|
||||||
|
} else {
|
||||||
|
p.oval(x, y, 2.6, 1.6, 0.0, a(IRON));
|
||||||
|
p.rect(x as i32 - 1, y as i32, x as i32, y as i32, f(IRON, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drawbridge(p: &mut Pad) {
|
||||||
|
// a gatehouse with its bridge hanging on chains above the moat
|
||||||
|
p.rect(14, 4, 29, 29, a(STONE));
|
||||||
|
for x in [14, 19, 24] {
|
||||||
|
p.rect(x, 1, x + 2, 3, a(STONE));
|
||||||
|
}
|
||||||
|
for y in [9, 15, 21] {
|
||||||
|
p.rect(14, y, 29, y, f(STONE, 0));
|
||||||
|
}
|
||||||
|
p.disc(22.0, 16.0, 4.5, f(IRON, 0));
|
||||||
|
p.rect(18, 16, 26, 28, f(IRON, 0));
|
||||||
|
for x in [19, 21, 23, 25] {
|
||||||
|
p.rect(x, 13, x, 22, f(IRON, 2));
|
||||||
|
}
|
||||||
|
p.fill(a(WATER), |x, y| y >= 26.5 + 0.8 * (x * 1.1).sin() && y < 31.0 && (1.0..18.0).contains(&x));
|
||||||
|
// the bridge, half raised
|
||||||
|
p.line(18.0, 27.0, 4.0, 17.0, 4.0, a(WOODM));
|
||||||
|
for k in 1..4 {
|
||||||
|
let t = k as f32 / 4.0;
|
||||||
|
let (x, y) = (18.0 - 14.0 * t, 27.0 - 10.0 * t);
|
||||||
|
p.line(x - 1.2, y - 1.7, x + 1.2, y + 1.7, 0.8, f(WOODM, 0));
|
||||||
|
}
|
||||||
|
p.disc(18.0, 27.0, 1.3, a(IRON));
|
||||||
|
for k in 0..6 {
|
||||||
|
let t = k as f32 / 5.0;
|
||||||
|
let (x, y) = (4.5 + 9.5 * t, 15.0 - 7.0 * t);
|
||||||
|
p.disc(x, y, 1.1, if k % 2 == 0 { a(STEEL) } else { a(IRON) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wins(p: &mut Pad) {
|
||||||
|
// a banner planted in the dungeon heart
|
||||||
|
p.disc(10.5, 20.0, 6.0, a(RED));
|
||||||
|
p.disc(21.5, 20.0, 6.0, a(RED));
|
||||||
|
p.poly(&[(4.7, 21.5), (27.3, 21.5), (16.0, 31.0)], a(RED));
|
||||||
|
p.oval(9.0, 18.5, 2.0, 1.4, -0.6, f(RED, 3));
|
||||||
|
p.line(12.0, 2.0, 12.0, 21.0, 1.8, a(DKWOOD));
|
||||||
|
p.disc(12.0, 2.0, 1.3, a(GOLDM));
|
||||||
|
p.poly(&[(13.0, 3.5), (27.0, 4.5), (23.5, 8.0), (27.0, 11.5), (13.0, 12.5)], a(GOLDM));
|
||||||
|
p.line(14.5, 6.0, 21.5, 6.5, 0.8, f(GOLDM, 0));
|
||||||
|
p.line(14.5, 9.5, 21.5, 9.5, 0.8, f(GOLDM, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missions(p: &mut Pad) {
|
||||||
|
// a crystal shard on a rock
|
||||||
|
p.poly(&[(5.0, 30.0), (8.0, 25.0), (14.0, 24.0), (22.0, 25.0), (27.0, 30.0)], a(STONE));
|
||||||
|
p.poly(&[(16.0, 1.5), (22.0, 9.0), (19.5, 27.0), (12.5, 27.0), (10.0, 9.0)], f(CRYSTAL, 1));
|
||||||
|
p.poly(&[(16.0, 1.5), (10.0, 9.0), (12.5, 27.0), (14.5, 27.0), (14.0, 9.0)], f(CRYSTAL, 2));
|
||||||
|
p.poly(&[(16.0, 1.5), (22.0, 9.0), (19.5, 27.0), (18.5, 27.0), (18.5, 9.0)], f(CRYSTAL, 0));
|
||||||
|
p.line(12.0, 6.5, 13.0, 18.0, 0.9, f(CRYSTAL, 3));
|
||||||
|
p.poly(&[(6.0, 14.0), (9.5, 18.0), (10.5, 27.0), (6.5, 27.0), (4.5, 19.0)], f(CRYSTAL, 1));
|
||||||
|
p.poly(&[(6.0, 14.0), (4.5, 19.0), (6.5, 27.0), (7.5, 27.0), (6.5, 18.0)], f(CRYSTAL, 2));
|
||||||
|
p.poly(&[(26.0, 16.0), (28.0, 21.0), (26.5, 27.0), (22.0, 27.0), (23.0, 20.0)], f(CRYSTAL, 0));
|
||||||
|
p.sparkle(24, 5);
|
||||||
|
p.sparkle(4, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coop(p: &mut Pad) {
|
||||||
|
// two claws clasped: an imp's and a demon's
|
||||||
|
p.line(0.0, 25.0, 11.0, 19.5, 6.0, a(SKIN));
|
||||||
|
p.line(32.0, 25.0, 21.0, 19.5, 6.0, a(RED));
|
||||||
|
p.line(3.0, 21.0, 6.0, 27.5, 2.0, a(PURPLE)); // cuffs
|
||||||
|
p.line(29.0, 21.0, 26.0, 27.5, 2.0, a(IRON));
|
||||||
|
p.oval(19.5, 18.5, 5.5, 4.5, 0.0, a(RED));
|
||||||
|
p.oval(12.5, 18.5, 5.0, 4.8, 0.0, a(SKIN));
|
||||||
|
// the imp's fingers curl over the back of the demon's hand
|
||||||
|
for k in 0..4 {
|
||||||
|
let x = 15.0 + k as f32 * 2.4;
|
||||||
|
p.path(&[(x - 0.5, 15.0), (x + 1.0, 17.5), (x + 0.6, 21.0)], 2.0, a(SKIN));
|
||||||
|
p.px((x + 0.6) as i32, 22, f(BONE, 3));
|
||||||
|
}
|
||||||
|
// the demon's thumb over the imp's hand
|
||||||
|
p.line(19.0, 14.5, 12.0, 14.0, 2.2, a(RED));
|
||||||
|
p.px(10, 13, f(BONE, 3));
|
||||||
|
p.px(11, 13, f(BONE, 2));
|
||||||
|
p.sparkle(16, 6);
|
||||||
|
p.sparkle(8, 9);
|
||||||
|
p.sparkle(25, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hourglass(p: &mut Pad, cx: f32, top: i32, bottom: i32, half: f32, frame: u8) {
|
||||||
|
let (t, b) = (top as f32, bottom as f32);
|
||||||
|
let mid = (t + b) * 0.5 + 0.5;
|
||||||
|
p.poly(&[(cx - half + 1.0, t + 2.0), (cx + half - 1.0, t + 2.0), (cx + 1.0, mid), (cx + half - 1.0, b - 1.0), (cx - half + 1.0, b - 1.0), (cx - 1.0, mid)], f(GLASS, 1));
|
||||||
|
p.poly(&[(cx - half + 2.0, t + 2.5), (cx - half + 3.0, t + 2.5), (cx - 0.5, mid - 1.0)], f(GLASS, 3));
|
||||||
|
// sand: a little left above, a heap below, the stream
|
||||||
|
p.poly(&[(cx - half * 0.45, mid - 4.0), (cx + half * 0.45, mid - 4.0), (cx, mid - 0.5)], f(SUN, 1));
|
||||||
|
p.poly(&[(cx - half + 1.5, b - 1.0), (cx, b - 6.0), (cx + half - 1.5, b - 1.0)], f(SUN, 1));
|
||||||
|
p.poly(&[(cx - 2.0, b - 3.5), (cx, b - 6.0), (cx + 0.5, b - 3.5)], f(SUN, 2));
|
||||||
|
p.line(cx, mid, cx, b - 5.0, 0.9, f(SUN, 2));
|
||||||
|
p.rect((cx - half) as i32, top, (cx + half) as i32 - 1, top + 1, a(frame));
|
||||||
|
p.rect((cx - half) as i32, bottom, (cx + half) as i32 - 1, bottom + 1, a(frame));
|
||||||
|
p.line(cx - half + 0.5, t + 1.0, cx - half + 0.5, b, 1.3, a(frame));
|
||||||
|
p.line(cx + half - 0.5, t + 1.0, cx + half - 0.5, b, 1.3, a(frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn minutes(p: &mut Pad) {
|
||||||
|
// an hourglass with a candle beside it
|
||||||
|
hourglass(p, 13.0, 3, 27, 8.0, WOODM);
|
||||||
|
p.rect(23, 15, 27, 28, a(BONE));
|
||||||
|
p.rect(22, 28, 28, 29, a(IRON));
|
||||||
|
p.line(24.0, 17.0, 24.0, 21.0, 0.9, f(BONE, 3));
|
||||||
|
p.px(25, 14, f(IRON, 0));
|
||||||
|
p.poly(&[(23.5, 13.0), (25.0, 7.0), (26.6, 13.0)], f(FIRE, 1));
|
||||||
|
p.poly(&[(24.4, 13.0), (25.0, 9.5), (25.8, 13.0)], f(FIRE, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flawless(p: &mut Pad) {
|
||||||
|
// a gleaming, untouched shield
|
||||||
|
let rim = [(5.0, 3.0), (27.0, 3.0), (27.0, 15.0), (24.0, 22.0), (16.0, 30.0), (8.0, 22.0), (5.0, 15.0)];
|
||||||
|
p.poly(&rim, a(GOLDM));
|
||||||
|
let field = [(7.5, 5.5), (24.5, 5.5), (24.5, 15.0), (22.0, 21.0), (16.0, 27.0), (10.0, 21.0), (7.5, 15.0)];
|
||||||
|
p.poly(&field, a(BLUE));
|
||||||
|
p.poly(&[(7.5, 5.5), (16.0, 5.5), (16.0, 27.0), (10.0, 21.0), (7.5, 15.0)], f(BLUE, 2));
|
||||||
|
p.rect(15, 7, 16, 23, a(GOLDM));
|
||||||
|
p.rect(10, 12, 21, 13, a(GOLDM));
|
||||||
|
p.disc(16.0, 12.5, 2.0, a(CRYSTAL));
|
||||||
|
p.line(9.0, 7.0, 12.0, 7.0, 0.9, f(BLUE, 3));
|
||||||
|
p.line(9.0, 7.0, 9.0, 10.0, 0.9, f(BLUE, 3));
|
||||||
|
p.sparkle(26, 4);
|
||||||
|
p.sparkle(4, 20);
|
||||||
|
p.px(28, 9, f(SPARK, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn speedrun(p: &mut Pad) {
|
||||||
|
// a winged hourglass
|
||||||
|
for side in [-1.0f32, 1.0] {
|
||||||
|
let x = |dx: f32| 16.0 + side * dx;
|
||||||
|
p.poly(&[(x(6.0), 10.0), (x(13.0), 4.0), (x(15.5), 6.0), (x(14.5), 10.0), (x(15.0), 13.0), (x(13.0), 17.0), (x(6.0), 19.0)], a(BONE));
|
||||||
|
for (dx, y) in [(10.0, 9.0), (11.0, 12.0), (10.0, 15.0)] {
|
||||||
|
p.line(x(6.5), y + 1.5, x(dx + 4.0), y, 0.8, f(BONE, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hourglass(p, 16.0, 4, 27, 6.0, GOLDM);
|
||||||
|
p.path(&[(27.0, 20.0), (25.0, 24.0), (28.0, 24.0), (25.5, 29.5)], 1.2, f(SUN, 2));
|
||||||
|
p.path(&[(6.0, 20.0), (4.0, 24.0), (7.0, 24.0), (4.5, 29.5)], 1.2, f(SUN, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn campaign(p: &mut Pad) {
|
||||||
|
// the crown with the core made whole at its heart
|
||||||
|
p.poly(&[(4.0, 22.0), (3.0, 8.0), (10.0, 15.0), (16.0, 5.0), (22.0, 15.0), (29.0, 8.0), (28.0, 22.0)], a(GOLDM));
|
||||||
|
p.rect(4, 21, 27, 27, a(GOLDM));
|
||||||
|
p.rect(4, 22, 27, 22, f(GOLDM, 0));
|
||||||
|
p.rect(4, 26, 27, 26, f(GOLDM, 0));
|
||||||
|
for (x, y) in [(3.0, 7.0), (16.0, 3.5), (29.0, 7.0)] {
|
||||||
|
p.disc(x, y, 1.8, a(GOLDM));
|
||||||
|
}
|
||||||
|
p.disc(8.5, 24.0, 1.3, a(RED));
|
||||||
|
p.disc(23.5, 24.0, 1.3, a(RED));
|
||||||
|
// the core
|
||||||
|
p.poly(&[(16.0, 11.0), (20.5, 17.5), (16.0, 26.0), (11.5, 17.5)], f(CRYSTAL, 1));
|
||||||
|
p.poly(&[(16.0, 11.0), (11.5, 17.5), (16.0, 26.0), (15.0, 17.5)], f(CRYSTAL, 2));
|
||||||
|
p.px(14, 15, f(CRYSTAL, 3));
|
||||||
|
p.px(14, 16, f(CRYSTAL, 3));
|
||||||
|
p.sparkle(26, 14);
|
||||||
|
p.sparkle(7, 13);
|
||||||
|
p.sparkle(16, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn surface(p: &mut Pad) {
|
||||||
|
// the sun rising over grassy hills
|
||||||
|
for k in 0..8 {
|
||||||
|
let ang = k as f32 * std::f32::consts::PI / 4.0;
|
||||||
|
p.line(16.0 + 9.0 * ang.cos(), 14.0 + 9.0 * ang.sin(), 16.0 + 13.0 * ang.cos(), 14.0 + 13.0 * ang.sin(), 2.0, f(SUN, 1));
|
||||||
|
}
|
||||||
|
p.disc(16.0, 14.0, 7.0, f(SUN, 2));
|
||||||
|
p.disc(14.5, 12.5, 4.0, f(SUN, 3));
|
||||||
|
p.fill(a(GREEN), |x, y| y >= 21.0 + 2.5 * (x * 0.22 + 0.4).sin() && y < 31.0 && (1.0..31.0).contains(&x));
|
||||||
|
for x in [3, 7, 12, 19, 24, 28] {
|
||||||
|
let base = 21.0 + 2.5 * ((x as f32 + 0.5) * 0.22 + 0.4).sin();
|
||||||
|
p.line(x as f32 + 0.5, base, x as f32 - 0.5, base - 3.0, 1.0, f(GREEN, 2));
|
||||||
|
}
|
||||||
|
p.px(10, 26, f(RED, 2));
|
||||||
|
p.px(22, 27, f(SUN, 2));
|
||||||
|
p.px(16, 28, f(SPARK, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn defeat(p: &mut Pad) {
|
||||||
|
// a cup of tea, to soothe the defeat
|
||||||
|
p.oval(16.0, 27.0, 14.0, 2.8, 0.0, a(BONE));
|
||||||
|
p.oval(16.0, 26.5, 9.0, 1.5, 0.0, f(BONE, 0));
|
||||||
|
p.ring(24.5, 18.5, 2.0, 4.0, a(BONE));
|
||||||
|
p.poly(&[(6.0, 13.0), (26.0, 13.0), (25.0, 20.0), (21.0, 26.0), (11.0, 26.0), (7.0, 20.0)], a(BONE));
|
||||||
|
p.rect(7, 16, 25, 17, f(BLUE, 2));
|
||||||
|
p.px(12, 16, f(BLUE, 0));
|
||||||
|
p.px(16, 17, f(BLUE, 0));
|
||||||
|
p.px(20, 16, f(BLUE, 0));
|
||||||
|
p.oval(16.0, 13.5, 9.5, 1.8, 0.0, f(TEA, 1));
|
||||||
|
p.oval(14.0, 13.0, 3.0, 0.6, 0.0, f(TEA, 3));
|
||||||
|
// steam
|
||||||
|
for x0 in [11.0f32, 16.0, 21.0] {
|
||||||
|
let mut prev = (x0, 10.0);
|
||||||
|
for k in 1..6 {
|
||||||
|
let y = 10.0 - k as f32 * 1.5;
|
||||||
|
let pt = (x0 + (k as f32 * 1.4).sin() * 1.3, y);
|
||||||
|
p.line(prev.0, prev.1, pt.0, pt.1, 0.9, f(EMBER, 3));
|
||||||
|
prev = pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legend(p: &mut Pad) {
|
||||||
|
// a star crowned with laurels
|
||||||
|
for side in [-1.0f32, 1.0] {
|
||||||
|
let mut prev = (16.0 + side * 3.0, 29.0);
|
||||||
|
for k in 0..7 {
|
||||||
|
let ang = std::f32::consts::FRAC_PI_2 + side * (0.35 + k as f32 * 0.33);
|
||||||
|
let pt = (16.0 + 12.0 * ang.cos(), 16.0 + 12.0 * ang.sin());
|
||||||
|
p.line(prev.0, prev.1, pt.0, pt.1, 1.0, f(GREEN, 0));
|
||||||
|
let out = (16.0 + 14.2 * ang.cos(), 16.0 + 14.2 * ang.sin());
|
||||||
|
let inn = (16.0 + 10.0 * ang.cos(), 16.0 + 10.0 * ang.sin());
|
||||||
|
p.oval(out.0, out.1, 2.2, 1.1, ang + side * 0.6, a(GREEN));
|
||||||
|
if k < 6 {
|
||||||
|
p.oval(inn.0, inn.1, 2.0, 1.0, ang - side * 0.6, a(GREEN));
|
||||||
|
}
|
||||||
|
prev = pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.line(13.0, 29.0, 19.0, 29.0, 1.4, a(RED));
|
||||||
|
p.star(16.0, 15.5, 4.2, 10.0, a(GOLDM));
|
||||||
|
p.star(16.0, 15.5, 1.8, 4.0, f(GOLDM, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wyrm(p: &mut Pad) {
|
||||||
|
// the ember wyrm's head
|
||||||
|
p.path(&[(10.0, 9.0), (6.0, 5.0), (2.0, 4.0)], 2.2, a(BONE));
|
||||||
|
p.path(&[(13.0, 8.0), (11.0, 3.0), (7.0, 0.5)], 2.0, a(BONE));
|
||||||
|
// neck and spines
|
||||||
|
p.poly(&[(2.0, 31.0), (4.0, 18.0), (9.0, 10.0), (14.0, 22.0), (12.0, 31.0)], a(RED));
|
||||||
|
for (x, y) in [(3.0, 22.0), (3.5, 27.0), (5.5, 16.0)] {
|
||||||
|
p.poly(&[(x, y), (x - 3.0, y + 0.5), (x + 0.5, y + 3.0)], a(FIRE));
|
||||||
|
}
|
||||||
|
// the head, snout to the right, jaw open
|
||||||
|
p.poly(&[(6.0, 12.0), (12.0, 7.0), (21.0, 9.0), (29.0, 12.0), (29.5, 15.0), (19.0, 16.5), (12.0, 20.0)], a(RED));
|
||||||
|
p.poly(&[(12.0, 19.0), (19.0, 18.0), (27.0, 21.0), (26.0, 23.5), (16.0, 24.0), (10.0, 22.0)], a(RED));
|
||||||
|
p.poly(&[(19.0, 16.4), (29.0, 15.0), (27.0, 20.5), (19.0, 18.0)], f(IRON, 0));
|
||||||
|
for x in [21, 24, 27] {
|
||||||
|
p.px(x, 16, f(BONE, 3));
|
||||||
|
p.px(x - 1, 19, f(BONE, 3));
|
||||||
|
}
|
||||||
|
p.oval(16.0, 11.5, 2.4, 1.4, 0.2, f(SUN, 2));
|
||||||
|
p.rect(16, 11, 16, 12, f(IRON, 0));
|
||||||
|
p.line(13.0, 9.6, 19.0, 10.0, 0.9, f(RED, 0));
|
||||||
|
p.px(27, 11, f(IRON, 0));
|
||||||
|
p.line(16.0, 22.0, 25.0, 21.5, 0.8, f(RED, 3));
|
||||||
|
// embers from the jaws
|
||||||
|
for (x, y) in [(30, 18), (28, 25), (30, 27), (24, 27), (29, 22)] {
|
||||||
|
p.px(x, y, f(EMBER, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fullhand(p: &mut Pad) {
|
||||||
|
// the keeper's hand, full of creatures
|
||||||
|
// horns and ears behind the heads
|
||||||
|
p.poly(&[(12.5, 7.5), (11.0, 2.5), (14.5, 6.0)], a(BONE));
|
||||||
|
p.poly(&[(19.5, 7.5), (21.0, 2.5), (17.5, 6.0)], a(BONE));
|
||||||
|
p.poly(&[(6.0, 14.0), (3.5, 10.0), (8.0, 12.5)], a(GREEN));
|
||||||
|
p.poly(&[(26.0, 14.0), (28.5, 10.0), (24.0, 12.5)], a(PURPLE));
|
||||||
|
let heads: [(f32, f32, u8); 6] = [(16.0, 10.0, RED), (9.0, 16.5, GREEN), (23.0, 16.5, PURPLE), (16.0, 16.0, BONE), (10.5, 22.0, STEEL), (21.5, 22.0, BRICK)];
|
||||||
|
for (x, y, m) in heads {
|
||||||
|
p.disc(x, y, 4.3, f(IRON, 0));
|
||||||
|
p.disc(x, y, 3.6, a(m));
|
||||||
|
let (ex, ey) = (x as i32, y as i32);
|
||||||
|
p.px(ex - 2, ey, f(SUN, 2));
|
||||||
|
p.px(ex + 1, ey, f(SUN, 2));
|
||||||
|
p.px(ex - 1, ey + 2, f(IRON, 0));
|
||||||
|
p.px(ex, ey + 2, f(IRON, 0));
|
||||||
|
}
|
||||||
|
// the cupped hand below
|
||||||
|
p.poly(&[(2.0, 22.0), (6.0, 25.0), (26.0, 25.0), (30.0, 21.0), (29.0, 27.0), (22.0, 30.5), (9.0, 30.5), (3.0, 27.0)], a(SKIN));
|
||||||
|
p.line(30.0, 21.0, 29.0, 15.0, 3.0, a(SKIN));
|
||||||
|
p.px(28, 13, f(BONE, 3));
|
||||||
|
p.line(2.0, 22.0, 3.0, 17.0, 3.0, a(SKIN));
|
||||||
|
p.px(3, 15, f(BONE, 3));
|
||||||
|
p.line(8.0, 27.5, 24.0, 27.5, 0.8, f(SKIN, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// every badge in order of its index
|
||||||
|
pub fn gen_badges() -> Image {
|
||||||
|
let rows = (COUNT + COLS - 1) / COLS;
|
||||||
|
let mut img = Image::gen_image_color((COLS * SIZE) as u16, (rows * SIZE) as u16, Color::new(0.0, 0.0, 0.0, 0.0));
|
||||||
|
let painters: [fn(&mut Pad); COUNT as usize] = [
|
||||||
|
heroes, bosses, dug, gold, wood, walls, rooms, spells, slaps, traps, towers, drowned, burnt, siege, captured, drawbridge, wins, missions, coop, minutes, flawless, speedrun, campaign, surface, defeat, legend, wyrm, fullhand,
|
||||||
|
];
|
||||||
|
for (i, paint) in painters.iter().enumerate() {
|
||||||
|
let mut p = Pad::new();
|
||||||
|
paint(&mut p);
|
||||||
|
p.blit(&mut img, i as u32);
|
||||||
|
}
|
||||||
|
img
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_badge_is_drawn() {
|
||||||
|
let img = gen_badges();
|
||||||
|
for i in 0..COUNT {
|
||||||
|
let r = badge_rect(i);
|
||||||
|
let mut opaque = 0;
|
||||||
|
for y in 0..SIZE {
|
||||||
|
for x in 0..SIZE {
|
||||||
|
if img.get_pixel(r.x as u32 + x, r.y as u32 + y).a > 0.5 {
|
||||||
|
opaque += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(opaque > 150, "badge {} has only {} pixels", i, opaque);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn export_badges_png() {
|
||||||
|
// look at the art: cargo test export_badges_png -- --ignored
|
||||||
|
let img = gen_badges();
|
||||||
|
let mut big = Image::gen_image_color(img.width * 4, img.height * 4, Color::new(0.1, 0.07, 0.07, 1.0));
|
||||||
|
for y in 0..img.height as u32 * 4 {
|
||||||
|
for x in 0..img.width as u32 * 4 {
|
||||||
|
let c = img.get_pixel(x / 4, img.height as u32 - 1 - y / 4); // export_png flips
|
||||||
|
if c.a > 0.0 {
|
||||||
|
big.set_pixel(x, y, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
big.export_png("/tmp/badges.png");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
// Automated balancing: whole games played by the AI brain on every side, headless and
|
||||||
|
// deterministic, many seeds each. Run with
|
||||||
|
// cargo test --release balance_ -- --ignored --nocapture --test-threads=4
|
||||||
|
// and read the tables (docs/BALANCE.md keeps the findings).
|
||||||
|
|
||||||
|
#![cfg(test)]
|
||||||
|
|
||||||
|
use crate::game::*;
|
||||||
|
use crate::rival::{Rival, PARTNER_GIFT_EVERY, RIVAL_CORE_HP};
|
||||||
|
|
||||||
|
const STEP: f32 = 0.05;
|
||||||
|
|
||||||
|
// the master on side 0 gets the same brain as the rivals
|
||||||
|
fn pilot(g: &mut Game, difficulty: u8, focus: Option<u8>) {
|
||||||
|
let core = g.map.core;
|
||||||
|
// like every AI master: the 9x9 starting area and a portal of its own beside it
|
||||||
|
g.carve_master_area(0, core);
|
||||||
|
g.add_master_portal(0, core);
|
||||||
|
g.rivals.push(Rival { side: 0, core, hp: RIVAL_CORE_HP, alive: true, radius: 5, grow_t: 0.0, spawn_t: 0.0, attack_t: 0.0, difficulty, power: 0.0, gift_t: PARTNER_GIFT_EVERY, think_t: 1.0, attacking: false, army0: 0.0, focus, defending: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// the other corners of a square map for up to three more masters (centre of the core)
|
||||||
|
fn corners(w: i32, h: i32) -> [(i32, i32); 3] {
|
||||||
|
[(w - 29, 13), (w - 29, h - 15), (15, h - 15)]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct Outcome {
|
||||||
|
out_at: Vec<(u8, f32)>, // side, minute it fell
|
||||||
|
score: Vec<(u8, f32)>, // side, strength at the end (alive ones)
|
||||||
|
winner: Option<u8>,
|
||||||
|
minutes: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alive(g: &Game, side: u8) -> bool {
|
||||||
|
g.core_hp_of(side) > 0.0 && g.rivals.iter().any(|r| r.side == side && r.alive)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score(g: &Game, side: u8) -> f32 {
|
||||||
|
g.core_hp_of(side) * 2.0 + g.side_army(side) + g.gold_of(side) * 0.2
|
||||||
|
}
|
||||||
|
|
||||||
|
// plays until one side is left or the time is up; side 0 falling does not stop it
|
||||||
|
fn play(g: &mut Game, sides: &[u8], max_min: f32) -> Outcome {
|
||||||
|
let mut o = Outcome::default();
|
||||||
|
let steps = (max_min * 60.0 / STEP) as usize;
|
||||||
|
for k in 0..steps {
|
||||||
|
g.update(STEP);
|
||||||
|
if g.state != 0 {
|
||||||
|
g.state = 0; // the harness decides the end
|
||||||
|
}
|
||||||
|
if k % 20 == 0 {
|
||||||
|
for &s in sides {
|
||||||
|
if !o.out_at.iter().any(|x| x.0 == s) && !alive(g, s) {
|
||||||
|
o.out_at.push((s, g.time / 60.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let left: Vec<u8> = sides.iter().copied().filter(|&s| !o.out_at.iter().any(|x| x.0 == s)).collect();
|
||||||
|
if left.len() <= 1 {
|
||||||
|
o.winner = left.first().copied();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.minutes = g.time / 60.0;
|
||||||
|
o.score = sides.iter().copied().filter(|&s| !o.out_at.iter().any(|x| x.0 == s)).map(|s| (s, score(g, s))).collect();
|
||||||
|
if o.winner.is_none() {
|
||||||
|
o.winner = o.score.iter().max_by(|a, b| a.1.partial_cmp(&b.1).unwrap()).map(|x| x.0);
|
||||||
|
}
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arena(seed: u32, size: usize) -> Game {
|
||||||
|
let mut g = Game::new(seed, size, size);
|
||||||
|
g.gate_on = false; // no hero waves: masters against each other
|
||||||
|
g.ai_difficulty = 1;
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seeds() -> Vec<u32> {
|
||||||
|
let n: u32 = std::env::var("BALANCE_SEEDS").ok().and_then(|s| s.parse().ok()).unwrap_or(6);
|
||||||
|
(1..=n).map(|k| 1000 + k * 37).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- difficulty duels
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn balance_difficulty() {
|
||||||
|
println!("\n== difficulty duels (2 masters, 112x112, no waves, 45 min) ==");
|
||||||
|
println!("{:<14} {:>5} {:>5} {:>5} {:>8}", "pair", "A", "B", "draw", "min");
|
||||||
|
// BALANCE_PAIR=a,b runs one pairing only
|
||||||
|
let pairs: Vec<(u8, u8)> = match std::env::var("BALANCE_PAIR").ok().and_then(|p| p.split_once(',').map(|(a, b)| (a.parse().ok(), b.parse().ok()))) {
|
||||||
|
Some((Some(a), Some(b))) => vec![(a, b)],
|
||||||
|
_ => vec![(0, 1), (1, 2), (0, 2), (1, 1)],
|
||||||
|
};
|
||||||
|
for (a, b) in pairs {
|
||||||
|
let (mut wa, mut wb, mut dr, mut mins) = (0, 0, 0, 0.0);
|
||||||
|
for (k, seed) in seeds().into_iter().enumerate() {
|
||||||
|
let mut g = arena(seed, 112);
|
||||||
|
// swap who plays which side every other seed (side 0 has the map's own start)
|
||||||
|
let (d0, d2) = if k % 2 == 0 { (a, b) } else { (b, a) };
|
||||||
|
pilot(&mut g, d0, None);
|
||||||
|
let (x, y) = corners(112, 112)[k % 3];
|
||||||
|
g.add_rival(2, x, y, d2);
|
||||||
|
let o = play(&mut g, &[0, 2], 45.0);
|
||||||
|
mins += o.minutes;
|
||||||
|
let a_side = if k % 2 == 0 { 0 } else { 2 };
|
||||||
|
let decisive = o.out_at.len() == 1;
|
||||||
|
match o.winner {
|
||||||
|
Some(w) if decisive && w == a_side => wa += 1,
|
||||||
|
Some(_) if decisive => wb += 1,
|
||||||
|
_ => dr += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let n = seeds().len() as f32;
|
||||||
|
println!("{:<14} {:>5} {:>5} {:>5} {:>8.1}", format!("{} vs {}", a, b), wa, wb, dr, mins / n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- talent branches
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn balance_talents() {
|
||||||
|
use crate::talents::*;
|
||||||
|
println!("\n== talent tournament (4 masters, medium, 128x128, no waves, 50 min) ==");
|
||||||
|
let names = ["builder", "warlord", "warlock", "merchant"];
|
||||||
|
let mut wins = [0usize; 4];
|
||||||
|
let mut place_sum = [0.0f32; 4];
|
||||||
|
let mut lv_sum = [0.0f32; 4];
|
||||||
|
let sides = [0u8, 2, 3, 4];
|
||||||
|
for (k, seed) in seeds().into_iter().enumerate() {
|
||||||
|
let mut g = arena(seed, 128);
|
||||||
|
// the branches move round the four starts from seed to seed
|
||||||
|
let branch_of = |s: usize| ((s + k) % 4) as u8;
|
||||||
|
pilot(&mut g, 1, Some(branch_of(0)));
|
||||||
|
for (n, &(x, y)) in corners(128, 128).iter().enumerate() {
|
||||||
|
g.add_rival(sides[n + 1], x, y, 1);
|
||||||
|
g.rivals.last_mut().unwrap().focus = Some(branch_of(n + 1));
|
||||||
|
}
|
||||||
|
let o = play(&mut g, &sides, 50.0);
|
||||||
|
// placement: the winner 1st, the survivors by score, the fallen by how long they lasted
|
||||||
|
let mut order: Vec<u8> = Vec::new();
|
||||||
|
let mut alive_sorted = o.score.clone();
|
||||||
|
alive_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||||
|
order.extend(alive_sorted.iter().map(|x| x.0));
|
||||||
|
let mut fallen = o.out_at.clone();
|
||||||
|
fallen.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||||
|
order.extend(fallen.iter().map(|x| x.0));
|
||||||
|
for (place, &s) in order.iter().enumerate() {
|
||||||
|
let n = sides.iter().position(|&x| x == s).unwrap();
|
||||||
|
let b = branch_of(n) as usize;
|
||||||
|
place_sum[b] += place as f32 + 1.0;
|
||||||
|
if place == 0 {
|
||||||
|
wins[b] += 1;
|
||||||
|
}
|
||||||
|
lv_sum[b] += g.talents_of(s).lv[b] as f32;
|
||||||
|
}
|
||||||
|
let _ = BRANCHES;
|
||||||
|
}
|
||||||
|
let n = seeds().len() as f32;
|
||||||
|
println!("{:<10} {:>5} {:>10} {:>10}", "branch", "wins", "avg place", "avg level");
|
||||||
|
for b in 0..4 {
|
||||||
|
println!("{:<10} {:>5} {:>10.2} {:>10.1}", names[b], wins[b], place_sum[b] / n, lv_sum[b] / n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- one master against the waves
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn balance_waves() {
|
||||||
|
println!("\n== one AI master against the hero waves (96x96, 40 min) ==");
|
||||||
|
println!("{:<6} {:>8} {:>6} {:>7} {:>7} {:>7} {:>6}", "diff", "fell@min", "wave", "heroes", "lost", "gold", "army");
|
||||||
|
for d in 0..3u8 {
|
||||||
|
for seed in seeds() {
|
||||||
|
let mut g = Game::new(seed, 96, 96);
|
||||||
|
g.ai_difficulty = d;
|
||||||
|
pilot(&mut g, d, None);
|
||||||
|
let mut fell = None;
|
||||||
|
for k in 0..((40.0 * 60.0 / STEP) as usize) {
|
||||||
|
g.update(STEP);
|
||||||
|
if g.state != 0 {
|
||||||
|
if g.core_hp <= 0.0 {
|
||||||
|
fell = Some(g.time / 60.0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
g.state = 0;
|
||||||
|
}
|
||||||
|
if std::env::var("BALANCE_TRACE").is_ok() && k % (120.0 / STEP) as usize == 0 {
|
||||||
|
let rooms: usize = g.counts.iter().sum();
|
||||||
|
let portal = (0..g.map.portals.len()).any(|p| g.map.portal_owner(p) == crate::map::Owner::Player);
|
||||||
|
println!(" seed {} d{} min {:>4.1}: creatures {:>3} imps {:>2} gold {:>6} rooms {:>3} lair {:>3} farm {:>3} wave {:>2} core {:>5} portal {} heroes near {}",
|
||||||
|
seed, d, g.time / 60.0, g.creature_count(), g.worker_count(), g.gold as i32, rooms, g.counts[1], g.counts[2], g.wave, g.core_hp as i32, portal,
|
||||||
|
g.ents.iter().filter(|e| e.alive && e.active_hero()).count());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{:<6} {:>8} {:>6} {:>7} {:>7} {:>7} {:>6}",
|
||||||
|
d,
|
||||||
|
fell.map_or("-".to_string(), |m| format!("{:.1}", m)),
|
||||||
|
g.wave,
|
||||||
|
g.stats.get(crate::stats::HEROES) as i32,
|
||||||
|
g.stats.get(crate::stats::LOST) as i32,
|
||||||
|
g.gold as i32,
|
||||||
|
g.side_army(0) as i32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// where the heroes of a wave really break in, against the marked entry points
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn balance_entry_points() {
|
||||||
|
use crate::map::*;
|
||||||
|
println!("\n== planned entry vs. where the heroes step onto the master's ground ==");
|
||||||
|
for seed in seeds() {
|
||||||
|
let mut g = Game::new(seed, 96, 96);
|
||||||
|
pilot(&mut g, 1, None);
|
||||||
|
let mut planned: Vec<(i32, i32)> = Vec::new();
|
||||||
|
let mut first_in: Option<(i32, i32)> = None;
|
||||||
|
let mut wave = 0;
|
||||||
|
for _ in 0..((20.0 * 60.0 / STEP) as usize) {
|
||||||
|
g.update(STEP);
|
||||||
|
if g.state != 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !g.planned_entries.is_empty() {
|
||||||
|
planned = g.planned_entries.clone();
|
||||||
|
}
|
||||||
|
if g.wave != wave {
|
||||||
|
wave = g.wave;
|
||||||
|
first_in = None;
|
||||||
|
}
|
||||||
|
if first_in.is_none() {
|
||||||
|
if let Some(e) = g.ents.iter().find(|e| e.alive && e.active_hero() && g.map.get(e.x as i32, e.y as i32).map_or(false, |t| t.owner == Owner::Player)) {
|
||||||
|
first_in = Some((e.x as i32, e.y as i32));
|
||||||
|
let d = planned.iter().map(|&(px, py)| (((px - e.x as i32).pow(2) + (py - e.y as i32).pow(2)) as f32).sqrt()).fold(f32::MAX, f32::min);
|
||||||
|
let wp = g.ents.iter().filter(|e| e.alive && e.active_hero()).filter(|e| e.waypoint.is_some()).count();
|
||||||
|
let heroes = g.ents.iter().filter(|e| e.alive && e.active_hero()).count();
|
||||||
|
let job = format!("{:?}", e.job);
|
||||||
|
println!("seed {} wave {}: planned {:?} first in at {:?} -> {:.1} tiles off; heroes with waypoint {}/{}; its job {}", seed, wave, planned, first_in.unwrap(), d, wp, heroes, job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Wild beasts: cave bears and giant beetles live in the open caverns of random maps. They defend
|
||||||
|
// their den like the dwarves defend their cave (they never stray far) and count for nobody. A
|
||||||
|
// master with room in a prison tames a beast he beats: instead of dying it gives in and joins.
|
||||||
|
|
||||||
|
use crate::game::*;
|
||||||
|
use crate::map::*;
|
||||||
|
|
||||||
|
pub fn beast(k: EK) -> bool {
|
||||||
|
matches!(k, EK::CaveBear | EK::Beetle)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
// a few dens in the caverns of a random map (deterministic, from the seed)
|
||||||
|
pub fn place_beasts(&mut self, seed: u32) {
|
||||||
|
let mut rng = Rng(seed.wrapping_mul(0x85eb_ca6b) ^ 0x27d4_eb2f | 1);
|
||||||
|
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
|
||||||
|
let area = (mw() * mh()) as f32;
|
||||||
|
// none on small maps (no room), one per ~95x95 tiles on bigger ones
|
||||||
|
let dens = (area / 9000.0) as usize;
|
||||||
|
let open: Vec<usize> = (0..self.map.t.len())
|
||||||
|
.filter(|&i| {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
let t = &self.map.t[i];
|
||||||
|
t.kind == Kind::Floor && t.owner == Owner::None && !self.map.sky(i) && (x - cx).abs().max((y - cy).abs()) > 18
|
||||||
|
&& DIR8.iter().all(|&(dx, dy)| self.map.walk(x + dx, y + dy))
|
||||||
|
&& !self.dwarf_caves.iter().any(|c| (c.at.0 - x).abs() + (c.at.1 - y).abs() < 12)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if open.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut placed: Vec<(i32, i32)> = Vec::new();
|
||||||
|
for _ in 0..dens * 6 {
|
||||||
|
if placed.len() >= dens {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let i = open[(rng.next() as usize) % open.len()];
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
if placed.iter().any(|&(px, py)| (px - x).abs() + (py - y).abs() < 20) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
placed.push((x, y));
|
||||||
|
let kind = if rng.f() < 0.5 { EK::CaveBear } else { EK::Beetle };
|
||||||
|
let n = 1 + (rng.next() % 2) as usize;
|
||||||
|
for k in 0..n {
|
||||||
|
let level = 2 + rng.next() % 3;
|
||||||
|
let id = self.spawn(kind, false, x as f32 + 0.5 + k as f32 * 0.6, y as f32 + 0.5, level);
|
||||||
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
||||||
|
e.side = crate::dwarves::DWARF_SIDE;
|
||||||
|
e.home = Some((x as f32 + 0.5, y as f32 + 0.5));
|
||||||
|
e.settled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a beaten wild beast: with room in a prison it is tamed instead of killed (true)
|
||||||
|
pub(crate) fn try_tame(&mut self, j: usize) -> bool {
|
||||||
|
let e = &self.ents[j];
|
||||||
|
if !beast(e.kind) || e.side != crate::dwarves::DWARF_SIDE || self.prison_space() == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let side = self.me_side;
|
||||||
|
let e = &mut self.ents[j];
|
||||||
|
e.side = side;
|
||||||
|
e.home = None;
|
||||||
|
e.guard = false;
|
||||||
|
e.hp = e.max_hp * 0.4;
|
||||||
|
e.happy = 0.6;
|
||||||
|
e.hunger = 0.5;
|
||||||
|
e.energy = 0.5;
|
||||||
|
e.job = Job::None;
|
||||||
|
e.path.clear();
|
||||||
|
e.bed = None;
|
||||||
|
e.settled = false;
|
||||||
|
let (x, y, kind) = (e.x, e.y, e.kind);
|
||||||
|
self.fx(7, x, y, 0.0, 0.0, 1.0, 0xff40c0ff);
|
||||||
|
self.sfx(Sfx::Convert, x, y);
|
||||||
|
self.msg("msg.beast_tamed", vec![format!("@{}", st(kind).name)]);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn beasts_live_in_caverns_and_can_be_tamed() {
|
||||||
|
let g0 = Game::new(9, 128, 96);
|
||||||
|
let beasts: Vec<&Ent> = g0.ents.iter().filter(|e| e.alive && beast(e.kind)).collect();
|
||||||
|
assert!(!beasts.is_empty(), "a den on a bigger map");
|
||||||
|
assert!(beasts.iter().all(|e| e.dwarf() && e.home.is_some()), "neutral, at home");
|
||||||
|
let mut g = g0;
|
||||||
|
let j = g.ents.iter().position(|e| e.alive && beast(e.kind)).unwrap();
|
||||||
|
// without a prison the beast dies
|
||||||
|
assert!(!g.try_tame(j));
|
||||||
|
// a prison: the beaten beast joins
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
g.build_rect(9, cx - 6, cy - 6, cx - 3, cy - 4);
|
||||||
|
g.gold = 99999.0;
|
||||||
|
let t = &mut g.map.t[idx(cx - 5, cy - 5)];
|
||||||
|
if t.kind != Kind::Prison {
|
||||||
|
t.kind = Kind::Prison;
|
||||||
|
t.owner = Owner::Player;
|
||||||
|
}
|
||||||
|
g.recount();
|
||||||
|
assert!(g.prison_space() > 0);
|
||||||
|
g.ents[j].hp = 0.0;
|
||||||
|
g.kill(j);
|
||||||
|
assert!(g.ents[j].alive && g.ents[j].side == 0 && !g.ents[j].dwarf(), "tamed");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,13 +10,13 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub const SIDES: [i32; 6] = [-1, 0, 1, 2, 3, 4];
|
pub const SIDES: [i32; 6] = [-1, 0, 1, 2, 3, 4];
|
||||||
|
|
||||||
// creature and hero kinds a block may name ("" = any)
|
// creature and hero kinds a block may name ("" = any)
|
||||||
pub const KINDS: [&str; 22] = [
|
pub const KINDS: [&str; 30] = [
|
||||||
"Worker", "Kobold", "Spider", "Mage", "Ogre", "Jailer", "Priestess", "Chainbreaker", "Bat", "Gargoyle", "Owl", "Wyrm", "Cogling", "Revenant", "Knight", "Archer", "Priest", "Dwarf", "Paladin", "Giant", "Fairy",
|
"Worker", "Kobold", "Spider", "Mage", "Ogre", "Jailer", "Priestess", "Chainbreaker", "Bat", "Gargoyle", "Owl", "Wyrm", "Cogling", "Revenant", "Knight", "Archer", "Priest", "Dwarf", "Paladin", "Giant", "Fairy",
|
||||||
"BattleMage",
|
"BattleMage", "Ram", "Catapult", "CaveBear", "Beetle", "Thief", "Monk", "Ranger", "Inquisitor",
|
||||||
];
|
];
|
||||||
|
|
||||||
// ground a Terrain block can make
|
// ground a Terrain block can make
|
||||||
pub const TERRAIN: [&str; 6] = ["floor", "earth", "rock", "water", "lava", "gold"];
|
pub const TERRAIN: [&str; 7] = ["floor", "earth", "rock", "water", "lava", "gold", "grove"];
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(tag = "b")]
|
#[serde(tag = "b")]
|
||||||
@@ -42,6 +42,8 @@ pub enum Block {
|
|||||||
Terrain { zone: String, kind: String },
|
Terrain { zone: String, kind: String },
|
||||||
Kill { zone: String, side: i32 },
|
Kill { zone: String, side: i32 },
|
||||||
Trigger { name: String, on: bool },
|
Trigger { name: String, on: bool },
|
||||||
|
Weather { kind: String, secs: Val }, // the weather above ground (secs 0: until changed again)
|
||||||
|
TalentXp { amount: Val }, // experience for the player's talents
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -70,6 +72,7 @@ pub enum Val {
|
|||||||
Or { a: Box<Val>, b: Box<Val> },
|
Or { a: Box<Val>, b: Box<Val> },
|
||||||
Not { a: Box<Val> },
|
Not { a: Box<Val> },
|
||||||
EventKind { kind: String }, // the unit that set off the trigger is of this kind
|
EventKind { kind: String }, // the unit that set off the trigger is of this kind
|
||||||
|
WeatherIs { kind: String }, // the weather above ground is ...
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const MATH_OPS: [&str; 4] = ["+", "-", "*", "/"];
|
pub const MATH_OPS: [&str; 4] = ["+", "-", "*", "/"];
|
||||||
@@ -89,7 +92,7 @@ impl Val {
|
|||||||
|
|
||||||
pub fn ty(&self) -> Ty {
|
pub fn ty(&self) -> Ty {
|
||||||
match self {
|
match self {
|
||||||
Val::Bool { .. } | Val::Cmp { .. } | Val::And { .. } | Val::Or { .. } | Val::Not { .. } | Val::EventKind { .. } => Ty::Bool,
|
Val::Bool { .. } | Val::Cmp { .. } | Val::And { .. } | Val::Or { .. } | Val::Not { .. } | Val::EventKind { .. } | Val::WeatherIs { .. } => Ty::Bool,
|
||||||
_ => Ty::Num,
|
_ => Ty::Num,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,6 +116,7 @@ impl Val {
|
|||||||
"or" => Val::Or { a: Box::new(Val::Bool { b: true }), b: Box::new(Val::Bool { b: false }) },
|
"or" => Val::Or { a: Box::new(Val::Bool { b: true }), b: Box::new(Val::Bool { b: false }) },
|
||||||
"not" => Val::Not { a: Box::new(Val::Bool { b: true }) },
|
"not" => Val::Not { a: Box::new(Val::Bool { b: true }) },
|
||||||
"eventkind" => Val::EventKind { kind: "Knight".into() },
|
"eventkind" => Val::EventKind { kind: "Knight".into() },
|
||||||
|
"weatheris" => Val::WeatherIs { kind: "rain".into() },
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -135,16 +139,20 @@ impl Val {
|
|||||||
Val::Or { .. } => "or",
|
Val::Or { .. } => "or",
|
||||||
Val::Not { .. } => "not",
|
Val::Not { .. } => "not",
|
||||||
Val::EventKind { .. } => "eventkind",
|
Val::EventKind { .. } => "eventkind",
|
||||||
|
Val::WeatherIs { .. } => "weatheris",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// value kinds by type, for the value picker
|
// value kinds by type, for the value picker
|
||||||
pub const NUM_VALUES: [&str; 9] = ["num", "var", "gold", "mana", "time", "creatures", "inzone", "random", "math"];
|
pub const NUM_VALUES: [&str; 9] = ["num", "var", "gold", "mana", "time", "creatures", "inzone", "random", "math"];
|
||||||
pub const BOOL_VALUES: [&str; 6] = ["cmp", "and", "or", "not", "bool", "eventkind"];
|
pub const BOOL_VALUES: [&str; 7] = ["cmp", "and", "or", "not", "bool", "eventkind", "weatheris"];
|
||||||
|
|
||||||
|
// the weather a block can set or ask for (weather.rs: FAIR, RAIN, FOG, STORM)
|
||||||
|
pub const WEATHER: [&str; 4] = ["fair", "rain", "fog", "storm"];
|
||||||
|
|
||||||
// statement blocks in the palette: (category, kind)
|
// statement blocks in the palette: (category, kind)
|
||||||
pub const PALETTE: [(&str, &str); 21] = [
|
pub const PALETTE: [(&str, &str); 23] = [
|
||||||
("say", "message"),
|
("say", "message"),
|
||||||
("say", "objective"),
|
("say", "objective"),
|
||||||
("say", "hint"),
|
("say", "hint"),
|
||||||
@@ -156,6 +164,8 @@ pub const PALETTE: [(&str, &str); 21] = [
|
|||||||
("world", "camera"),
|
("world", "camera"),
|
||||||
("world", "gold"),
|
("world", "gold"),
|
||||||
("world", "mana"),
|
("world", "mana"),
|
||||||
|
("world", "weather"),
|
||||||
|
("world", "talentxp"),
|
||||||
("logic", "if"),
|
("logic", "if"),
|
||||||
("logic", "repeat"),
|
("logic", "repeat"),
|
||||||
("logic", "until"),
|
("logic", "until"),
|
||||||
@@ -185,6 +195,8 @@ impl Block {
|
|||||||
"camera" => Block::Camera { zone: String::new() },
|
"camera" => Block::Camera { zone: String::new() },
|
||||||
"gold" => Block::Gold { amount: n(500.0) },
|
"gold" => Block::Gold { amount: n(500.0) },
|
||||||
"mana" => Block::Mana { amount: n(100.0) },
|
"mana" => Block::Mana { amount: n(100.0) },
|
||||||
|
"weather" => Block::Weather { kind: "storm".into(), secs: n(0.0) },
|
||||||
|
"talentxp" => Block::TalentXp { amount: n(300.0) },
|
||||||
"if" => Block::If { cond: Val::template("cmp").unwrap(), then: Vec::new(), els: Vec::new() },
|
"if" => Block::If { cond: Val::template("cmp").unwrap(), then: Vec::new(), els: Vec::new() },
|
||||||
"repeat" => Block::Repeat { times: n(3.0), body: Vec::new() },
|
"repeat" => Block::Repeat { times: n(3.0), body: Vec::new() },
|
||||||
"until" => Block::Until { cond: Val::template("cmp").unwrap(), body: Vec::new() },
|
"until" => Block::Until { cond: Val::template("cmp").unwrap(), body: Vec::new() },
|
||||||
@@ -212,6 +224,8 @@ impl Block {
|
|||||||
Block::Camera { .. } => "camera",
|
Block::Camera { .. } => "camera",
|
||||||
Block::Gold { .. } => "gold",
|
Block::Gold { .. } => "gold",
|
||||||
Block::Mana { .. } => "mana",
|
Block::Mana { .. } => "mana",
|
||||||
|
Block::Weather { .. } => "weather",
|
||||||
|
Block::TalentXp { .. } => "talentxp",
|
||||||
Block::If { .. } => "if",
|
Block::If { .. } => "if",
|
||||||
Block::Repeat { .. } => "repeat",
|
Block::Repeat { .. } => "repeat",
|
||||||
Block::Until { .. } => "until",
|
Block::Until { .. } => "until",
|
||||||
@@ -258,6 +272,7 @@ fn check_val(v: &Val, want: Ty, n: &Names, path: &[usize], out: &mut Vec<Problem
|
|||||||
Val::Creatures { kind, .. } if !kind.is_empty() && !KINDS.contains(&kind.as_str()) => out.push(p("blk.err_kind")),
|
Val::Creatures { kind, .. } if !kind.is_empty() && !KINDS.contains(&kind.as_str()) => out.push(p("blk.err_kind")),
|
||||||
Val::InZone { zone, .. } if !n.zones.iter().any(|z| z == zone) => out.push(p("blk.err_zone")),
|
Val::InZone { zone, .. } if !n.zones.iter().any(|z| z == zone) => out.push(p("blk.err_zone")),
|
||||||
Val::EventKind { kind } if !KINDS.contains(&kind.as_str()) => out.push(p("blk.err_kind")),
|
Val::EventKind { kind } if !KINDS.contains(&kind.as_str()) => out.push(p("blk.err_kind")),
|
||||||
|
Val::WeatherIs { kind } if !WEATHER.contains(&kind.as_str()) => out.push(p("blk.err_weather")),
|
||||||
Val::Random { a, b } | Val::Math { a, b, .. } => {
|
Val::Random { a, b } | Val::Math { a, b, .. } => {
|
||||||
check_val(a, Ty::Num, n, path, out);
|
check_val(a, Ty::Num, n, path, out);
|
||||||
check_val(b, Ty::Num, n, path, out);
|
check_val(b, Ty::Num, n, path, out);
|
||||||
@@ -295,7 +310,13 @@ fn check_list(list: &[Block], n: &Names, path: &mut Vec<usize>, out: &mut Vec<Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Block::Wave { count } => check_val(count, Ty::Num, n, path, out),
|
Block::Wave { count } => check_val(count, Ty::Num, n, path, out),
|
||||||
Block::Gold { amount } | Block::Mana { amount } => check_val(amount, Ty::Num, n, path, out),
|
Block::Gold { amount } | Block::Mana { amount } | Block::TalentXp { amount } => check_val(amount, Ty::Num, n, path, out),
|
||||||
|
Block::Weather { kind, secs } => {
|
||||||
|
if !WEATHER.contains(&kind.as_str()) {
|
||||||
|
out.push(p("blk.err_weather", path));
|
||||||
|
}
|
||||||
|
check_val(secs, Ty::Num, n, path, out);
|
||||||
|
}
|
||||||
Block::SetVar { name, value } | Block::AddVar { name, value } => {
|
Block::SetVar { name, value } | Block::AddVar { name, value } => {
|
||||||
if !valid_var(name) {
|
if !valid_var(name) {
|
||||||
out.push(p("blk.err_var", path));
|
out.push(p("blk.err_var", path));
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// Bosses: every fifth wave is led by one of the Order's champions - a paladin with a name, more
|
||||||
|
// than twice the health, an ability of his own and loot for whoever brings him down (gold and a
|
||||||
|
// free rune forge tower). His health bar stands at the top of the screen while he lives.
|
||||||
|
|
||||||
|
use crate::game::*;
|
||||||
|
use crate::particles::*;
|
||||||
|
|
||||||
|
// the champions (Ent::boss = index + 1): ability cooldowns in seconds
|
||||||
|
pub const BOSSES: [(&str, f32); 4] = [("aldric", 12.0), ("seraphine", 10.0), ("halvard", 18.0), ("rothgar", 9.0)];
|
||||||
|
pub const BOSS_HP: f32 = 2.5; // health factor
|
||||||
|
pub const BOSS_GOLD: f32 = 600.0;
|
||||||
|
|
||||||
|
pub fn boss_name(b: u8) -> String {
|
||||||
|
format!("@boss.{}", BOSSES[(b.max(1) as usize - 1) % BOSSES.len()].0)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
// the unit at index j leads the wave as champion number `b` (1..=4)
|
||||||
|
pub(crate) fn make_boss(&mut self, j: usize, b: u8) {
|
||||||
|
let e = &mut self.ents[j];
|
||||||
|
e.boss = b;
|
||||||
|
e.max_hp *= BOSS_HP;
|
||||||
|
e.hp = e.max_hp;
|
||||||
|
e.ab_cd = 4.0;
|
||||||
|
let name = boss_name(b);
|
||||||
|
self.msg("msg.boss_comes", vec![name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the champion's ability, used by itself whenever it is ready
|
||||||
|
pub(crate) fn boss_ability(&mut self, e: &mut Ent, dt: f32) {
|
||||||
|
e.ab_cd -= dt;
|
||||||
|
if e.ab_cd > 0.0 || e.captive != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (bx, by) = (e.x, e.y);
|
||||||
|
let idx_b = (e.boss.max(1) as usize - 1) % BOSSES.len();
|
||||||
|
let foe = |o: &Ent| o.alive && !o.held && !o.hero && o.captive == 0 && !o.chained;
|
||||||
|
let near = |o: &Ent, r: f32| (o.x - bx).powi(2) + (o.y - by).powi(2) < r * r;
|
||||||
|
let used = match idx_b {
|
||||||
|
// Sir Aldric: shield wall - heroes around are healed and shielded
|
||||||
|
0 => {
|
||||||
|
for o in self.ents.iter_mut().filter(|o| o.alive && o.hero && (o.x - bx).powi(2) + (o.y - by).powi(2) < 16.0) {
|
||||||
|
o.hp = (o.hp + 60.0).min(o.max_hp);
|
||||||
|
o.shield = o.shield.max(6.0);
|
||||||
|
}
|
||||||
|
self.part(RING, (bx, by, 0.1), (0.0, 0.0, 0.0), 0.7, abgr(255, 230, 140), 4.0);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Dame Seraphine: radiant burst - the dungeon's units around are burnt by light
|
||||||
|
1 => {
|
||||||
|
let hit: Vec<usize> = (0..self.ents.len()).filter(|&k| foe(&self.ents[k]) && near(&self.ents[k], 3.0)).collect();
|
||||||
|
if hit.is_empty() {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
for k in hit {
|
||||||
|
self.damage_unit(k, 45.0);
|
||||||
|
}
|
||||||
|
self.part(FLASH, (bx, by, 0.5), (0.0, 0.0, 0.0), 0.5, abgr(255, 250, 200), 3.0);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Brother Halvard: call to arms - two knights join him
|
||||||
|
2 => {
|
||||||
|
let level = e.level;
|
||||||
|
for k in 0..2 {
|
||||||
|
let a = k as f32 * 3.1;
|
||||||
|
let id = self.spawn(EK::Knight, true, bx + a.cos(), by + a.sin(), level.max(2) - 1);
|
||||||
|
if let Some(o) = self.ents.iter_mut().find(|o| o.id == id) {
|
||||||
|
o.target = e.target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.fx(4, bx, by, 0.0, 0.0, 0.8, 0xffffe0a0);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Captain Rothgar: charge - he rushes at an enemy, hits hard and stuns
|
||||||
|
_ => {
|
||||||
|
let target = (0..self.ents.len()).filter(|&k| foe(&self.ents[k]) && near(&self.ents[k], 6.0)).min_by(|&a, &b| {
|
||||||
|
let da = (self.ents[a].x - bx).powi(2) + (self.ents[a].y - by).powi(2);
|
||||||
|
let db = (self.ents[b].x - bx).powi(2) + (self.ents[b].y - by).powi(2);
|
||||||
|
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
match target {
|
||||||
|
Some(k) => {
|
||||||
|
let (tx, ty) = (self.ents[k].x, self.ents[k].y);
|
||||||
|
let d = ((tx - bx).powi(2) + (ty - by).powi(2)).sqrt().max(0.01);
|
||||||
|
if self.map.walk((tx - (tx - bx) / d * 0.8) as i32, (ty - (ty - by) / d * 0.8) as i32) {
|
||||||
|
e.x = tx - (tx - bx) / d * 0.8;
|
||||||
|
e.y = ty - (ty - by) / d * 0.8;
|
||||||
|
e.path.clear();
|
||||||
|
}
|
||||||
|
self.ents[k].stun = self.ents[k].stun.max(1.5);
|
||||||
|
self.damage_unit(k, 50.0);
|
||||||
|
for _ in 0..10 {
|
||||||
|
let v = (self.vrng.f() * 2.0 - 1.0, self.vrng.f() * 2.0 - 1.0, 0.5);
|
||||||
|
self.parts.push(Part { kind: DEBRIS, x: bx, y: by, h: 0.1, vx: v.0, vy: v.1, vh: v.2, t: 0.0, max: 0.8, col: abgr(150, 130, 110), size: 0.05 });
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
e.ab_cd = if used { BOSSES[idx_b].1 } else { 1.0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// a champion fell (or was captured): his loot
|
||||||
|
pub(crate) fn boss_loot(&mut self, j: usize) {
|
||||||
|
let b = self.ents[j].boss;
|
||||||
|
if b == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.ents[j].boss = 0;
|
||||||
|
self.count_local(crate::stats::BOSSES);
|
||||||
|
let got = self.add_gold(BOSS_GOLD);
|
||||||
|
self.rune_free += 1;
|
||||||
|
let (x, y) = (self.ents[j].x, self.ents[j].y);
|
||||||
|
self.fx(7, x, y, 0.0, 0.0, 1.2, 0xff40e0ff);
|
||||||
|
self.sfx_ui(Sfx::Victory);
|
||||||
|
self.msg("msg.boss_slain", vec![boss_name(b), (got as i32).to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the living champion with the most health (for the bar at the top)
|
||||||
|
pub fn boss_alive(&self) -> Option<&Ent> {
|
||||||
|
self.ents.iter().filter(|e| e.alive && e.boss > 0 && e.captive == 0).max_by(|a, b| a.hp.partial_cmp(&b.hp).unwrap_or(std::cmp::Ordering::Equal))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_fifth_wave_brings_a_champion_with_loot() {
|
||||||
|
let mut g = Game::new(8, 80, 64);
|
||||||
|
g.wave = 4; // the next wave is the fifth
|
||||||
|
let before = g.ents.len();
|
||||||
|
g.spawn_wave();
|
||||||
|
let boss = g.ents[before..].iter().position(|e| e.boss > 0).map(|k| k + before).expect("a champion");
|
||||||
|
assert!(g.ents[boss].max_hp > st(g.ents[boss].kind).hp * 2.0);
|
||||||
|
assert!(g.boss_alive().is_some());
|
||||||
|
let (gold, runes) = (g.gold, g.rune_free);
|
||||||
|
g.boss_loot(boss);
|
||||||
|
assert!(g.gold > gold && g.rune_free == runes + 1);
|
||||||
|
assert!(g.boss_alive().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn champions_use_their_abilities() {
|
||||||
|
for b in 1..=4u8 {
|
||||||
|
let mut g = Game::new(8, 80, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let (x, y) = (cx as f32 + 1.5, cy as f32 + 4.5);
|
||||||
|
let id = g.spawn(EK::Paladin, true, x, y, 5);
|
||||||
|
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
||||||
|
g.make_boss(j, b);
|
||||||
|
// an ogre of the dungeon close by
|
||||||
|
g.spawn(EK::Ogre, false, x + 1.5, y, 3);
|
||||||
|
let n0 = g.ents.len();
|
||||||
|
let mut e = g.ents[j].clone();
|
||||||
|
e.ab_cd = 0.0;
|
||||||
|
e.hp = e.max_hp * 0.5;
|
||||||
|
g.boss_ability(&mut e, 0.1);
|
||||||
|
assert!(e.ab_cd > 1.5, "champion {} used his ability", b);
|
||||||
|
if b == 3 {
|
||||||
|
assert_eq!(g.ents.len(), n0 + 2, "two knights joined");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
// Bridges under strain: wooden bridges catch fire (fire traps, fire breath, fireballs, lightning),
|
||||||
|
// the fire spreads along the planks and the bridge collapses into the river; knights wear bridges
|
||||||
|
// down step by step, pit imps mend them again. Stone bridges over lava never burn.
|
||||||
|
// Drawbridges are raised and lowered by hand or rise by themselves when enemies come near.
|
||||||
|
// Frost (the frost glow trap, the frost spell) cools lava into walkable obsidian for a while.
|
||||||
|
|
||||||
|
use crate::game::*;
|
||||||
|
use crate::map::*;
|
||||||
|
use crate::particles::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// seconds a burning plank holds before it falls into the river
|
||||||
|
pub const BURN_TIME: f32 = 6.0;
|
||||||
|
// a burning plank sets a neighbouring wooden plank alight with this chance per second
|
||||||
|
pub const BURN_SPREAD: f32 = 0.35;
|
||||||
|
// fire damage per second to whoever stands on a burning plank
|
||||||
|
pub const BURN_DPS: f32 = 6.0;
|
||||||
|
// wear a knight does to a bridge tile per second (1.0 = it falls)
|
||||||
|
pub const TEAR_RATE: f32 = 0.12;
|
||||||
|
// wear an imp mends per second
|
||||||
|
pub const MEND_RATE: f32 = 0.25;
|
||||||
|
// an automatic drawbridge rises when an enemy comes this close (tiles)
|
||||||
|
pub const DRAWBRIDGE_WATCH: f32 = 6.0;
|
||||||
|
// seconds the frost spell keeps lava cooled; the last seconds it steams
|
||||||
|
pub const FREEZE_TIME: f32 = 40.0;
|
||||||
|
pub const MELT_WARN: f32 = 5.0;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct BridgeWork {
|
||||||
|
pub burning: Vec<(usize, f32)>, // tile, seconds it burns already
|
||||||
|
pub wear: Vec<(usize, f32)>, // tile, damage 0..1
|
||||||
|
#[serde(default)]
|
||||||
|
pub cooled: Vec<(usize, f32)>, // obsidian tile, seconds until it melts
|
||||||
|
#[serde(default)]
|
||||||
|
pub watch_t: f32, // automatic drawbridges look around twice a second
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
// a wooden bridge (or wooden drawbridge) that can burn
|
||||||
|
pub fn wooden_bridge(&self, i: usize) -> bool {
|
||||||
|
self.map.t.get(i).map_or(false, |t| t.kind == Kind::Bridge || (t.kind == Kind::Drawbridge && t.res & DB_LAVA == 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn burning(&self, i: usize) -> bool {
|
||||||
|
self.bridgework.burning.iter().any(|b| b.0 == i)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wear_of(&self, i: usize) -> f32 {
|
||||||
|
self.bridgework.wear.iter().find(|w| w.0 == i).map_or(0.0, |w| w.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fire reaches tile (x, y): a wooden bridge there starts to burn
|
||||||
|
pub fn ignite_at(&mut self, x: f32, y: f32) {
|
||||||
|
let (tx, ty) = (x.floor() as i32, y.floor() as i32);
|
||||||
|
if !inb(tx, ty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let i = idx(tx, ty);
|
||||||
|
// nothing catches fire in the rain
|
||||||
|
if !self.wooden_bridge(i) || self.burning(i) || self.rain_at(i) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.bridgework.burning.push((i, 0.0));
|
||||||
|
self.fx(3, tx as f32 + 0.5, ty as f32 + 0.5, 0.0, 0.0, 0.5, 0xff2080ff);
|
||||||
|
if self.map.t[i].owner == self.me {
|
||||||
|
self.msg("msg.bridge_fire", vec![]);
|
||||||
|
self.hint(32, "hint.bridge_fire");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fire along a jet from (x, y) in direction (dx, dy) over `len` tiles
|
||||||
|
pub fn ignite_line(&mut self, x: f32, y: f32, dx: f32, dy: f32, len: f32) {
|
||||||
|
let steps = (len * 3.0) as usize;
|
||||||
|
for s in 1..=steps {
|
||||||
|
let f = s as f32 / 3.0;
|
||||||
|
self.ignite_at(x + dx * f, y + dy * f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the shared part of every step: fire, collapse, drawbridges, melting obsidian
|
||||||
|
pub(crate) fn update_bridges(&mut self, dt: f32) {
|
||||||
|
self.update_drawbridges(dt);
|
||||||
|
self.update_obsidian(dt);
|
||||||
|
if self.bridgework.burning.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut k = 0;
|
||||||
|
while k < self.bridgework.burning.len() {
|
||||||
|
let (i, t0) = self.bridgework.burning[k];
|
||||||
|
// the plank is gone already (torn down, sold, rebuilt)
|
||||||
|
if !self.wooden_bridge(i) {
|
||||||
|
self.bridgework.burning.swap_remove(k);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let t = t0 + dt;
|
||||||
|
self.bridgework.burning[k].1 = t;
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||||
|
// flames and smoke (looks only)
|
||||||
|
if self.vrng.f() < dt * 14.0 {
|
||||||
|
let (ox, oy) = (self.vrng.f() - 0.5, self.vrng.f() - 0.5);
|
||||||
|
let v = (self.vrng.f() * 0.4 - 0.2, self.vrng.f() * 0.4 - 0.2, 0.9 + self.vrng.f() * 0.8);
|
||||||
|
self.parts.push(Part { kind: EMBER, x: fx + ox * 0.9, y: fy + oy * 0.9, h: 0.15, vx: v.0, vy: v.1, vh: v.2, t: 0.0, max: 0.7, col: abgr(255, 120 + (self.vrng.f() * 80.0) as u8, 30), size: 0.08 });
|
||||||
|
}
|
||||||
|
if self.vrng.f() < dt * 3.0 {
|
||||||
|
self.parts.push(Part { kind: SMOKE, x: fx + self.vrng.f() - 0.5, y: fy + self.vrng.f() - 0.5, h: 0.5, vx: 0.0, vy: 0.0, vh: 0.6, t: 0.0, max: 2.2, col: abgr(40, 34, 30), size: 0.18 });
|
||||||
|
}
|
||||||
|
// whoever stands in the flames gets burnt
|
||||||
|
for j in 0..self.ents.len() {
|
||||||
|
let e = &self.ents[j];
|
||||||
|
if e.alive && !e.held && !flies(e.kind) && e.tile() == i {
|
||||||
|
self.ents[j].hp -= BURN_DPS * dt;
|
||||||
|
if self.ents[j].hp <= 0.0 {
|
||||||
|
self.kill(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the fire spreads along the planks (deterministic: the shared random numbers)
|
||||||
|
if t > 1.5 && (t / 1.0).floor() != ((t - dt) / 1.0).floor() {
|
||||||
|
for &(dx, dy) in DIR4.iter() {
|
||||||
|
if !inb(x + dx, y + dy) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let n = idx(x + dx, y + dy);
|
||||||
|
if self.wooden_bridge(n) && !self.burning(n) && self.rng.f() < BURN_SPREAD {
|
||||||
|
self.bridgework.burning.push((n, 0.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t >= BURN_TIME {
|
||||||
|
self.bridgework.burning.swap_remove(k);
|
||||||
|
self.count_local(crate::stats::BURNT);
|
||||||
|
self.bridge_falls(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
k += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a bridge tile falls into its river: whoever stands there goes into the water (or lava)
|
||||||
|
pub(crate) fn bridge_falls(&mut self, i: usize) {
|
||||||
|
let t = self.map.t[i];
|
||||||
|
if !t.bridge() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mine = t.owner == self.me;
|
||||||
|
let tl = &mut self.map.t[i];
|
||||||
|
tl.kind = if t.river() == Some(Kind::Lava) { Kind::Lava } else { Kind::Water };
|
||||||
|
tl.owner = Owner::None;
|
||||||
|
tl.reserved = 0;
|
||||||
|
tl.res = 0;
|
||||||
|
self.bridgework.wear.retain(|w| w.0 != i);
|
||||||
|
self.bridgework.burning.retain(|w| w.0 != i);
|
||||||
|
self.ground_lost(i);
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
self.fx(6, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.6, 0xff3a5a7a);
|
||||||
|
for _ in 0..6 {
|
||||||
|
let v = (self.vrng.f() * 2.0 - 1.0, self.vrng.f() * 2.0 - 1.0, 0.6 + self.vrng.f());
|
||||||
|
self.parts.push(Part { kind: DEBRIS, x: x as f32 + 0.5, y: y as f32 + 0.5, h: 0.2, vx: v.0, vy: v.1, vh: v.2, t: 0.0, max: 1.2, col: abgr(90, 62, 34), size: 0.08 });
|
||||||
|
}
|
||||||
|
self.sfx(Sfx::DigDone, x as f32 + 0.5, y as f32 + 0.5);
|
||||||
|
if mine {
|
||||||
|
self.msg("msg.bridge_torn", vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a knight works on a bridge tile: true while it still stands
|
||||||
|
pub(crate) fn wear_bridge(&mut self, i: usize, dt: f32) -> bool {
|
||||||
|
let w = self.wear_of(i) + TEAR_RATE * dt;
|
||||||
|
if w >= 1.0 {
|
||||||
|
self.bridge_falls(i);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match self.bridgework.wear.iter_mut().find(|x| x.0 == i) {
|
||||||
|
Some(x) => x.1 = w,
|
||||||
|
None => self.bridgework.wear.push((i, w)),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// an imp mends a worn bridge tile: true while there is more to do
|
||||||
|
pub(crate) fn mend_bridge(&mut self, i: usize, dt: f32) -> bool {
|
||||||
|
let Some(k) = self.bridgework.wear.iter().position(|x| x.0 == i) else { return false };
|
||||||
|
self.bridgework.wear[k].1 -= MEND_RATE * dt;
|
||||||
|
if self.bridgework.wear[k].1 <= 0.0 {
|
||||||
|
self.bridgework.wear.swap_remove(k);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// a worn (not burning) bridge tile of the master being processed next to or at tile i
|
||||||
|
pub(crate) fn worn_bridge_near(&self, i: usize) -> Option<usize> {
|
||||||
|
if self.bridgework.wear.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
std::iter::once((0, 0)).chain(DIR4.iter().copied()).find_map(|(dx, dy)| {
|
||||||
|
let n = idx(x + dx, y + dy);
|
||||||
|
let ok = inb(x + dx, y + dy) && self.map.t[n].bridge() && self.map.t[n].owner == self.me && self.wear_of(n) > 0.0 && !self.burning(n);
|
||||||
|
ok.then_some(n)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ drawbridges and frost
|
||||||
|
impl Game {
|
||||||
|
// the drawbridge tiles connected to tile i (same owner)
|
||||||
|
pub fn drawbridge_group(&self, i: usize) -> Vec<usize> {
|
||||||
|
let owner = self.map.t[i].owner;
|
||||||
|
let mut group = vec![i];
|
||||||
|
let mut k = 0;
|
||||||
|
while k < group.len() {
|
||||||
|
let (x, y) = xy(group[k]);
|
||||||
|
for &(dx, dy) in DIR4.iter() {
|
||||||
|
if !inb(x + dx, y + dy) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let n = idx(x + dx, y + dy);
|
||||||
|
let t = &self.map.t[n];
|
||||||
|
if t.kind == Kind::Drawbridge && t.owner == owner && !group.contains(&n) {
|
||||||
|
group.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k += 1;
|
||||||
|
}
|
||||||
|
group
|
||||||
|
}
|
||||||
|
|
||||||
|
// the drawbridge tool: a click on an own drawbridge switches it (down -> up -> automatic -> down),
|
||||||
|
// anything else builds drawbridges like bridges
|
||||||
|
pub fn drawbridge_order(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
|
||||||
|
if x0 == x1 && y0 == y1 && inb(x0, y0) {
|
||||||
|
let i = idx(x0, y0);
|
||||||
|
let t = self.map.t[i];
|
||||||
|
if t.kind == Kind::Drawbridge && t.owner == self.me {
|
||||||
|
let state = t.res & (DB_RAISED | DB_AUTO);
|
||||||
|
let next = match state {
|
||||||
|
0 => DB_RAISED,
|
||||||
|
s if s & DB_AUTO != 0 => 0,
|
||||||
|
_ => DB_AUTO,
|
||||||
|
};
|
||||||
|
self.set_drawbridge(i, next);
|
||||||
|
let key = match next {
|
||||||
|
DB_RAISED => "msg.drawbridge_up",
|
||||||
|
DB_AUTO => "msg.drawbridge_auto",
|
||||||
|
_ => "msg.drawbridge_down",
|
||||||
|
};
|
||||||
|
self.msg(key, vec![]);
|
||||||
|
self.sfx_ui(Sfx::Click);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.build_bridges_of(x0, y0, x1, y1, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sets the state bits (DB_RAISED, DB_AUTO) of the whole drawbridge at tile i
|
||||||
|
pub fn set_drawbridge(&mut self, i: usize, state: u8) {
|
||||||
|
let group = self.drawbridge_group(i);
|
||||||
|
let raise = state & DB_RAISED != 0 && !self.map.t[i].raised();
|
||||||
|
let lower = state & DB_RAISED == 0 && self.map.t[i].raised();
|
||||||
|
for &g in &group {
|
||||||
|
let t = &mut self.map.t[g];
|
||||||
|
t.res = (t.res & DB_LAVA) | state;
|
||||||
|
}
|
||||||
|
if raise {
|
||||||
|
self.count(crate::stats::DRAWBRIDGE, 1.0);
|
||||||
|
// whoever stands on it jumps to the bank (or falls)
|
||||||
|
for &g in &group {
|
||||||
|
self.ground_lost(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raise || lower {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
self.sfx(Sfx::Build, x as f32 + 0.5, y as f32 + 0.5);
|
||||||
|
for &g in &group {
|
||||||
|
let (gx, gy) = xy(g);
|
||||||
|
self.fx(6, gx as f32 + 0.5, gy as f32 + 0.5, 0.0, 0.0, 0.3, 0xff506070);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// automatic drawbridges rise while an enemy of their master is near, and come down again
|
||||||
|
fn update_drawbridges(&mut self, dt: f32) {
|
||||||
|
self.bridgework.watch_t -= dt;
|
||||||
|
if self.bridgework.watch_t > 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.bridgework.watch_t = 0.5;
|
||||||
|
let mut seen: Vec<usize> = Vec::new();
|
||||||
|
for i in 0..self.map.t.len() {
|
||||||
|
let t = self.map.t[i];
|
||||||
|
if t.kind != Kind::Drawbridge || t.res & DB_AUTO == 0 || seen.contains(&i) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let group = self.drawbridge_group(i);
|
||||||
|
seen.extend(group.iter().copied());
|
||||||
|
let side = t.owner.side();
|
||||||
|
let r2 = DRAWBRIDGE_WATCH * DRAWBRIDGE_WATCH;
|
||||||
|
let enemy = self.ents.iter().any(|e| {
|
||||||
|
e.alive
|
||||||
|
&& !e.held
|
||||||
|
&& e.captive == 0
|
||||||
|
&& !e.chained
|
||||||
|
&& e.side() != side
|
||||||
|
&& !self.allied(side, e.side())
|
||||||
|
&& group.iter().any(|&g| {
|
||||||
|
let (gx, gy) = xy(g);
|
||||||
|
(e.x - gx as f32 - 0.5).powi(2) + (e.y - gy as f32 - 0.5).powi(2) < r2
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if enemy != t.raised() {
|
||||||
|
self.set_drawbridge(i, DB_AUTO | if enemy { DB_RAISED } else { 0 });
|
||||||
|
if enemy && t.owner == self.me {
|
||||||
|
self.msg("msg.drawbridge_rises", vec![]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frost on tile i: lava turns to obsidian (or stays it) for at least `secs`
|
||||||
|
pub fn cool_lava(&mut self, i: usize, secs: f32) {
|
||||||
|
let t = &mut self.map.t[i];
|
||||||
|
match t.kind {
|
||||||
|
Kind::Lava => {
|
||||||
|
t.kind = Kind::Obsidian;
|
||||||
|
t.owner = Owner::None;
|
||||||
|
self.bridgework.cooled.push((i, secs));
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
for _ in 0..3 {
|
||||||
|
self.parts.push(Part { kind: SMOKE, x: x as f32 + self.vrng.f(), y: y as f32 + self.vrng.f(), h: 0.1, vx: 0.0, vy: 0.0, vh: 0.5, t: 0.0, max: 1.6, col: abgr(200, 210, 220), size: 0.16 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Obsidian => {
|
||||||
|
if let Some(c) = self.bridgework.cooled.iter_mut().find(|c| c.0 == i) {
|
||||||
|
c.1 = c.1.max(secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frost in a circle around (x, y)
|
||||||
|
pub fn cool_lava_around(&mut self, x: f32, y: f32, r: f32, secs: f32) {
|
||||||
|
let ri = r.ceil() as i32;
|
||||||
|
let (cx, cy) = (x.floor() as i32, y.floor() as i32);
|
||||||
|
for ty in cy - ri..=cy + ri {
|
||||||
|
for tx in cx - ri..=cx + ri {
|
||||||
|
if inb(tx, ty) && (tx as f32 + 0.5 - x).powi(2) + (ty as f32 + 0.5 - y).powi(2) <= r * r {
|
||||||
|
self.cool_lava(idx(tx, ty), secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// obsidian melts again: it steams before, and whoever still stands there is burnt
|
||||||
|
fn update_obsidian(&mut self, dt: f32) {
|
||||||
|
let mut k = 0;
|
||||||
|
while k < self.bridgework.cooled.len() {
|
||||||
|
let (i, left) = self.bridgework.cooled[k];
|
||||||
|
if self.map.t[i].kind != Kind::Obsidian {
|
||||||
|
self.bridgework.cooled.swap_remove(k);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let left = left - dt;
|
||||||
|
self.bridgework.cooled[k].1 = left;
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
if left < MELT_WARN && self.vrng.f() < dt * 4.0 {
|
||||||
|
self.parts.push(Part { kind: SMOKE, x: x as f32 + self.vrng.f(), y: y as f32 + self.vrng.f(), h: 0.05, vx: 0.0, vy: 0.0, vh: 0.4, t: 0.0, max: 1.4, col: abgr(150, 150, 160), size: 0.14 });
|
||||||
|
}
|
||||||
|
if left <= 0.0 {
|
||||||
|
self.bridgework.cooled.swap_remove(k);
|
||||||
|
self.map.t[i].kind = Kind::Lava;
|
||||||
|
self.ground_lost(i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
k += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// a wooden bridge of the player across a strip of water, 6 tiles long
|
||||||
|
fn bridge_game() -> (Game, Vec<usize>) {
|
||||||
|
let mut g = Game::new(5, 64, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let y = cy + 6;
|
||||||
|
let mut tiles = Vec::new();
|
||||||
|
for x in cx - 3..cx + 3 {
|
||||||
|
let i = idx(x, y);
|
||||||
|
g.map.t[i].kind = Kind::Bridge;
|
||||||
|
g.map.t[i].owner = Owner::Player;
|
||||||
|
for dy in [-1, 1] {
|
||||||
|
let t = &mut g.map.t[idx(x, y + dy)];
|
||||||
|
t.kind = Kind::Water;
|
||||||
|
t.owner = Owner::None;
|
||||||
|
}
|
||||||
|
tiles.push(i);
|
||||||
|
}
|
||||||
|
(g, tiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_burning_bridge_spreads_and_falls() {
|
||||||
|
let (mut g, tiles) = bridge_game();
|
||||||
|
let (x, y) = xy(tiles[2]);
|
||||||
|
g.ignite_at(x as f32 + 0.5, y as f32 + 0.5);
|
||||||
|
assert!(g.burning(tiles[2]));
|
||||||
|
for _ in 0..(20 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
assert_eq!(g.map.t[tiles[2]].kind, Kind::Water, "the burnt plank fell");
|
||||||
|
assert!(tiles.iter().filter(|&&i| g.map.t[i].kind == Kind::Water).count() >= 2, "the fire spread");
|
||||||
|
assert!(g.bridgework.burning.iter().all(|b| g.wooden_bridge(b.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stone_bridges_do_not_burn() {
|
||||||
|
let (mut g, tiles) = bridge_game();
|
||||||
|
g.map.t[tiles[0]].kind = Kind::LavaBridge;
|
||||||
|
let (x, y) = xy(tiles[0]);
|
||||||
|
g.ignite_at(x as f32 + 0.5, y as f32 + 0.5);
|
||||||
|
assert!(!g.burning(tiles[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worn_bridges_fall_or_are_mended() {
|
||||||
|
let (mut g, tiles) = bridge_game();
|
||||||
|
assert!(g.wear_bridge(tiles[1], 2.0));
|
||||||
|
assert!(g.wear_of(tiles[1]) > 0.2);
|
||||||
|
assert_eq!(g.worn_bridge_near(tiles[1]), Some(tiles[1]));
|
||||||
|
while g.mend_bridge(tiles[1], 0.5) {}
|
||||||
|
assert_eq!(g.wear_of(tiles[1]), 0.0);
|
||||||
|
while g.wear_bridge(tiles[1], 1.0) {}
|
||||||
|
assert_eq!(g.map.t[tiles[1]].kind, Kind::Water);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drawbridges_switch_and_rise_by_themselves() {
|
||||||
|
let (mut g, tiles) = bridge_game();
|
||||||
|
for &i in &tiles {
|
||||||
|
g.map.t[i].kind = Kind::Drawbridge;
|
||||||
|
g.map.t[i].res = 0;
|
||||||
|
}
|
||||||
|
let (x, y) = xy(tiles[2]);
|
||||||
|
g.drawbridge_order(x, y, x, y);
|
||||||
|
assert!(tiles.iter().all(|&i| g.map.t[i].raised()), "the whole bridge goes up");
|
||||||
|
assert!(!g.map.walk(x, y));
|
||||||
|
g.drawbridge_order(x, y, x, y);
|
||||||
|
assert!(tiles.iter().all(|&i| g.map.t[i].res & DB_AUTO != 0 && !g.map.t[i].raised()), "automatic");
|
||||||
|
// a knight comes near: it rises
|
||||||
|
let id = g.spawn(EK::Knight, true, x as f32 + 0.5, y as f32 + 3.5, 1);
|
||||||
|
for _ in 0..20 {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
assert!(g.map.t[tiles[2]].raised(), "risen for the knight");
|
||||||
|
g.ents.retain(|e| e.id != id);
|
||||||
|
for _ in 0..20 {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
assert!(!g.map.t[tiles[2]].raised(), "down again");
|
||||||
|
g.drawbridge_order(x, y, x, y);
|
||||||
|
assert_eq!(g.map.t[tiles[2]].res & (DB_RAISED | DB_AUTO), 0, "back to manual, down");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frost_cools_lava_for_a_while() {
|
||||||
|
let mut g = Game::new(5, 64, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let i = idx(cx + 6, cy + 6);
|
||||||
|
g.map.t[i].kind = Kind::Lava;
|
||||||
|
g.cool_lava(i, 2.0);
|
||||||
|
assert_eq!(g.map.t[i].kind, Kind::Obsidian);
|
||||||
|
assert!(g.map.t[i].walkable());
|
||||||
|
for _ in 0..(3 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
assert_eq!(g.map.t[i].kind, Kind::Lava, "melted again");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
// From which mission (0-based) on each tool is available (same order as ui::TOOLS):
|
// From which mission (0-based) on each tool is available (same order as ui::TOOLS):
|
||||||
// dig, hand, treasury, lair, farm, training, library, sell, flag, lightning, heal, imp,
|
// dig, hand, treasury, lair, farm, training, library, sell, flag, lightning, heal, imp,
|
||||||
// torture, temple, arrow tower, cannon tower, magic tower, bridge, aerie, steel
|
// torture, temple, arrow tower, cannon tower, magic tower, bridge, aerie, steel
|
||||||
pub const TOOL_UNLOCK: [usize; 47] = [0, 0, 0, 0, 0, 2, 5, 0, 0, 3, 5, 0, 8, 12, 3, 7, 12, 1, 8, 17, 0, 7, 7, 10, 12, 12, 7, 16, 16, 7, 10, 16, 9, 13, 6, 10, 14, 17, 19, 22, 9, 13, 6, 11, 18, 0, 0];
|
pub const TOOL_UNLOCK: [usize; crate::ui::TOOLS.len()] = [0, 0, 0, 0, 0, 2, 5, 0, 0, 3, 5, 0, 8, 12, 3, 7, 12, 1, 8, 17, 0, 7, 7, 10, 12, 12, 7, 16, 16, 7, 10, 16, 9, 13, 6, 10, 14, 17, 19, 22, 9, 13, 6, 11, 18, 0, 0, 17, 16, 7, 13, 17];
|
||||||
|
|
||||||
pub struct FortDef {
|
pub struct FortDef {
|
||||||
pub at: (f32, f32), // centre as a fraction of the map size
|
pub at: (f32, f32), // centre as a fraction of the map size
|
||||||
@@ -48,6 +48,7 @@ pub struct MissionDef {
|
|||||||
pub rivals: Vec<(f32, f32, u8, u8)>, // rival masters: position (fraction of the map), difficulty, side (2 Mossgrim, 3 Lady Nightwell)
|
pub rivals: Vec<(f32, f32, u8, u8)>, // rival masters: position (fraction of the map), difficulty, side (2 Mossgrim, 3 Lady Nightwell)
|
||||||
pub islands: Vec<IslandDef>,
|
pub islands: Vec<IslandDef>,
|
||||||
pub caves: Vec<CaveDef>,
|
pub caves: Vec<CaveDef>,
|
||||||
|
pub surface: f32, // share of the width from where the map lies above ground (0: none)
|
||||||
}
|
}
|
||||||
|
|
||||||
// an island in a lake of water or lava, with treasure and/or a mana spring
|
// an island in a lake of water or lava, with treasure and/or a mana spring
|
||||||
@@ -83,6 +84,8 @@ pub struct Carry {
|
|||||||
pub rune_free: u32, // unbuilt rune forge towers from chests
|
pub rune_free: u32, // unbuilt rune forge towers from chests
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub rune_final: u32, // unbuilt or sold rune forge towers that were already given back once
|
pub rune_final: u32, // unbuilt or sold rune forge towers that were already given back once
|
||||||
|
#[serde(default)]
|
||||||
|
pub talents: crate::talents::Talents, // the master's talents go along from mission to mission
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const CARRY_CREATURES: usize = 5;
|
pub const CARRY_CREATURES: usize = 5;
|
||||||
@@ -166,6 +169,7 @@ fn base(key: &str, size: (usize, usize), seed: u32, goals: Vec<Goal>) -> Mission
|
|||||||
rivals: Vec::new(),
|
rivals: Vec::new(),
|
||||||
islands: Vec::new(),
|
islands: Vec::new(),
|
||||||
caves: Vec::new(),
|
caves: Vec::new(),
|
||||||
|
surface: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +185,7 @@ pub fn mission_def_from_doc(d: &crate::campaign_doc::MissionDoc) -> MissionDef {
|
|||||||
out.gate = d.waves;
|
out.gate = d.waves;
|
||||||
out.gate_defenders = d.waves;
|
out.gate_defenders = d.waves;
|
||||||
out.time_limit = d.time_limit;
|
out.time_limit = d.time_limit;
|
||||||
|
out.surface = if d.gen.surface { 0.5 } else { 0.0 };
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,16 +360,19 @@ pub fn missions() -> Vec<MissionDef> {
|
|||||||
.boss(Paladin, 10)],
|
.boss(Paladin, 10)],
|
||||||
..base("m20", (160, 160), 2020, vec![Goal::Heart, Goal::Shards])
|
..base("m20", (160, 160), 2020, vec![Goal::Heart, Goal::Shards])
|
||||||
});
|
});
|
||||||
|
// the finale above ground: the dungeon in the rock, the eastern half a forest with the High
|
||||||
|
// Marshal's fortress (the gate of the order) and both rival masters
|
||||||
v.push(MissionDef {
|
v.push(MissionDef {
|
||||||
portals: 3,
|
portals: 3,
|
||||||
gold: 6000.0,
|
gold: 6000.0,
|
||||||
gate: true,
|
|
||||||
waves: true,
|
waves: true,
|
||||||
first_wave: 420.0,
|
first_wave: 420.0,
|
||||||
gate_defenders: true,
|
gate_defenders: true,
|
||||||
rivers: vec![river(false, true, 0.45, 4, 2), river(true, false, 0.5, 4, 2)],
|
rivers: vec![river(false, true, 0.3, 4, 2)],
|
||||||
islands: vec![island((0.3, 0.2), false, true, 3000)],
|
islands: vec![island((0.2, 0.2), false, true, 3000)],
|
||||||
rivals: vec![(0.85, 0.22, 2, 2), (0.85, 0.78, 2, 3)],
|
forts: vec![fort((0.8, 0.5), (27, 25), &[3], &[Arrow, Cannon, Magic, Arrow, Cannon, Magic, Arrow, Cannon], 3, 14, 4, 4, 3, 3000).heart().boss(Paladin, 10)],
|
||||||
|
rivals: vec![(0.86, 0.15, 2, 2), (0.86, 0.85, 2, 3)],
|
||||||
|
surface: 0.48,
|
||||||
..base("m30", (176, 176), 3030, vec![Goal::Rivals, Goal::Heart])
|
..base("m30", (176, 176), 3030, vec![Goal::Rivals, Goal::Heart])
|
||||||
});
|
});
|
||||||
v
|
v
|
||||||
@@ -537,6 +545,9 @@ fn start_mission_def(d: &MissionDef, i: usize, carry: Option<&Carry>, coop: bool
|
|||||||
}
|
}
|
||||||
g.place_dwarf_caves(d.seed);
|
g.place_dwarf_caves(d.seed);
|
||||||
g.add_wilds(d.seed, false);
|
g.add_wilds(d.seed, false);
|
||||||
|
if d.surface > 0.0 {
|
||||||
|
g.add_surface(d.seed, d.surface, 2 + (h / 64) as u32);
|
||||||
|
}
|
||||||
g.init_research(Some(i));
|
g.init_research(Some(i));
|
||||||
g.init_fog();
|
g.init_fog();
|
||||||
let heart = d.gate || d.forts.iter().any(|f| f.heart);
|
let heart = d.gate || d.forts.iter().any(|f| f.heart);
|
||||||
@@ -571,6 +582,7 @@ fn start_mission_def(d: &MissionDef, i: usize, carry: Option<&Carry>, coop: bool
|
|||||||
g.gold += c.gold;
|
g.gold += c.gold;
|
||||||
g.rune_free += c.rune_free;
|
g.rune_free += c.rune_free;
|
||||||
g.rune_final += c.rune_final;
|
g.rune_final += c.rune_final;
|
||||||
|
g.talents = c.talents.clone();
|
||||||
}
|
}
|
||||||
for (k, &(kind, level, xp)) in joiners.iter().enumerate() {
|
for (k, &(kind, level, xp)) in joiners.iter().enumerate() {
|
||||||
let a = k as f32 * 0.9;
|
let a = k as f32 * 0.9;
|
||||||
@@ -654,6 +666,8 @@ fn remove_rivals(g: &mut Game) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
g.rivals.clear();
|
g.rivals.clear();
|
||||||
|
// their masters go too (the seats belong to the players)
|
||||||
|
g.masters.retain(|m| !sides.contains(&m.side));
|
||||||
g.ents.retain(|e| e.hero || !sides.contains(&e.side));
|
g.ents.retain(|e| e.hero || !sides.contains(&e.side));
|
||||||
let owners: Vec<Owner> = sides.iter().map(|&s| Owner::of_side(s)).collect();
|
let owners: Vec<Owner> = sides.iter().map(|&s| Owner::of_side(s)).collect();
|
||||||
for t in g.map.t.iter_mut() {
|
for t in g.map.t.iter_mut() {
|
||||||
@@ -734,6 +748,20 @@ pub fn editor_fort(g: &mut Game, x: i32, y: i32, level: u32, shard: bool) {
|
|||||||
g.recount();
|
g.recount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// an island in a lake (water or lava) at a map fraction, with a mana spring and/or treasure
|
||||||
|
pub fn editor_island(g: &mut Game, at: (f32, f32), lava: bool, spring: bool, treasure: i32, seed: u32) {
|
||||||
|
let (w, h) = (g.map.w, g.map.h);
|
||||||
|
place_island(g, &island(at, lava, spring, treasure), w, h, seed);
|
||||||
|
g.recount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// a sealed cave at a map fraction, with a chained creature and/or treasure
|
||||||
|
pub fn editor_cave(g: &mut Game, at: (f32, f32), chained: Option<(EK, u32)>, treasure: i32) {
|
||||||
|
let (w, h) = (g.map.w, g.map.h);
|
||||||
|
place_cave(g, &cave(at, chained, treasure), w, h);
|
||||||
|
g.recount();
|
||||||
|
}
|
||||||
|
|
||||||
// a painted map: the game state saved by the map editor
|
// a painted map: the game state saved by the map editor
|
||||||
pub fn load_fixed_map(json: &str) -> Option<Game> {
|
pub fn load_fixed_map(json: &str) -> Option<Game> {
|
||||||
let mut g: Game = serde_json::from_str(json).ok()?;
|
let mut g: Game = serde_json::from_str(json).ok()?;
|
||||||
@@ -771,7 +799,47 @@ pub fn carry_from(g: &Game) -> Carry {
|
|||||||
let mut c: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && e.creature()).collect();
|
let mut c: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && e.creature()).collect();
|
||||||
let power = |e: &Ent| e.max_hp * e.atk() / st(e.kind).interval;
|
let power = |e: &Ent| e.max_hp * e.atk() / st(e.kind).interval;
|
||||||
c.sort_by(|a, b| power(b).partial_cmp(&power(a)).unwrap_or(std::cmp::Ordering::Equal));
|
c.sort_by(|a, b| power(b).partial_cmp(&power(a)).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
Carry { creatures: c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect(), gold: (g.gold * CARRY_GOLD).floor(), rune_free: g.rune_free, rune_final: g.rune_final + g.rune_next }
|
Carry { creatures: c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect(), gold: (g.gold * CARRY_GOLD).floor(), rune_free: g.rune_free, rune_final: g.rune_final + g.rune_next, talents: g.talents.clone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// a co-op match of an editor campaign: what every master takes into the next mission - his
|
||||||
|
// strongest creatures and a fifth of his gold. It comes from the shared world, so it is the same on
|
||||||
|
// every client (the host sends it along with the next mission anyway).
|
||||||
|
pub fn coop_carry_from(g: &Game) -> Vec<(u8, Carry)> {
|
||||||
|
let power = |e: &Ent| e.max_hp * e.atk() / st(e.kind).interval;
|
||||||
|
g.master_sides()
|
||||||
|
.into_iter()
|
||||||
|
.map(|side| {
|
||||||
|
let mut c: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && !e.hero && e.side == side && e.kind != EK::Worker && !e.chained && e.captive == 0).collect();
|
||||||
|
c.sort_by(|a, b| power(b).partial_cmp(&power(a)).unwrap_or(std::cmp::Ordering::Equal).then(a.id.cmp(&b.id)));
|
||||||
|
let creatures = c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect();
|
||||||
|
(side, Carry { creatures, gold: (g.gold_of(side).max(0.0) * CARRY_GOLD).floor(), rune_free: 0, rune_final: 0, talents: g.talents_of(side) })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the carry of every master put into a new co-op mission: gold for him, his creatures around his heart
|
||||||
|
pub fn apply_coop_carry(g: &mut Game, carry: &[(u8, Carry)]) {
|
||||||
|
let local = g.local_side;
|
||||||
|
for (side, c) in carry {
|
||||||
|
if !g.is_master(*side) {
|
||||||
|
continue; // the seat is played by the AI now
|
||||||
|
}
|
||||||
|
g.switch_to(*side);
|
||||||
|
g.gold += c.gold.max(0.0);
|
||||||
|
g.talents = c.talents.clone();
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
for (k, &(kind, level, xp)) in c.creatures.iter().take(CARRY_CREATURES).enumerate() {
|
||||||
|
let a = k as f32 * 1.1;
|
||||||
|
let (x, y) = (cx as f32 + 1.5 + a.cos() * 3.0, cy as f32 + 1.5 + a.sin() * 3.0);
|
||||||
|
let id = g.spawn(kind, false, x, y, level.clamp(1, 10));
|
||||||
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
|
||||||
|
e.xp = xp;
|
||||||
|
e.happy = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.switch_to(local);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ pub struct CampaignProgress {
|
|||||||
// random maps are rolled once per run of the campaign: this seed is fixed for the run
|
// random maps are rolled once per run of the campaign: this seed is fixed for the run
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub run_seed: u32,
|
pub run_seed: u32,
|
||||||
|
// co-op runs (kept by the host): the mission the run goes on with and what every master carries
|
||||||
|
// into it; None once the run is over
|
||||||
|
#[serde(default)]
|
||||||
|
pub next: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub carry: Vec<(u8, crate::campaign::Carry)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CampaignProgress {
|
impl CampaignProgress {
|
||||||
@@ -27,6 +33,8 @@ impl CampaignProgress {
|
|||||||
done: vec![false; n],
|
done: vec![false; n],
|
||||||
completed_choice: vec![None; n],
|
completed_choice: vec![None; n],
|
||||||
run_seed: (macroquad::miniquad::date::now() * 1000.0) as u64 as u32 | 1,
|
run_seed: (macroquad::miniquad::date::now() * 1000.0) as u64 as u32 | 1,
|
||||||
|
next: None,
|
||||||
|
carry: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,11 +145,13 @@ pub struct GenDoc {
|
|||||||
pub rivals: u8, // 0..2 rival masters
|
pub rivals: u8, // 0..2 rival masters
|
||||||
#[serde(default = "default_difficulty")]
|
#[serde(default = "default_difficulty")]
|
||||||
pub difficulty: u8, // 0 easy, 1 medium, 2 hard
|
pub difficulty: u8, // 0 easy, 1 medium, 2 hard
|
||||||
|
#[serde(default)]
|
||||||
|
pub surface: bool, // the eastern half lies above ground (forest)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for GenDoc {
|
impl Default for GenDoc {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
GenDoc { rivers: 1, lava: 0, forts: 1, rivals: 0, difficulty: 1 }
|
GenDoc { rivers: 1, lava: 0, forts: 1, rivals: 0, difficulty: 1, surface: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ enum Req {
|
|||||||
CampaignPublish,
|
CampaignPublish,
|
||||||
PublicList,
|
PublicList,
|
||||||
PublicLoad,
|
PublicLoad,
|
||||||
|
ProfileLoad,
|
||||||
|
ProfileSave,
|
||||||
}
|
}
|
||||||
|
|
||||||
// the logged-in account
|
// the logged-in account
|
||||||
@@ -124,6 +126,7 @@ pub struct Cloud {
|
|||||||
pub public_listed: bool,
|
pub public_listed: bool,
|
||||||
pub campaign_error: Option<&'static str>, // language key of the last failed campaign request
|
pub campaign_error: Option<&'static str>, // language key of the last failed campaign request
|
||||||
loaded_campaign: Option<String>,
|
loaded_campaign: Option<String>,
|
||||||
|
pub profile_loaded: Option<String>, // the profile from the server, waiting to be merged
|
||||||
saved_campaign: Option<CampaignInfo>,
|
saved_campaign: Option<CampaignInfo>,
|
||||||
pending: Option<(u32, Req)>,
|
pending: Option<(u32, Req)>,
|
||||||
save_name: String,
|
save_name: String,
|
||||||
@@ -176,6 +179,7 @@ impl Cloud {
|
|||||||
public_listed: false,
|
public_listed: false,
|
||||||
campaign_error: None,
|
campaign_error: None,
|
||||||
loaded_campaign: None,
|
loaded_campaign: None,
|
||||||
|
profile_loaded: None,
|
||||||
saved_campaign: None,
|
saved_campaign: None,
|
||||||
pending: Some((id, Req::Health)),
|
pending: Some((id, Req::Health)),
|
||||||
save_name: String::new(),
|
save_name: String::new(),
|
||||||
@@ -330,6 +334,13 @@ impl Cloud {
|
|||||||
CloudEvent::Error(error_key(res.status))
|
CloudEvent::Error(error_key(res.status))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Req::ProfileLoad => {
|
||||||
|
if ok {
|
||||||
|
self.profile_loaded = Some(String::from_utf8_lossy(&res.body).into_owned());
|
||||||
|
}
|
||||||
|
CloudEvent::None
|
||||||
|
}
|
||||||
|
Req::ProfileSave => CloudEvent::None,
|
||||||
Req::CreateUser | Req::DeleteUser => {
|
Req::CreateUser | Req::DeleteUser => {
|
||||||
if ok {
|
if ok {
|
||||||
self.load_users();
|
self.load_users();
|
||||||
@@ -470,6 +481,15 @@ impl Cloud {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// JSON text of a campaign loaded by load_campaign() or load_public_campaign()
|
// JSON text of a campaign loaded by load_campaign() or load_public_campaign()
|
||||||
|
// the profile (statistics and achievements) of the logged-in player
|
||||||
|
pub fn load_profile(&mut self) -> bool {
|
||||||
|
self.session.is_some() && self.start("GET", "profile", &[], Req::ProfileLoad)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_profile(&mut self, json: &str) -> bool {
|
||||||
|
self.session.is_some() && self.start("PUT", "profile", json.as_bytes(), Req::ProfileSave)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn take_loaded_campaign(&mut self) -> Option<String> {
|
pub fn take_loaded_campaign(&mut self) -> Option<String> {
|
||||||
self.loaded_campaign.take()
|
self.loaded_campaign.take()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ pub enum Act {
|
|||||||
Sell(Rect),
|
Sell(Rect),
|
||||||
Bridge(Rect),
|
Bridge(Rect),
|
||||||
Steel(Rect),
|
Steel(Rect),
|
||||||
|
Siege {
|
||||||
|
which: u8, // 0 ram, 1 catapult
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
},
|
||||||
|
Wall(Rect), // plan walls on own floor (or cancel planned ones)
|
||||||
|
Drawbridge(Rect), // build drawbridges, or (one tile on an own drawbridge) switch it: down, up, automatic
|
||||||
Tower {
|
Tower {
|
||||||
kind: TowerKind,
|
kind: TowerKind,
|
||||||
x: i32,
|
x: i32,
|
||||||
@@ -85,6 +92,19 @@ pub enum Act {
|
|||||||
y: i32,
|
y: i32,
|
||||||
},
|
},
|
||||||
CancelOrders,
|
CancelOrders,
|
||||||
|
Unmark {
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
}, // right click with an order tool: removes the marker there (or cancels the guard orders)
|
||||||
|
// gold for an allied master of a match (`crate::netplay::GOLD_ALL`: everything)
|
||||||
|
SendGold {
|
||||||
|
to: u8,
|
||||||
|
amount: u32,
|
||||||
|
},
|
||||||
|
// spend a talent point on a branch (talents.rs)
|
||||||
|
Talent {
|
||||||
|
branch: u8,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -97,7 +117,8 @@ pub struct Command {
|
|||||||
impl Game {
|
impl Game {
|
||||||
// the local master orders something: queued until the next step boundary
|
// the local master orders something: queued until the next step boundary
|
||||||
pub fn queue(&mut self, act: Act) {
|
pub fn queue(&mut self, act: Act) {
|
||||||
if self.state != 0 || self.paused || self.demo.is_some() || !self.is_master(self.local_side) {
|
// a master whose heart fell only watches
|
||||||
|
if self.state != 0 || self.paused || self.demo.is_some() || !self.is_master(self.local_side) || self.is_out(self.local_side) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let seq = self.cmd_seq;
|
let seq = self.cmd_seq;
|
||||||
@@ -109,7 +130,7 @@ impl Game {
|
|||||||
// one command of one master: that master's state is switched in, the action runs, the local
|
// one command of one master: that master's state is switched in, the action runs, the local
|
||||||
// master's state is switched back. Messages, hints and sounds only come from the local master.
|
// master's state is switched back. Messages, hints and sounds only come from the local master.
|
||||||
pub fn apply(&mut self, c: &Command) {
|
pub fn apply(&mut self, c: &Command) {
|
||||||
if !self.is_master(c.side) {
|
if !self.is_master(c.side) || self.is_out(c.side) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.switch_to(c.side);
|
self.switch_to(c.side);
|
||||||
@@ -131,12 +152,13 @@ impl Game {
|
|||||||
|
|
||||||
fn mark_command(&mut self, act: Act, side: u8) {
|
fn mark_command(&mut self, act: Act, side: u8) {
|
||||||
let ((x, y), kind, dur) = match act {
|
let ((x, y), kind, dur) = match act {
|
||||||
|
Act::SendGold { .. } | Act::Talent { .. } => return, // nothing to point at on the map
|
||||||
Act::Dig(r) => (rect_center(r), MARK_DIG, 4.0),
|
Act::Dig(r) => (rect_center(r), MARK_DIG, 4.0),
|
||||||
Act::Room { r, .. } => (rect_center(r), MARK_ROOM, 5.0),
|
Act::Room { r, .. } => (rect_center(r), MARK_ROOM, 5.0),
|
||||||
Act::Sell(r) => (rect_center(r), MARK_SELL, 4.0),
|
Act::Sell(r) => (rect_center(r), MARK_SELL, 4.0),
|
||||||
Act::Bridge(r) => (rect_center(r), MARK_BRIDGE, 4.0),
|
Act::Bridge(r) | Act::Drawbridge(r) | Act::Wall(r) => (rect_center(r), MARK_BRIDGE, 4.0),
|
||||||
Act::Steel(r) => (rect_center(r), MARK_STEEL, 4.0),
|
Act::Steel(r) => (rect_center(r), MARK_STEEL, 4.0),
|
||||||
Act::Tower { x, y, .. } => (point(x as f32, y as f32), MARK_TOWER, 6.0),
|
Act::Tower { x, y, .. } | Act::Siege { x, y, .. } => (point(x as f32, y as f32), MARK_TOWER, 6.0),
|
||||||
Act::Trap { x, y, .. } => (point(x as f32, y as f32), MARK_TRAP, 6.0),
|
Act::Trap { x, y, .. } => (point(x as f32, y as f32), MARK_TRAP, 6.0),
|
||||||
Act::Cast { x, y, .. } => (point(x, y), MARK_SPELL, 5.0),
|
Act::Cast { x, y, .. } => (point(x, y), MARK_SPELL, 5.0),
|
||||||
Act::Lightning { x, y } => (point(x, y), MARK_LIGHTNING, 5.0),
|
Act::Lightning { x, y } => (point(x, y), MARK_LIGHTNING, 5.0),
|
||||||
@@ -158,6 +180,7 @@ 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),
|
||||||
|
Act::Unmark { x, y } => (point(x, y), MARK_CANCEL, 3.0),
|
||||||
};
|
};
|
||||||
let fixed = matches!(act, Act::Guard { .. } | Act::Attack { .. } | Act::Flag { .. });
|
let fixed = matches!(act, Act::Guard { .. } | Act::Attack { .. } | Act::Flag { .. });
|
||||||
let dur = match act {
|
let dur = match act {
|
||||||
@@ -178,6 +201,9 @@ impl Game {
|
|||||||
Act::Room { room, r } => self.build_rect(room as usize, r.x0, r.y0, r.x1, r.y1),
|
Act::Room { room, r } => self.build_rect(room as usize, r.x0, r.y0, r.x1, r.y1),
|
||||||
Act::Sell(r) => self.sell_rect(r.x0, r.y0, r.x1, r.y1),
|
Act::Sell(r) => self.sell_rect(r.x0, r.y0, r.x1, r.y1),
|
||||||
Act::Bridge(r) => self.build_bridges(r.x0, r.y0, r.x1, r.y1),
|
Act::Bridge(r) => self.build_bridges(r.x0, r.y0, r.x1, r.y1),
|
||||||
|
Act::Drawbridge(r) => self.drawbridge_order(r.x0, r.y0, r.x1, r.y1),
|
||||||
|
Act::Wall(r) => self.plan_walls(r.x0, r.y0, r.x1, r.y1),
|
||||||
|
Act::Siege { which, x, y } => self.build_siege(which, x, y),
|
||||||
Act::Steel(r) => self.build_steel(r.x0, r.y0, r.x1, r.y1),
|
Act::Steel(r) => self.build_steel(r.x0, r.y0, r.x1, r.y1),
|
||||||
Act::Tower { kind, x, y } => self.build_tower(kind, x, y),
|
Act::Tower { kind, x, y } => self.build_tower(kind, x, y),
|
||||||
Act::Trap { kind, x, y } => self.place_trap(kind, x, y),
|
Act::Trap { kind, x, y } => self.place_trap(kind, x, y),
|
||||||
@@ -203,6 +229,9 @@ impl Game {
|
|||||||
}
|
}
|
||||||
Act::Guard { x, y } => self.order_guard(x, y),
|
Act::Guard { x, y } => self.order_guard(x, y),
|
||||||
Act::CancelOrders => self.cancel_orders(),
|
Act::CancelOrders => self.cancel_orders(),
|
||||||
|
Act::Unmark { x, y } => self.unmark(x, y),
|
||||||
|
Act::SendGold { to, amount } => self.send_gold(to, amount),
|
||||||
|
Act::Talent { branch } => self.learn_talent(branch as usize),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,7 +288,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fixed_order_flags_do_not_need_the_debug_marker_setting() {
|
fn command_markers_only_show_with_the_debug_setting() {
|
||||||
let mut g = game(13);
|
let mut g = game(13);
|
||||||
g.debug_markers = false;
|
g.debug_markers = false;
|
||||||
let (cx, cy) = g.map.core;
|
let (cx, cy) = g.map.core;
|
||||||
@@ -267,10 +296,13 @@ mod tests {
|
|||||||
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.queue(Act::Flag { x: cx, y: cy });
|
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.is_empty(), "the real shields, swords and flags show the orders");
|
||||||
assert!(g.marks.iter().any(|m| m.kind == MARK_GUARD && m.fixed), "guard is fixed");
|
// with the setting on they show, and removing a marker takes them away
|
||||||
assert!(g.marks.iter().any(|m| m.kind == MARK_ATTACK && m.fixed), "attack is fixed");
|
g.debug_markers = true;
|
||||||
assert!(g.marks.iter().any(|m| m.kind == MARK_FLAG && m.fixed), "gather flag is fixed");
|
g.queue(Act::Guard { x: cx + 2, y: cy + 2 });
|
||||||
|
assert!(g.marks.iter().any(|m| m.kind == MARK_GUARD));
|
||||||
|
g.unmark(cx as f32 + 2.5, cy as f32 + 2.5);
|
||||||
|
assert!(!g.marks.iter().any(|m| m.kind == MARK_GUARD), "gone with the marker");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// the knights fight on a tongue of rock between a lava river (north) and an underground river
|
// the knights fight on a tongue of rock between a lava river (north) and an underground river
|
||||||
// (south). The dungeon side has a wall with a wooden, an iron and a magic door, rooms behind it
|
// (south). The dungeon side has a wall with a wooden, an iron and a magic door, rooms behind it
|
||||||
// (treasury, forge, library, lair, ...), a rune forge tower, a magic tower and a mana spring; all
|
// (treasury, forge, library, lair, ...), a rune forge tower, a magic tower and a mana spring; all
|
||||||
// seven traps lie on the knights' way; the knights have their towers and the hero gate. Both sides
|
// seven traps lie on the knights' way; the knights have their towers, and their gate stands above
|
||||||
|
// ground in a sunny forest east of a cliff, from where they march in through two cave mouths. Both sides
|
||||||
// get balanced reinforcements, destroyed towers, doors and traps come back after a while, and now
|
// get balanced reinforcements, destroyed towers, doors and traps come back after a while, and now
|
||||||
// and then the dungeon casts a spell. Nothing here is ever saved.
|
// and then the dungeon casts a spell. Nothing here is ever saved.
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ pub struct Demo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const WALL_X: i32 = RIVER_X - 8; // the dungeon's wall with its three doors
|
const WALL_X: i32 = RIVER_X - 8; // the dungeon's wall with its three doors
|
||||||
|
const SURF_X: i32 = 55; // from here east the land lies above ground (the knights come from there)
|
||||||
|
|
||||||
const CREATURES: [(EK, u32); 12] = [(EK::Kobold, 4), (EK::Spider, 3), (EK::Mage, 2), (EK::Ogre, 2), (EK::Jailer, 1), (EK::Priestess, 1), (EK::Bat, 2), (EK::Gargoyle, 1), (EK::Owl, 1), (EK::Wyrm, 1), (EK::Cogling, 1), (EK::Revenant, 1)];
|
const CREATURES: [(EK, u32); 12] = [(EK::Kobold, 4), (EK::Spider, 3), (EK::Mage, 2), (EK::Ogre, 2), (EK::Jailer, 1), (EK::Priestess, 1), (EK::Bat, 2), (EK::Gargoyle, 1), (EK::Owl, 1), (EK::Wyrm, 1), (EK::Cogling, 1), (EK::Revenant, 1)];
|
||||||
const KNIGHTS: [(EK, u32); 8] = [(EK::Knight, 5), (EK::Archer, 3), (EK::Priest, 2), (EK::Dwarf, 2), (EK::Paladin, 1), (EK::Giant, 1), (EK::Fairy, 1), (EK::BattleMage, 1)];
|
const KNIGHTS: [(EK, u32); 8] = [(EK::Knight, 5), (EK::Archer, 3), (EK::Priest, 2), (EK::Dwarf, 2), (EK::Paladin, 1), (EK::Giant, 1), (EK::Fairy, 1), (EK::BattleMage, 1)];
|
||||||
@@ -183,8 +185,58 @@ impl Game {
|
|||||||
let spring = idx(WALL_X - 3, BRIDGE_Y + 1);
|
let spring = idx(WALL_X - 3, BRIDGE_Y + 1);
|
||||||
g.map.t[spring].kind = Kind::ManaSpring;
|
g.map.t[spring].kind = Kind::ManaSpring;
|
||||||
g.map.t[spring].owner = Owner::Player;
|
g.map.t[spring].owner = Owner::Player;
|
||||||
// the hero gate on the knights' side
|
// above ground in the east: grass, a forest at the edges, a cliff with two cave mouths
|
||||||
let (gx, gy) = (RIVER_X + 11, BRIDGE_Y);
|
let mouth = |y: i32| (BRIDGE_Y - 4..=BRIDGE_Y - 1).contains(&y) || (BRIDGE_Y + 2..=BRIDGE_Y + 6).contains(&y);
|
||||||
|
for y in 1..H as i32 - 1 {
|
||||||
|
for x in SURF_X - 1..W as i32 - 1 {
|
||||||
|
let i = idx(x, y);
|
||||||
|
let border = y < 2 || y >= H as i32 - 2 || x >= W as i32 - 2;
|
||||||
|
if x < SURF_X + 1 {
|
||||||
|
// the cliff (or a cave mouth: knight ground)
|
||||||
|
if !border {
|
||||||
|
let t = &mut g.map.t[i];
|
||||||
|
t.kind = if mouth(y) { Kind::Floor } else { Kind::Rock };
|
||||||
|
t.owner = if mouth(y) { Owner::Hero } else { Owner::None };
|
||||||
|
t.reinforced = false;
|
||||||
|
t.gold = 0;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
g.map.set_sky(i, true);
|
||||||
|
if border {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let t = &mut g.map.t[i];
|
||||||
|
t.owner = Owner::None;
|
||||||
|
t.gold = 0;
|
||||||
|
t.reinforced = false;
|
||||||
|
if t.kind == Kind::Rock && rng.f() < 0.6 {
|
||||||
|
continue; // a few rocks stay as outcrops
|
||||||
|
}
|
||||||
|
// trees along the north and south edges and in the far east; the march route stays open
|
||||||
|
let dy = (y - (BRIDGE_Y + 1)).abs();
|
||||||
|
let h = ((x * 73 + y * 151) as u32).wrapping_mul(2654435761) >> 24;
|
||||||
|
let wood = dy > 9 || x > W as i32 - 7;
|
||||||
|
t.kind = if wood && h % 3 != 0 { Kind::Tree } else { Kind::Floor };
|
||||||
|
t.hp = if t.kind == Kind::Tree { crate::wood::TREE_HP } else { 0.0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the knight cave runs on to the cave mouths
|
||||||
|
for y in BRIDGE_Y - 4..=BRIDGE_Y + 6 {
|
||||||
|
if !mouth(y) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut x = SURF_X - 1;
|
||||||
|
while x > RIVER_X + 3 && !g.map.t[idx(x, y)].walkable() {
|
||||||
|
let t = &mut g.map.t[idx(x, y)];
|
||||||
|
t.kind = Kind::Floor;
|
||||||
|
t.owner = Owner::Hero;
|
||||||
|
t.reinforced = false;
|
||||||
|
x -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the hero gate on the knights' side, in the forest
|
||||||
|
let (gx, gy) = (SURF_X + 6, BRIDGE_Y);
|
||||||
for y in gy..gy + 3 {
|
for y in gy..gy + 3 {
|
||||||
for x in gx..gx + 3 {
|
for x in gx..gx + 3 {
|
||||||
let t = &mut g.map.t[idx(x, y)];
|
let t = &mut g.map.t[idx(x, y)];
|
||||||
@@ -220,8 +272,9 @@ impl Game {
|
|||||||
g.demo_spawn(true);
|
g.demo_spawn(true);
|
||||||
g.demo_spawn(false);
|
g.demo_spawn(false);
|
||||||
}
|
}
|
||||||
let mut cam = Cam::new(RIVER_X as f32 + 0.5, BRIDGE_Y as f32 + 1.5);
|
// the camera between the battle on the tongue and the forest the knights come from
|
||||||
cam.zoom = 16.0;
|
let mut cam = Cam::new(RIVER_X as f32 + 11.5, BRIDGE_Y as f32 + 1.5);
|
||||||
|
cam.zoom = 21.0;
|
||||||
(g, cam)
|
(g, cam)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::game::*;
|
|||||||
use crate::map::*;
|
use crate::map::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub const DWARF_SIDE: u8 = 4;
|
pub const DWARF_SIDE: u8 = 6; // its own side (4 is the fourth master)
|
||||||
const LEASH: f32 = 7.0; // dwarves do not chase enemies further from the cave centre
|
const LEASH: f32 = 7.0; // dwarves do not chase enemies further from the cave centre
|
||||||
const CHEST_CHANCE: f32 = 0.3;
|
const CHEST_CHANCE: f32 = 0.3;
|
||||||
|
|
||||||
|
|||||||
@@ -543,7 +543,13 @@ impl BlockEdit {
|
|||||||
let r = lay.take(CW() * 10.0);
|
let r = lay.take(CW() * 10.0);
|
||||||
im.text_field(gfx, fid(path, 6), r, name, 16);
|
im.text_field(gfx, fid(path, 6), r, name, 16);
|
||||||
}
|
}
|
||||||
Block::Wave { count } | Block::Gold { amount: count } | Block::Mana { amount: count } => self.value(im, gfx, count, Ty::Num, path, 1, lay, ctx),
|
Block::Wave { count } | Block::Gold { amount: count } | Block::Mana { amount: count } | Block::TalentXp { amount: count } => self.value(im, gfx, count, Ty::Num, path, 1, lay, ctx),
|
||||||
|
Block::Weather { kind, secs } => {
|
||||||
|
weather_dd(im, gfx, kind, fid(path, 2), lay);
|
||||||
|
lay.word(gfx, &tr("blk.w_for"));
|
||||||
|
self.value(im, gfx, secs, Ty::Num, path, 1, lay, ctx);
|
||||||
|
lay.word(gfx, &tr("blk.w_seconds"));
|
||||||
|
}
|
||||||
Block::SetVar { name, value } | Block::AddVar { name, value } => {
|
Block::SetVar { name, value } | Block::AddVar { name, value } => {
|
||||||
let r = lay.take(CW() * 12.0);
|
let r = lay.take(CW() * 12.0);
|
||||||
im.text_field(gfx, fid(path, 0), r, name, 24);
|
im.text_field(gfx, fid(path, 0), r, name, 24);
|
||||||
@@ -713,8 +719,22 @@ impl BlockEdit {
|
|||||||
lay.word(gfx, &tr("blk.v.eventkind"));
|
lay.word(gfx, &tr("blk.v.eventkind"));
|
||||||
self.kind_dd(im, gfx, kind, false, path, sub(0), lay);
|
self.kind_dd(im, gfx, kind, false, path, sub(0), lay);
|
||||||
}
|
}
|
||||||
|
Val::WeatherIs { kind } => {
|
||||||
|
lay.word(gfx, &tr("blk.v.weatheris"));
|
||||||
|
weather_dd(im, gfx, kind, fid(path, sub(0)), lay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a dropdown with the kinds of weather
|
||||||
|
fn weather_dd(im: &mut Im, gfx: &Gfx, kind: &mut String, id: u64, lay: &mut Lay) {
|
||||||
|
let opts: Vec<String> = WEATHER.iter().map(|w| tr(&format!("weather.{}", w))).collect();
|
||||||
|
let cur = tr(&format!("weather.{}", kind));
|
||||||
|
let r = lay.take(CW() * 12.0);
|
||||||
|
if let Some(c) = im.dropdown(gfx, id, r, &cur, &opts) {
|
||||||
|
*kind = WEATHER[c].to_string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// where the next field of a block goes: left to right, into a new row at the block's edge
|
// where the next field of a block goes: left to right, into a new row at the block's edge
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// The built-in co-op campaign "League against the Lightwatch": 20 missions for two to four masters,
|
||||||
|
// made as an editor campaign (painted maps, goals, branches, triggers). Crookfinger brings young
|
||||||
|
// masters together: the Lightwatch has allied with Mossgrim to smoke out every dungeon. The masters
|
||||||
|
// take fortresses, free creatures, and finally bring down the traitor Mossgrim and the High Marshal.
|
||||||
|
// In co-op the players hold the master seats, so Mossgrim's creatures fight on the heroes' side.
|
||||||
|
//
|
||||||
|
// Texts: coop.* keys in the language files; the campaign is built in the player's language.
|
||||||
|
|
||||||
|
use crate::blocks::{Block, Place, Val};
|
||||||
|
use crate::campaign_doc::*;
|
||||||
|
use crate::game::EK;
|
||||||
|
use crate::i18n::tr;
|
||||||
|
|
||||||
|
struct Spec {
|
||||||
|
size: (usize, usize),
|
||||||
|
seed: u32,
|
||||||
|
gold: f32,
|
||||||
|
gen: (u8, u8, u8, u8), // rivers, lava rivers, forts, difficulty
|
||||||
|
goals: &'static [&'static str],
|
||||||
|
waves: (bool, u32, f32), // hero waves, how many, first after
|
||||||
|
islands: &'static [((f32, f32), bool, bool, i32)], // at, lava, mana spring, treasure
|
||||||
|
caves: &'static [((f32, f32), Option<(EK, u32)>, i32)], // at, chained creature, treasure
|
||||||
|
raids: &'static [(f64, bool, &'static str, u32, u32, usize)], // after/every secs, repeat, kind, count, level, zone
|
||||||
|
boss: Option<(&'static str, u32, &'static str, usize)>, // kind, level, name, zone
|
||||||
|
branch: Option<(&'static str, usize)>, // goal, mission number it leads to
|
||||||
|
}
|
||||||
|
|
||||||
|
// zones where Mossgrim's creatures and the bosses appear (map fractions)
|
||||||
|
const ZONES: [(&str, (f32, f32)); 3] = [("east", (0.86, 0.5)), ("north", (0.7, 0.16)), ("south", (0.7, 0.84))];
|
||||||
|
|
||||||
|
const fn s(size: (usize, usize), seed: u32, gold: f32, gen: (u8, u8, u8, u8), goals: &'static [&'static str]) -> Spec {
|
||||||
|
Spec { size, seed, gold, gen, goals, waves: (false, 0, 0.0), islands: &[], caves: &[], raids: &[], boss: None, branch: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn specs() -> Vec<Spec> {
|
||||||
|
use EK::*;
|
||||||
|
vec![
|
||||||
|
// ---- Act I: the league
|
||||||
|
Spec { raids: &[(150.0, false, "Knight", 3, 1, 0)], ..s((80, 80), 9101, 3000.0, (0, 0, 0, 0), &["KillAll"]) },
|
||||||
|
s((80, 80), 9102, 3000.0, (1, 0, 1, 0), &["Towers"]),
|
||||||
|
Spec { waves: (true, 3, 240.0), raids: &[(120.0, false, "Ogre", 2, 2, 1)], ..s((80, 80), 9103, 3500.0, (1, 0, 0, 0), &["Survive(3)"]) },
|
||||||
|
Spec { islands: &[((0.5, 0.2), false, true, 0), ((0.5, 0.8), false, true, 1500)], branch: Some(("Loot(2000)", 6)), ..s((88, 80), 9104, 3000.0, (1, 0, 1, 0), &["Springs(2)"]) },
|
||||||
|
Spec { caves: &[((0.3, 0.2), Some((Ogre, 5)), 500), ((0.3, 0.8), Some((Mage, 5)), 500)], raids: &[(200.0, false, "Spider", 4, 3, 0)], ..s((88, 80), 9105, 3500.0, (0, 1, 1, 0), &["Freed(2)"]) },
|
||||||
|
// ---- Act II: against the Lightwatch
|
||||||
|
s((88, 88), 9106, 4000.0, (2, 0, 1, 1), &["Towers"]),
|
||||||
|
Spec { raids: &[(180.0, true, "Knight", 2, 3, 0)], ..s((96, 80), 9107, 4000.0, (0, 2, 1, 1), &["Loot(4000)"]) },
|
||||||
|
Spec { raids: &[(60.0, true, "Priest", 1, 3, 0)], ..s((88, 88), 9108, 4000.0, (1, 0, 1, 1), &["KillAll"]) },
|
||||||
|
Spec { boss: Some(("Paladin", 7, "ambassador", 0)), raids: &[(150.0, true, "Ogre", 2, 4, 1)], ..s((96, 88), 9109, 4500.0, (1, 1, 1, 1), &[]) },
|
||||||
|
s((96, 96), 9110, 5000.0, (1, 0, 2, 1), &["Towers"]),
|
||||||
|
Spec { waves: (true, 5, 240.0), raids: &[(300.0, true, "Gargoyle", 2, 4, 2)], ..s((96, 88), 9111, 5000.0, (1, 0, 0, 1), &["Survive(5)"]) },
|
||||||
|
Spec { waves: (true, 0, 300.0), ..s((96, 96), 9112, 5000.0, (2, 0, 1, 1), &["Heart"]) },
|
||||||
|
// ---- Act III: the traitor
|
||||||
|
Spec { caves: &[((0.5, 0.15), None, 2500), ((0.5, 0.85), None, 2500)], raids: &[(120.0, true, "Spider", 3, 4, 0)], ..s((96, 96), 9113, 5000.0, (0, 1, 1, 2), &["Loot(5000)"]) },
|
||||||
|
Spec { raids: &[(90.0, true, "Revenant", 2, 5, 1), (90.0, true, "Revenant", 2, 5, 2)], ..s((104, 96), 9114, 5500.0, (2, 0, 1, 2), &["KillAll"]) },
|
||||||
|
Spec { caves: &[((0.35, 0.2), Some((Chainbreaker, 6)), 1000)], branch: Some(("Loot(5000)", 17)), ..s((104, 96), 9115, 5500.0, (1, 1, 1, 2), &["Freed(1)", "Towers"]) },
|
||||||
|
Spec { islands: &[((0.5, 0.5), true, true, 3000)], raids: &[(150.0, true, "Mage", 2, 5, 0)], ..s((104, 104), 9116, 6000.0, (0, 2, 1, 2), &["Springs(1)", "KillAll"]) },
|
||||||
|
Spec { raids: &[(60.0, true, "Ogre", 2, 6, 0), (120.0, true, "Wyrm", 1, 5, 1)], ..s((104, 104), 9117, 6000.0, (1, 1, 1, 2), &["KillAll"]) },
|
||||||
|
s((112, 104), 9118, 6500.0, (2, 1, 3, 2), &["Towers"]),
|
||||||
|
Spec { boss: Some(("Paladin", 10, "marshal", 0)), raids: &[(120.0, true, "Knight", 3, 6, 1), (120.0, true, "Archer", 3, 6, 2)], ..s((112, 112), 9119, 7000.0, (2, 0, 2, 2), &[]) },
|
||||||
|
Spec { boss: Some(("Wyrm", 10, "mossgrim", 0)), raids: &[(90.0, true, "Ogre", 3, 7, 1), (90.0, true, "Gargoyle", 2, 7, 2)], ..s((112, 112), 9120, 8000.0, (1, 2, 2, 2), &["KillAll"]) },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn n(x: f64) -> Val {
|
||||||
|
Val::num(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(i: usize, part: &str) -> String {
|
||||||
|
format!("coop.m{:02}.{}", i + 1, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn say(k: String) -> Block {
|
||||||
|
Block::Message { text: tr(&k) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// the co-op campaign, built in the player's language
|
||||||
|
pub fn coop_campaign() -> CampaignDoc {
|
||||||
|
let mut doc = CampaignDoc { name: tr("coop.name"), author: "DungeonCraft".into(), language: crate::i18n::language(), ..CampaignDoc::default() };
|
||||||
|
let saved = (crate::map::mw(), crate::map::mh());
|
||||||
|
let specs = specs();
|
||||||
|
for (i, sp) in specs.iter().enumerate() {
|
||||||
|
let mut m = MissionDoc {
|
||||||
|
key: format!("c{:02}", i + 1),
|
||||||
|
title: tr(&key(i, "title")),
|
||||||
|
story: tr(&key(i, "story")).replace("\\n", "\n"),
|
||||||
|
size: sp.size,
|
||||||
|
seed: sp.seed,
|
||||||
|
gold: sp.gold,
|
||||||
|
portals: 2,
|
||||||
|
goals: sp.goals.iter().map(|g| g.to_string()).collect(),
|
||||||
|
waves: sp.waves.0,
|
||||||
|
wave_limit: sp.waves.1,
|
||||||
|
first_wave: if sp.waves.0 { sp.waves.2 } else { 300.0 },
|
||||||
|
gen: GenDoc { rivers: sp.gen.0, lava: sp.gen.1, forts: sp.gen.2, rivals: 0, difficulty: sp.gen.3, surface: i + 1 == specs.len() },
|
||||||
|
next: (i + 1 < specs.len()).then(|| format!("c{:02}", i + 2)),
|
||||||
|
pos: (40.0 + (i % 5) as f32 * 220.0, 40.0 + (i / 5) as f32 * 140.0),
|
||||||
|
..MissionDoc::default()
|
||||||
|
};
|
||||||
|
if let Some((goal, to)) = sp.branch {
|
||||||
|
m.branches.push(BranchDoc { id: "detour".into(), goal: goal.into(), title: tr(&key(i, "branch")), next: format!("c{:02}", to), priority: Some(1) });
|
||||||
|
}
|
||||||
|
// the map: generated, then fixed with the islands, caves and the places where enemies appear
|
||||||
|
let mut g = crate::campaign::start_editor_mission(&m, i, sp.seed);
|
||||||
|
for (k, &(at, lava, spring, treasure)) in sp.islands.iter().enumerate() {
|
||||||
|
crate::campaign::editor_island(&mut g, at, lava, spring, treasure, sp.seed + k as u32);
|
||||||
|
}
|
||||||
|
for &(at, chained, treasure) in sp.caves.iter() {
|
||||||
|
crate::campaign::editor_cave(&mut g, at, chained, treasure);
|
||||||
|
}
|
||||||
|
let (w, h) = (g.map.w as i32, g.map.h as i32);
|
||||||
|
for (name, (fx, fy)) in ZONES {
|
||||||
|
let (cx, cy) = (((fx * w as f32) as i32).clamp(4, w - 5), ((fy * h as f32) as i32).clamp(4, h - 5));
|
||||||
|
for y in cy - 2..=cy + 2 {
|
||||||
|
for x in cx - 2..=cx + 2 {
|
||||||
|
let t = &mut g.map.t[crate::map::idx(x, y)];
|
||||||
|
if !matches!(t.kind, crate::map::Kind::Core | crate::map::Kind::Portal | crate::map::Kind::Gate | crate::map::Kind::Tower) {
|
||||||
|
t.kind = crate::map::Kind::Floor;
|
||||||
|
t.owner = crate::map::Owner::Hero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.zones.push(ZoneDoc { name: name.into(), shape: "rect".into(), at: None, radius: None, tiles: None, from: Some(((cx - 2) as f32, (cy - 2) as f32)), to: Some(((cx + 2) as f32, (cy + 2) as f32)), width: None });
|
||||||
|
}
|
||||||
|
g.recount();
|
||||||
|
g.place_all_torches();
|
||||||
|
g.init_fog();
|
||||||
|
m.map = MapDoc::Fixed { game: serde_json::to_string(&g).unwrap_or_default() };
|
||||||
|
|
||||||
|
// the scripts: Crookfinger's start, the objective, story lines, raids and the boss
|
||||||
|
let sc = &mut m.scripts;
|
||||||
|
sc.start = vec![say(key(i, "start")), Block::Objective { text: tr(&key(i, "goal")) }];
|
||||||
|
sc.triggers.push(TriggerDoc { name: "story1".into(), kind: TriggerKind::Timer { secs: 90.0, every: false }, once: true, enabled: true, script: vec![Block::Hint { text: tr(&key(i, "t1")) }] });
|
||||||
|
sc.triggers.push(TriggerDoc { name: "story2".into(), kind: TriggerKind::Timer { secs: 360.0, every: false }, once: true, enabled: true, script: vec![Block::Hint { text: tr(&key(i, "t2")) }] });
|
||||||
|
for (k, &(secs, every, kind, count, level, zone)) in sp.raids.iter().enumerate() {
|
||||||
|
sc.triggers.push(TriggerDoc {
|
||||||
|
name: format!("raid{}", k + 1),
|
||||||
|
kind: TriggerKind::Timer { secs, every },
|
||||||
|
once: !every,
|
||||||
|
enabled: true,
|
||||||
|
script: vec![
|
||||||
|
Block::Message { text: tr("coop.raid") },
|
||||||
|
Block::Spawn { side: 1, kind: kind.into(), count: n(count as f64), level: n(level as f64), place: Place::Zone(ZONES[zone].0.into()), name: String::new() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// the rogues of the Order in the later missions: thieves from mission 7, an inquisitor
|
||||||
|
// from mission 15; the last mission (above ground) brews a storm
|
||||||
|
let lv = n((1 + i / 3) as f64);
|
||||||
|
if i >= 6 {
|
||||||
|
sc.triggers.push(TriggerDoc {
|
||||||
|
name: "thieves".into(),
|
||||||
|
kind: TriggerKind::Timer { secs: 420.0, every: false },
|
||||||
|
once: true,
|
||||||
|
enabled: true,
|
||||||
|
script: vec![Block::Message { text: tr("coop.raid") }, Block::Spawn { side: 1, kind: "Thief".into(), count: n(2.0), level: lv.clone(), place: Place::Zone(ZONES[i % 3].0.into()), name: String::new() }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if i >= 14 {
|
||||||
|
sc.triggers.push(TriggerDoc {
|
||||||
|
name: "inquisitor".into(),
|
||||||
|
kind: TriggerKind::Timer { secs: 600.0, every: false },
|
||||||
|
once: true,
|
||||||
|
enabled: true,
|
||||||
|
script: vec![
|
||||||
|
Block::Message { text: tr("coop.raid") },
|
||||||
|
Block::Spawn { side: 1, kind: "Inquisitor".into(), count: n(1.0), level: lv.clone(), place: Place::Zone(ZONES[(i + 1) % 3].0.into()), name: String::new() },
|
||||||
|
Block::Spawn { side: 1, kind: "Monk".into(), count: n(2.0), level: lv.clone(), place: Place::Zone(ZONES[(i + 1) % 3].0.into()), name: String::new() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if i + 1 == specs.len() {
|
||||||
|
sc.triggers.push(TriggerDoc {
|
||||||
|
name: "storm".into(),
|
||||||
|
kind: TriggerKind::Timer { secs: 480.0, every: false },
|
||||||
|
once: true,
|
||||||
|
enabled: true,
|
||||||
|
script: vec![Block::Weather { kind: "storm".into(), secs: n(120.0) }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some((kind, level, name, zone)) = sp.boss {
|
||||||
|
sc.start.push(Block::Spawn { side: 1, kind: kind.into(), count: n(1.0), level: n(level as f64), place: Place::Zone(ZONES[zone].0.into()), name: name.into() });
|
||||||
|
// the boss falls: the mission is won once the goals (if any) are done too
|
||||||
|
let win = if sp.goals.is_empty() { vec![say(key(i, "boss")), Block::Win] } else { vec![say(key(i, "boss")), Block::SetVar { name: "boss".into(), value: n(1.0) }] };
|
||||||
|
sc.triggers.push(TriggerDoc { name: "boss".into(), kind: TriggerKind::UnitDies { side: 1, kind: String::new(), name: name.into() }, once: true, enabled: true, script: win });
|
||||||
|
if !sp.goals.is_empty() {
|
||||||
|
// KillAll covers the boss as well: nothing more to do
|
||||||
|
sc.victory = vec![];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doc.missions.push(m);
|
||||||
|
}
|
||||||
|
crate::map::set_dims(saved.0, saved.1);
|
||||||
|
doc
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_coop_campaign_is_complete_and_valid() {
|
||||||
|
let doc = coop_campaign();
|
||||||
|
assert_eq!(doc.missions.len(), 20);
|
||||||
|
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||||
|
for m in &doc.missions {
|
||||||
|
assert!(m.size.0 >= 72 && m.size.1 >= 72, "{} fits the co-op master areas", m.key);
|
||||||
|
assert!(!m.goals.is_empty() || m.scripts.triggers.iter().any(|t| t.name == "boss"), "{} can be won", m.key);
|
||||||
|
assert!(matches!(m.map, MapDoc::Fixed { .. }));
|
||||||
|
}
|
||||||
|
// every mission starts and its scripts run
|
||||||
|
for (i, m) in doc.missions.iter().enumerate().step_by(5) {
|
||||||
|
let mut g = crate::campaign::start_editor_mission(m, i, 3);
|
||||||
|
for _ in 0..(20 * 5) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
assert!(g.script.as_ref().unwrap().error.is_none());
|
||||||
|
assert_eq!(g.state, 0, "{} is not decided at once", m.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ enum Cat {
|
|||||||
|
|
||||||
const CATS: [Cat; 9] = [Cat::Terrain, Cat::Rooms, Cat::Places, Cat::Units, Cat::Towers, Cat::Traps, Cat::Forts, Cat::Zones, Cat::Erase];
|
const CATS: [Cat; 9] = [Cat::Terrain, Cat::Rooms, Cat::Places, Cat::Units, Cat::Towers, Cat::Traps, Cat::Forts, Cat::Zones, Cat::Erase];
|
||||||
|
|
||||||
const TERRAIN: [&str; 13] = ["floor", "earth", "reinforced", "rock", "water", "lava", "bridge", "lavabridge", "gold", "silver", "ruby", "diamond", "steel"];
|
const TERRAIN: [&str; 17] = ["floor", "earth", "reinforced", "rock", "water", "lava", "bridge", "lavabridge", "gold", "silver", "ruby", "diamond", "steel", "mushroom", "grove", "grass", "tree"];
|
||||||
const PLACES: [&str; 7] = ["core", "portal", "gate", "spring", "shard", "chest", "chained"];
|
const PLACES: [&str; 7] = ["core", "portal", "gate", "spring", "shard", "chest", "chained"];
|
||||||
const OWNERS: [Owner; 6] = [Owner::Player, Owner::Hero, Owner::Rival1, Owner::Rival2, Owner::Rival3, Owner::None];
|
const OWNERS: [Owner; 6] = [Owner::Player, Owner::Hero, Owner::Rival1, Owner::Rival2, Owner::Rival3, Owner::None];
|
||||||
const ZONE_SHAPES: [&str; 4] = ["rect", "circle", "tiles", "line"];
|
const ZONE_SHAPES: [&str; 4] = ["rect", "circle", "tiles", "line"];
|
||||||
@@ -61,6 +61,8 @@ pub struct MapEdit {
|
|||||||
zone: usize, // selected zone
|
zone: usize, // selected zone
|
||||||
zone_shape: usize, // shape for a new zone
|
zone_shape: usize, // shape for a new zone
|
||||||
zone_name: String,
|
zone_name: String,
|
||||||
|
zone_erase: bool, // painting a tiles zone takes tiles out of it again
|
||||||
|
zone_scroll: usize, // first zone shown in the list (usize::MAX: scroll to the end)
|
||||||
last_mouse: Option<Vec2>,
|
last_mouse: Option<Vec2>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +75,10 @@ fn kind_of_terrain(t: &str) -> (Kind, bool) {
|
|||||||
"lava" => (Kind::Lava, false),
|
"lava" => (Kind::Lava, false),
|
||||||
"bridge" => (Kind::Bridge, false),
|
"bridge" => (Kind::Bridge, false),
|
||||||
"lavabridge" => (Kind::LavaBridge, false),
|
"lavabridge" => (Kind::LavaBridge, false),
|
||||||
|
"mushroom" => (Kind::Mushroom, false),
|
||||||
|
"grove" => (Kind::Grove, false),
|
||||||
|
"grass" => (Kind::Floor, false),
|
||||||
|
"tree" => (Kind::Tree, false),
|
||||||
"gold" => (Kind::Gold, false),
|
"gold" => (Kind::Gold, false),
|
||||||
"silver" => (Kind::Silver, false),
|
"silver" => (Kind::Silver, false),
|
||||||
"ruby" => (Kind::Ruby, false),
|
"ruby" => (Kind::Ruby, false),
|
||||||
@@ -117,6 +123,8 @@ impl MapEdit {
|
|||||||
zone: 0,
|
zone: 0,
|
||||||
zone_shape: 0,
|
zone_shape: 0,
|
||||||
zone_name: String::new(),
|
zone_name: String::new(),
|
||||||
|
zone_erase: false,
|
||||||
|
zone_scroll: 0,
|
||||||
last_mouse: None,
|
last_mouse: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,14 +212,27 @@ impl MapEdit {
|
|||||||
}
|
}
|
||||||
match self.cat {
|
match self.cat {
|
||||||
Cat::Terrain => {
|
Cat::Terrain => {
|
||||||
let (k, reinforced) = kind_of_terrain(TERRAIN[self.item.min(TERRAIN.len() - 1)]);
|
let name = TERRAIN[self.item.min(TERRAIN.len() - 1)];
|
||||||
|
let (k, reinforced) = kind_of_terrain(name);
|
||||||
|
// grass and trees lie above ground; anything else is painted underground
|
||||||
|
self.game.map.set_sky(i, matches!(name, "grass" | "tree"));
|
||||||
self.game.torches.retain(|tr| tr.wall != i);
|
self.game.torches.retain(|tr| tr.wall != i);
|
||||||
let owner = if k == Kind::Floor || reinforced || matches!(k, Kind::Bridge | Kind::LavaBridge) { OWNERS[self.owner] } else { Owner::None };
|
let owner = if k == Kind::Floor || reinforced || matches!(k, Kind::Bridge | Kind::LavaBridge) { OWNERS[self.owner] } else { Owner::None };
|
||||||
let tl = &mut self.game.map.t[i];
|
let tl = &mut self.game.map.t[i];
|
||||||
|
// a mushroom painted into a grove belongs to it (it leaves moss when felled); fully grown
|
||||||
|
if k == Kind::Mushroom {
|
||||||
|
tl.res = if tl.kind == Kind::Grove || tl.res & crate::map::MUSH_GROVE != 0 { crate::map::MUSH_GROVE } else { 0 };
|
||||||
|
tl.food = 0.0;
|
||||||
|
} else if tl.kind == Kind::Mushroom {
|
||||||
|
tl.res = 0;
|
||||||
|
}
|
||||||
tl.kind = k;
|
tl.kind = k;
|
||||||
tl.reinforced = reinforced;
|
tl.reinforced = reinforced;
|
||||||
tl.owner = if reinforced && owner == Owner::None { Owner::Player } else { owner };
|
tl.owner = if reinforced && owner == Owner::None { Owner::Player } else { owner };
|
||||||
tl.marked = false;
|
tl.marked = false;
|
||||||
|
if tl.plant() {
|
||||||
|
tl.hp = if k == Kind::Tree { crate::wood::TREE_HP } else { crate::wood::MUSHROOM_HP };
|
||||||
|
}
|
||||||
tl.gold = match k {
|
tl.gold = match k {
|
||||||
Kind::Gold => 1200,
|
Kind::Gold => 1200,
|
||||||
Kind::Silver => 900,
|
Kind::Silver => 900,
|
||||||
@@ -460,9 +481,18 @@ impl MapEdit {
|
|||||||
}
|
}
|
||||||
m.zones.push(z);
|
m.zones.push(z);
|
||||||
self.zone = m.zones.len() - 1;
|
self.zone = m.zones.len() - 1;
|
||||||
|
self.zone_scroll = usize::MAX;
|
||||||
self.zone_name.clear();
|
self.zone_name.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the brush takes tiles out of the selected tiles zone
|
||||||
|
fn erase_zone_tiles(&mut self, m: &mut MissionDoc, x: i32, y: i32) {
|
||||||
|
let targets = self.targets(x, y);
|
||||||
|
if let Some(t) = m.zones.get_mut(self.zone).filter(|z| z.shape == "tiles").and_then(|z| z.tiles.as_mut()) {
|
||||||
|
t.retain(|p| !targets.contains(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn frame(&mut self, im: &mut Im, gfx: &Gfx, r3d: &mut R3d, dt: f32, now: f64, m: &mut MissionDoc) -> MapResult {
|
pub fn frame(&mut self, im: &mut Im, gfx: &Gfx, r3d: &mut R3d, dt: f32, now: f64, m: &mut MissionDoc) -> MapResult {
|
||||||
let (cssx, cssy) = mouse_position();
|
let (cssx, cssy) = mouse_position();
|
||||||
let bar_w = CW() * 30.0;
|
let bar_w = CW() * 30.0;
|
||||||
@@ -547,6 +577,15 @@ impl MapEdit {
|
|||||||
}
|
}
|
||||||
im.pressed = false;
|
im.pressed = false;
|
||||||
}
|
}
|
||||||
|
// a tiles zone is painted (or erased) all along the drag
|
||||||
|
let tiles_zone = self.cat == Cat::Zones && ZONE_SHAPES[self.zone_shape] == "tiles";
|
||||||
|
if tiles_zone && im.down && self.drag.is_some() {
|
||||||
|
if self.zone_erase {
|
||||||
|
self.erase_zone_tiles(m, x, y);
|
||||||
|
} else {
|
||||||
|
self.draw_zone(m, x, y, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
if self.stroke && im.down {
|
if self.stroke && im.down {
|
||||||
for (tx, ty) in self.targets(x, y) {
|
for (tx, ty) in self.targets(x, y) {
|
||||||
self.paint_tile(tx, ty);
|
self.paint_tile(tx, ty);
|
||||||
@@ -555,7 +594,11 @@ impl MapEdit {
|
|||||||
if im.released {
|
if im.released {
|
||||||
if let Some((sx, sy)) = self.drag {
|
if let Some((sx, sy)) = self.drag {
|
||||||
if self.cat == Cat::Zones {
|
if self.cat == Cat::Zones {
|
||||||
|
if ZONE_SHAPES[self.zone_shape] == "tiles" && self.zone_erase {
|
||||||
|
self.erase_zone_tiles(m, x, y);
|
||||||
|
} else {
|
||||||
self.draw_zone(m, sx, sy, x, y);
|
self.draw_zone(m, sx, sy, x, y);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for (tx, ty) in self.targets(x, y) {
|
for (tx, ty) in self.targets(x, y) {
|
||||||
self.paint_tile(tx, ty);
|
self.paint_tile(tx, ty);
|
||||||
@@ -738,7 +781,24 @@ impl MapEdit {
|
|||||||
label(gfx, x, y, &tr("ed.zones"), FRAME_HI);
|
label(gfx, x, y, &tr("ed.zones"), FRAME_HI);
|
||||||
y += CH() * 1.4;
|
y += CH() * 1.4;
|
||||||
let mut remove = None;
|
let mut remove = None;
|
||||||
for k in 0..m.zones.len() {
|
// the list scrolls (wheel over it) when there are more zones than room
|
||||||
|
let row = CH() * 1.7;
|
||||||
|
let room = (((h - CH() * 3.0 - y) / row).floor() as usize).max(1);
|
||||||
|
let over_list = im.mx < bar_w && im.my >= y && im.my < y + room as f32 * row;
|
||||||
|
if over_list && im.wheel != 0.0 {
|
||||||
|
self.zone_scroll = if im.wheel > 0.0 { self.zone_scroll.min(m.zones.len()).saturating_sub(1) } else { self.zone_scroll.saturating_add(1) };
|
||||||
|
}
|
||||||
|
let max_scroll = m.zones.len().saturating_sub(room);
|
||||||
|
if self.zone_scroll == usize::MAX {
|
||||||
|
// a new zone: show it
|
||||||
|
self.zone_scroll = self.zone.saturating_sub(room - 1);
|
||||||
|
}
|
||||||
|
self.zone_scroll = self.zone_scroll.min(max_scroll);
|
||||||
|
if self.zone_scroll > 0 || max_scroll > 0 {
|
||||||
|
let more = format!("{}-{} / {}", self.zone_scroll + 1, (self.zone_scroll + room).min(m.zones.len()), m.zones.len());
|
||||||
|
gfx.text(&more, x + fw - gfx.measure(&more, SMALL()), y - CH() * 1.3, SMALL(), DIM);
|
||||||
|
}
|
||||||
|
for k in self.zone_scroll..m.zones.len() {
|
||||||
if y > h - CH() * 3.0 {
|
if y > h - CH() * 3.0 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -778,6 +838,10 @@ impl MapEdit {
|
|||||||
}
|
}
|
||||||
bx += gfx.measure(&tr("ed.fill"), S()) + CW() * 4.0;
|
bx += gfx.measure(&tr("ed.fill"), S()) + CW() * 4.0;
|
||||||
}
|
}
|
||||||
|
if self.cat == Cat::Zones && ZONE_SHAPES[self.zone_shape] == "tiles" {
|
||||||
|
im.toggle(gfx, bx, by + CH() * 0.15, &tr("ed.zone_erase"), &mut self.zone_erase);
|
||||||
|
bx += gfx.measure(&tr("ed.zone_erase"), S()) + CW() * 4.0;
|
||||||
|
}
|
||||||
let mirrors: Vec<String> = (0..4).map(|k| tr(&format!("ed.mirror{}", k))).collect();
|
let mirrors: Vec<String> = (0..4).map(|k| tr(&format!("ed.mirror{}", k))).collect();
|
||||||
if let Some(c) = im.dropdown(gfx, id("mirror", 0), Rect::new(bx, by, CW() * 23.0, CH() * 1.7), &mirrors[self.mirror as usize], &mirrors) {
|
if let Some(c) = im.dropdown(gfx, id("mirror", 0), Rect::new(bx, by, CW() * 23.0, CH() * 1.7), &mirrors[self.mirror as usize], &mirrors) {
|
||||||
self.mirror = c as u8;
|
self.mirror = c as u8;
|
||||||
@@ -799,3 +863,27 @@ impl MapEdit {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tiles_of_a_painted_zone_can_be_taken_out_again() {
|
||||||
|
let mut ed = MapEdit::new(Game::new(3, 64, 64));
|
||||||
|
let mut m = MissionDoc::default();
|
||||||
|
ed.zone_shape = ZONE_SHAPES.iter().position(|s| *s == "tiles").unwrap();
|
||||||
|
ed.brush = 2;
|
||||||
|
ed.draw_zone(&mut m, 10, 10, 10, 10);
|
||||||
|
ed.draw_zone(&mut m, 14, 10, 14, 10);
|
||||||
|
assert_eq!(m.zones.len(), 1, "painting goes on in the selected tiles zone");
|
||||||
|
let n = m.zones[0].tiles.as_ref().unwrap().len();
|
||||||
|
assert!(n >= 2);
|
||||||
|
ed.erase_zone_tiles(&mut m, 10, 10);
|
||||||
|
let t = m.zones[0].tiles.as_ref().unwrap();
|
||||||
|
assert!(!t.contains(&(10, 10)) && t.contains(&(14, 10)));
|
||||||
|
assert!(t.len() < n);
|
||||||
|
// a new zone scrolls the list to it
|
||||||
|
assert_eq!(ed.zone_scroll, usize::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
// handles what it returns (test play, files, the savegame service).
|
// handles what it returns (test play, files, the savegame service).
|
||||||
|
|
||||||
pub mod blocks_ui;
|
pub mod blocks_ui;
|
||||||
|
pub mod coop_campaign;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
pub mod templates;
|
pub mod templates;
|
||||||
pub mod widgets;
|
pub mod widgets;
|
||||||
@@ -533,6 +534,13 @@ impl Editor {
|
|||||||
self.open_dialog = false;
|
self.open_dialog = false;
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
y += CH() * 2.2;
|
||||||
|
// the built-in co-op campaign, as a copy to change or to learn from
|
||||||
|
if im.button(gfx, Rect::new(x, y, bw * 2.0 + CW(), CH() * 1.7), &tr("net.coop_league")) {
|
||||||
|
self.load(coop_campaign::coop_campaign(), None);
|
||||||
|
self.open_dialog = false;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
y += CH() * 2.6;
|
y += CH() * 2.6;
|
||||||
// a mission of the built-in campaign as a copy
|
// a mission of the built-in campaign as a copy
|
||||||
label(gfx, x, y, &tr("ed.template"), FRAME_HI);
|
label(gfx, x, y, &tr("ed.template"), FRAME_HI);
|
||||||
@@ -769,6 +777,7 @@ impl Editor {
|
|||||||
(tr("ed.rivals"), m.gen.rivals.to_string(), 6),
|
(tr("ed.rivals"), m.gen.rivals.to_string(), 6),
|
||||||
(tr("ed.difficulty"), tr(["diff.0", "diff.1", "diff.2"][m.gen.difficulty.min(2) as usize]), 7),
|
(tr("ed.difficulty"), tr(["diff.0", "diff.1", "diff.2"][m.gen.difficulty.min(2) as usize]), 7),
|
||||||
(tr("ed.portals"), m.portals.to_string(), 8),
|
(tr("ed.portals"), m.portals.to_string(), 8),
|
||||||
|
(tr("ed.surface"), tr(if m.gen.surface { "val.on" } else { "val.off" }), 9),
|
||||||
];
|
];
|
||||||
if fixed {
|
if fixed {
|
||||||
rows.truncate(0);
|
rows.truncate(0);
|
||||||
@@ -786,6 +795,7 @@ impl Editor {
|
|||||||
5 => m.gen.forts = (m.gen.forts as i32 + d.signum()).clamp(0, 3) as u8,
|
5 => m.gen.forts = (m.gen.forts as i32 + d.signum()).clamp(0, 3) as u8,
|
||||||
6 => m.gen.rivals = (m.gen.rivals as i32 + d.signum()).clamp(0, 2) as u8,
|
6 => m.gen.rivals = (m.gen.rivals as i32 + d.signum()).clamp(0, 2) as u8,
|
||||||
7 => m.gen.difficulty = (m.gen.difficulty as i32 + d.signum()).clamp(0, 2) as u8,
|
7 => m.gen.difficulty = (m.gen.difficulty as i32 + d.signum()).clamp(0, 2) as u8,
|
||||||
|
9 => m.gen.surface = !m.gen.surface,
|
||||||
_ => m.portals = (m.portals as i32 + d.signum()).clamp(0, 6) as usize,
|
_ => m.portals = (m.portals as i32 + d.signum()).clamp(0, 6) as usize,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ pub fn demo_campaign() -> CampaignDoc {
|
|||||||
seed: 4242,
|
seed: 4242,
|
||||||
gold: 4000.0,
|
gold: 4000.0,
|
||||||
goals: vec!["Towers".into()],
|
goals: vec!["Towers".into()],
|
||||||
gen: GenDoc { rivers: 1, lava: 0, forts: 1, rivals: 0, difficulty: 0 },
|
gen: GenDoc { rivers: 1, lava: 0, forts: 1, rivals: 0, difficulty: 0, surface: false },
|
||||||
next: Some("demo2".into()),
|
next: Some("demo2".into()),
|
||||||
branches: vec![BranchDoc { id: "rich".into(), goal: "Loot(2000)".into(), title: tr("demo.branch"), next: "demo2".into(), priority: Some(1) }],
|
branches: vec![BranchDoc { id: "rich".into(), goal: "Loot(2000)".into(), title: tr("demo.branch"), next: "demo2".into(), priority: Some(1) }],
|
||||||
pos: (40.0, 60.0),
|
pos: (40.0, 60.0),
|
||||||
@@ -226,7 +226,7 @@ pub fn demo_campaign() -> CampaignDoc {
|
|||||||
seed: 77,
|
seed: 77,
|
||||||
gold: 3000.0,
|
gold: 3000.0,
|
||||||
goals: vec!["KillAll".into()],
|
goals: vec!["KillAll".into()],
|
||||||
gen: GenDoc { rivers: 1, lava: 1, forts: 1, rivals: 0, difficulty: 0 },
|
gen: GenDoc { rivers: 1, lava: 1, forts: 1, rivals: 0, difficulty: 0, surface: false },
|
||||||
pos: (300.0, 60.0),
|
pos: (300.0, 60.0),
|
||||||
..MissionDoc::default()
|
..MissionDoc::default()
|
||||||
};
|
};
|
||||||
@@ -238,6 +238,16 @@ pub fn demo_campaign() -> CampaignDoc {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn the_tutorial_is_a_valid_campaign() {
|
||||||
|
let doc = super::tutorial_campaign();
|
||||||
|
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||||
|
assert_eq!(doc.missions.len(), 1);
|
||||||
|
let g = crate::campaign::start_editor_mission(&doc.missions[0], 0, 1);
|
||||||
|
assert!(g.script.is_some());
|
||||||
|
assert!(!g.ents.iter().any(|e| e.alive && e.active_hero()), "no knight before the last step");
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -278,3 +288,94 @@ mod tests {
|
|||||||
assert!(g.mission.as_ref().unwrap().goals.contains(&Goal::Shards), "the shard of the outpost is still there");
|
assert!(g.mission.as_ref().unwrap().goals.contains(&Goal::Shards), "the shard of the outpost is still there");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tutorial: Crookfinger leads a new master step by step - dig the tunnel, claim the hall,
|
||||||
|
// build a lair and a mushroom garden, wait for the first creatures, slap one, and beat the first
|
||||||
|
// two knights. Each step switches on the trigger of the next one. Played like an editor campaign
|
||||||
|
// (title menu: Tutorial); texts come from the language files (tut.*).
|
||||||
|
pub fn tutorial_campaign() -> CampaignDoc {
|
||||||
|
let mut doc = CampaignDoc { name: tr("tut.name"), author: "DungeonCraft".into(), language: crate::i18n::language(), ..CampaignDoc::default() };
|
||||||
|
let mut m = MissionDoc {
|
||||||
|
key: "tutorial".into(),
|
||||||
|
title: tr("tut.title"),
|
||||||
|
story: tr("tut.story").replace("\\n", "\n"),
|
||||||
|
size: (64, 64),
|
||||||
|
seed: 777,
|
||||||
|
gold: 3000.0,
|
||||||
|
goals: Vec::new(),
|
||||||
|
gen: GenDoc { rivers: 0, lava: 0, forts: 0, rivals: 0, difficulty: 0, surface: false },
|
||||||
|
pos: (40.0, 60.0),
|
||||||
|
..MissionDoc::default()
|
||||||
|
};
|
||||||
|
let saved = (crate::map::mw(), crate::map::mh());
|
||||||
|
let mut g = crate::campaign::start_editor_mission(&m, 0, 777);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let (w, h) = (g.map.w as i32, g.map.h as i32);
|
||||||
|
let cl = |x: i32, y: i32| (x.clamp(3, w - 4), y.clamp(3, h - 4));
|
||||||
|
// east of the heart: a tunnel of earth to dig, then a hall of open ground behind it
|
||||||
|
let tunnel: Vec<(i32, i32)> = (cx + 4..cx + 8).flat_map(|x| [(x, cy + 1), (x, cy + 2)]).map(|(x, y)| cl(x, y)).collect();
|
||||||
|
let (hx0, hy0) = cl(cx + 8, cy - 2);
|
||||||
|
let (hx1, hy1) = cl(cx + 15, cy + 5);
|
||||||
|
for y in hy0 - 1..=hy1 + 1 {
|
||||||
|
for x in hx0..=hx1 + 1 {
|
||||||
|
let t = &mut g.map.t[crate::map::idx(x, y)];
|
||||||
|
let edge = y == hy0 - 1 || y == hy1 + 1 || x == hx1 + 1;
|
||||||
|
t.kind = if edge { crate::map::Kind::Earth } else { crate::map::Kind::Floor };
|
||||||
|
t.owner = crate::map::Owner::None;
|
||||||
|
t.gold = 0;
|
||||||
|
t.reinforced = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for &(x, y) in &tunnel {
|
||||||
|
let t = &mut g.map.t[crate::map::idx(x, y)];
|
||||||
|
t.kind = crate::map::Kind::Earth;
|
||||||
|
t.owner = crate::map::Owner::None;
|
||||||
|
t.hp = 3.0;
|
||||||
|
}
|
||||||
|
// the knights come from a cave further east
|
||||||
|
let (kx, ky) = cl(cx + 22, cy + 1);
|
||||||
|
for y in ky - 2..=ky + 2 {
|
||||||
|
for x in kx - 2..=kx + 2 {
|
||||||
|
let t = &mut g.map.t[crate::map::idx(x, y)];
|
||||||
|
t.kind = crate::map::Kind::Floor;
|
||||||
|
t.owner = crate::map::Owner::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for x in hx1 + 1..kx - 2 {
|
||||||
|
let t = &mut g.map.t[crate::map::idx(x, cy + 1)];
|
||||||
|
t.kind = crate::map::Kind::Earth;
|
||||||
|
t.owner = crate::map::Owner::None;
|
||||||
|
}
|
||||||
|
g.ents.retain(|e| !e.hero);
|
||||||
|
g.recount();
|
||||||
|
g.init_fog();
|
||||||
|
m.zones = vec![
|
||||||
|
ZoneDoc { name: "tunnel".into(), shape: "tiles".into(), at: None, radius: None, tiles: Some(tunnel), from: None, to: None, width: None },
|
||||||
|
zone_rect("hall", hx0, hy0, hx1, hy1),
|
||||||
|
ZoneDoc { name: "knights".into(), shape: "circle".into(), at: Some((kx as f32, ky as f32)), radius: Some(1.5), tiles: None, from: None, to: None, width: None },
|
||||||
|
];
|
||||||
|
m.map = MapDoc::Fixed { game: serde_json::to_string(&g).unwrap_or_default() };
|
||||||
|
crate::map::set_dims(saved.0, saved.1);
|
||||||
|
|
||||||
|
let s = &mut m.scripts;
|
||||||
|
let obj = |k: &str| Block::Objective { text: tr(k) };
|
||||||
|
let on = |t: &str| Block::Trigger { name: t.into(), on: true };
|
||||||
|
s.start = vec![say("tut.start"), obj("tut.obj1"), Block::SetVar { name: "knights".into(), value: n(0.0) }, Block::Camera { zone: "tunnel".into() }];
|
||||||
|
s.victory = vec![Block::If { cond: cmp(">=", var("knights"), n(2.0)), then: vec![say("tut.win"), Block::Win], els: vec![] }];
|
||||||
|
let off = |t: TriggerDoc| TriggerDoc { enabled: false, ..t };
|
||||||
|
s.triggers = vec![
|
||||||
|
trig("dug", TriggerKind::ZoneDug { zone: "tunnel".into() }, true, vec![say("tut.dug"), obj("tut.obj2"), on("lair")]),
|
||||||
|
off(trig("lair", TriggerKind::RoomBuilt { room: "Lair".into() }, true, vec![say("tut.lair"), obj("tut.obj3"), on("farm")])),
|
||||||
|
off(trig("farm", TriggerKind::RoomBuilt { room: "Farm".into() }, true, vec![say("tut.farm"), obj("tut.obj4"), on("creatures")])),
|
||||||
|
off(trig("creatures", TriggerKind::Value { what: "creatures".into(), var: String::new(), op: ">=".into(), n: 2.0 }, true, vec![say("tut.creatures"), obj("tut.obj5"), on("slap")])),
|
||||||
|
off(trig(
|
||||||
|
"slap",
|
||||||
|
TriggerKind::Slap,
|
||||||
|
true,
|
||||||
|
vec![say("tut.slap"), obj("tut.obj6"), Block::Wait { secs: n(20.0) }, Block::Spawn { side: 1, kind: "Knight".into(), count: n(2.0), level: n(1.0), place: Place::Zone("knights".into()), name: String::new() }, say("tut.knights"), Block::Camera { zone: "knights".into() }, on("knight")],
|
||||||
|
)),
|
||||||
|
off(trig("knight", TriggerKind::UnitDies { side: 1, kind: String::new(), name: String::new() }, false, vec![Block::AddVar { name: "knights".into(), value: n(1.0) }])),
|
||||||
|
];
|
||||||
|
doc.missions.push(m);
|
||||||
|
doc
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// Random events in free games and versus matches (rare; can be switched off): an earthquake
|
||||||
|
// brings down tunnels near the dungeon, lava or water overflows its banks, or the imps find a
|
||||||
|
// new gold vein. Shared state and the shared random numbers: every client of a match sees the
|
||||||
|
// same. Campaigns have none (their stories are told by the missions).
|
||||||
|
|
||||||
|
use crate::game::*;
|
||||||
|
use crate::map::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// seconds between two events: EVENT_GAP plus up to EVENT_SPREAD
|
||||||
|
pub const EVENT_GAP: f32 = 480.0;
|
||||||
|
pub const EVENT_SPREAD: f32 = 240.0;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Events {
|
||||||
|
pub t: f32, // seconds until the next one
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
// switches random events on (new free game or match) or off
|
||||||
|
pub fn set_events(&mut self, on: bool) {
|
||||||
|
self.happenings = if on && self.mission.is_none() { Some(Events { t: EVENT_GAP + self.rng.f() * EVENT_SPREAD }) } else { None };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update_events(&mut self, dt: f32) {
|
||||||
|
let Some(ev) = self.happenings.as_mut() else { return };
|
||||||
|
ev.t -= dt;
|
||||||
|
if ev.t > 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.t = EVENT_GAP + self.rng.f() * EVENT_SPREAD;
|
||||||
|
let kind = self.rng.next() % 4;
|
||||||
|
let happened = match kind {
|
||||||
|
0 => self.event_quake(),
|
||||||
|
1 => self.event_overflow(Kind::Lava),
|
||||||
|
2 => self.event_overflow(Kind::Water),
|
||||||
|
_ => self.event_vein(),
|
||||||
|
};
|
||||||
|
// nothing of that kind possible here: a gold vein instead (there is always earth)
|
||||||
|
if !happened && kind != 3 {
|
||||||
|
self.event_vein();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// own walkable ground within `r` of tile i?
|
||||||
|
fn near_own(&self, i: usize, r: i32) -> bool {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
(-r..=r).any(|dy| (-r..=r).any(|dx| self.map.get(x + dx, y + dy).map_or(false, |t| t.owner != Owner::None && t.owner != Owner::Hero && t.walkable())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn announce(&mut self, key: &'static str, i: usize) {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
let dir = format!("@{}", self.direction_key((x, y)));
|
||||||
|
// every master hears of it
|
||||||
|
let me = self.me_side;
|
||||||
|
for s in self.master_sides() {
|
||||||
|
self.switch_to(s);
|
||||||
|
self.msg(key, vec![dir.clone()]);
|
||||||
|
self.sfx_ui(Sfx::CoreHit);
|
||||||
|
}
|
||||||
|
self.switch_to(me);
|
||||||
|
self.add_mark(x as f32 + 0.5, y as f32 + 0.5, crate::game::MARK_BRIDGE, 255, 8.0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// an earthquake: plain tunnels (unowned, not rooms) near a dungeon cave in
|
||||||
|
fn event_quake(&mut self) -> bool {
|
||||||
|
let n = self.map.t.len();
|
||||||
|
let cands: Vec<usize> = (0..n).filter(|&i| {
|
||||||
|
let t = &self.map.t[i];
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
t.kind == Kind::Floor && t.owner == Owner::None && x > 2 && y > 2 && x < mw() as i32 - 3 && y < mh() as i32 - 3 && !self.map.sky(i) && self.near_own(i, 6)
|
||||||
|
}).collect();
|
||||||
|
if cands.len() < 4 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let centre = cands[(self.rng.next() as usize) % cands.len()];
|
||||||
|
let (cx, cy) = xy(centre);
|
||||||
|
let mut fell = 0;
|
||||||
|
for &i in &cands {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
if (x - cx).abs() + (y - cy).abs() > 3 || self.rng.f() < 0.35 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.ents.iter().any(|e| e.alive && e.tile() == i) {
|
||||||
|
continue; // nobody is buried alive (the quake is not that mean)
|
||||||
|
}
|
||||||
|
let t = &mut self.map.t[i];
|
||||||
|
t.kind = Kind::Earth;
|
||||||
|
t.hp = 6.0;
|
||||||
|
t.marked = false;
|
||||||
|
fell += 1;
|
||||||
|
}
|
||||||
|
if fell == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.announce("msg.event_quake", centre);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// a river overflows its banks: unowned open ground next to it fills
|
||||||
|
fn event_overflow(&mut self, river: Kind) -> bool {
|
||||||
|
let n = self.map.t.len();
|
||||||
|
let bank: Vec<usize> = (0..n).filter(|&i| {
|
||||||
|
let t = &self.map.t[i];
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
// no lava above ground
|
||||||
|
t.kind == Kind::Floor && t.owner == Owner::None && !(river == Kind::Lava && self.map.sky(i))
|
||||||
|
&& DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |o| o.kind == river))
|
||||||
|
}).collect();
|
||||||
|
if bank.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let start = bank[(self.rng.next() as usize) % bank.len()];
|
||||||
|
let (sx, sy) = xy(start);
|
||||||
|
let mut done = 0;
|
||||||
|
for &i in &bank {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
if (x - sx).abs() + (y - sy).abs() > 4 || done >= 8 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.map.t[i].kind = river;
|
||||||
|
self.ground_lost(i);
|
||||||
|
done += 1;
|
||||||
|
}
|
||||||
|
self.announce(if river == Kind::Lava { "msg.event_lava" } else { "msg.event_flood" }, start);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// the imps find a gold vein: earth near a dungeon turns to gold
|
||||||
|
fn event_vein(&mut self) -> bool {
|
||||||
|
let n = self.map.t.len();
|
||||||
|
let cands: Vec<usize> = (0..n).filter(|&i| self.map.t[i].kind == Kind::Earth && !self.map.t[i].reinforced && self.near_own(i, 5)).collect();
|
||||||
|
if cands.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let centre = cands[(self.rng.next() as usize) % cands.len()];
|
||||||
|
let (cx, cy) = xy(centre);
|
||||||
|
for &i in &cands {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
if (x - cx).abs() + (y - cy).abs() <= 2 {
|
||||||
|
let t = &mut self.map.t[i];
|
||||||
|
t.kind = Kind::Gold;
|
||||||
|
t.gold = 900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.announce("msg.event_vein", centre);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn free_games_have_rare_events() {
|
||||||
|
let mut g = Game::new(12, 80, 64);
|
||||||
|
g.set_events(true);
|
||||||
|
assert!(g.happenings.is_some());
|
||||||
|
let golds = |g: &Game| g.map.t.iter().filter(|t| t.kind == Kind::Gold).count();
|
||||||
|
let before = golds(&g);
|
||||||
|
g.happenings.as_mut().unwrap().t = 0.0;
|
||||||
|
g.rng = Rng(3); // a gold vein (or another event) happens now
|
||||||
|
g.update_events(0.1);
|
||||||
|
assert!(g.happenings.as_ref().unwrap().t >= EVENT_GAP);
|
||||||
|
assert!(g.msgs.iter().any(|m| m.key.starts_with("msg.event_")), "an event was told");
|
||||||
|
let _ = before;
|
||||||
|
// campaigns have none
|
||||||
|
let mut m = crate::campaign::start_mission(3, None);
|
||||||
|
m.set_events(true);
|
||||||
|
assert!(m.happenings.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_event_can_happen() {
|
||||||
|
let mut g = Game::new(12, 80, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
// a little lava and water next to unowned ground near the dungeon
|
||||||
|
for (x, k) in [(cx + 6, Kind::Lava), (cx + 9, Kind::Water)] {
|
||||||
|
for y in cy - 2..cy + 3 {
|
||||||
|
g.map.t[idx(x, y)].kind = k;
|
||||||
|
g.map.t[idx(x + 1, y)].kind = Kind::Floor;
|
||||||
|
g.map.t[idx(x + 1, y)].owner = Owner::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(g.event_vein());
|
||||||
|
assert!(g.event_overflow(Kind::Lava));
|
||||||
|
assert!(g.event_overflow(Kind::Water));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,14 +97,23 @@ impl Game {
|
|||||||
if self.map.visible.len() != n {
|
if self.map.visible.len() != n {
|
||||||
self.map.visible = vec![false; n];
|
self.map.visible = vec![false; n];
|
||||||
}
|
}
|
||||||
|
// a master whose heart fell watches the rest of the match: the whole map is open to him
|
||||||
|
if self.spectating() {
|
||||||
|
self.map.visible = vec![true; n];
|
||||||
|
self.map.explored.iter_mut().for_each(|e| *e = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let mut vis = vec![false; n];
|
let mut vis = vec![false; n];
|
||||||
let (w, h) = (mw() as i32, mh() as i32);
|
let (w, h) = (mw() as i32, mh() as i32);
|
||||||
|
// the ground and the eyes of the master at this computer and of his allies (co-op partners,
|
||||||
|
// team mates); fog is local, so the local master decides
|
||||||
|
let allies: Vec<Owner> = std::iter::once(self.me).chain(Owner::MASTER_SIDES.iter().filter(|&&s| s != self.me_side && self.allied(self.me_side, s)).map(|&s| Owner::of_side(s))).collect();
|
||||||
let (cx, cy) = self.own_core_corner();
|
let (cx, cy) = self.own_core_corner();
|
||||||
let core_start = (cx - 3, cy - 3, cx + 5, cy + 5);
|
let core_start = (cx - 3, cy - 3, cx + 5, cy + 5);
|
||||||
let in_core_start = |x: i32, y: i32| x >= core_start.0 && x <= core_start.2 && y >= core_start.1 && y <= core_start.3;
|
let in_core_start = |x: i32, y: i32| x >= core_start.0 && x <= core_start.2 && y >= core_start.1 && y <= core_start.3;
|
||||||
// own ground and the ground around it, except straight outside the core's starting room
|
// own ground and the ground around it, except straight outside the core's starting room
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
if self.map.t[i].owner == self.me {
|
if allies.contains(&self.map.t[i].owner) {
|
||||||
let (x, y) = xy(i);
|
let (x, y) = xy(i);
|
||||||
vis[i] = true;
|
vis[i] = true;
|
||||||
let in_start = in_core_start(x, y);
|
let in_start = in_core_start(x, y);
|
||||||
@@ -128,8 +137,9 @@ impl Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// what the creatures and towers see along open ground (walls stop the view)
|
// what the creatures and towers see along open ground (walls stop the view)
|
||||||
let mut eyes: Vec<(usize, i32)> = self.ents.iter().filter(|e| e.alive && !e.held && !e.hero && self.is_master(e.side) && !e.chained).map(|e| (e.tile(), SIGHT)).collect();
|
let mut eyes: Vec<(usize, i32)> = self.ents.iter().filter(|e| e.alive && !e.held && !e.hero && (e.side == self.me_side || self.allied(self.me_side, e.side)) && !e.chained).map(|e| (e.tile(), self.sight_at(e.tile(), SIGHT))).collect();
|
||||||
eyes.extend(self.towers.iter().filter(|t| !t.hero).map(|t| (t.tile, t.range() as i32 + 1)));
|
let solo = self.masters.is_empty();
|
||||||
|
eyes.extend(self.towers.iter().filter(|t| !t.hero && (solo || allies.contains(&self.map.t[t.tile].owner))).map(|t| (t.tile, t.range() as i32 + 1)));
|
||||||
let mut stamp = vec![0u32; n];
|
let mut stamp = vec![0u32; n];
|
||||||
let mut q = VecDeque::new();
|
let mut q = VecDeque::new();
|
||||||
for (sid, &(start, r)) in eyes.iter().enumerate() {
|
for (sid, &(start, r)) in eyes.iter().enumerate() {
|
||||||
@@ -218,7 +228,7 @@ mod tests {
|
|||||||
assert!(g.map.shown(out), "own ground beyond the lifted start is known");
|
assert!(g.map.shown(out), "own ground beyond the lifted start is known");
|
||||||
assert!(g.map.explored.iter().filter(|&&b| b).count() > explored);
|
assert!(g.map.explored.iter().filter(|&&b| b).count() > explored);
|
||||||
// savegame round trip
|
// savegame round trip
|
||||||
let json = crate::save::to_json(&g, &crate::render3d::Cam::new(0.0, 0.0), &crate::settings::Settings::default());
|
let json = crate::save::to_json(&g);
|
||||||
let l = crate::save::from_bytes(json.as_bytes()).unwrap();
|
let l = crate::save::from_bytes(json.as_bytes()).unwrap();
|
||||||
assert_eq!(l.game.map.explored, g.map.explored);
|
assert_eq!(l.game.map.explored, g.map.explored);
|
||||||
// the seer's eye opens a far area for a while
|
// the seer's eye opens a far area for a while
|
||||||
|
|||||||
@@ -80,11 +80,26 @@ pub const T_ARENA: u32 = 178;
|
|||||||
pub const T_ALTAR: u32 = 182;
|
pub const T_ALTAR: u32 = 182;
|
||||||
pub const I_ORDERS: u32 = 186; // attack, guard
|
pub const I_ORDERS: u32 = 186; // attack, guard
|
||||||
pub const T_RIVAL3: u32 = 189; // 4 variants: floor of the yellow master
|
pub const T_RIVAL3: u32 = 189; // 4 variants: floor of the yellow master
|
||||||
|
pub const I_DRAWBRIDGE: u32 = 193; // drawbridge tool: a raised flap on chains
|
||||||
|
pub const I_FREEZE: u32 = 194; // frost spell: a snowflake
|
||||||
|
pub const T_OBSIDIAN: u32 = 195; // 4 variants: cooled lava, dark glass with glowing seams
|
||||||
|
pub const T_STEM: u32 = 199; // giant mushroom stem
|
||||||
|
pub const T_CAP: u32 = 200; // giant mushroom cap, glowing spots
|
||||||
|
pub const T_BARK: u32 = 201; // tree trunk
|
||||||
|
pub const T_LEAVES: u32 = 202; // 2 variants: tree crown
|
||||||
|
pub const T_GRASS: u32 = 204; // 4 variants: grass above ground
|
||||||
|
pub const I_WALL: u32 = 208; // wall tool: bricks rising from the floor
|
||||||
|
pub const I_RAM: u32 = 209; // battering ram tool
|
||||||
|
pub const I_CATAPULT: u32 = 210; // catapult tool
|
||||||
|
pub const T_SNOW: u32 = 211; // 4 variants: snow on the land above ground (winter)
|
||||||
|
pub const T_PUMPKIN: u32 = 215; // a carved pumpkin (Halloween)
|
||||||
|
pub const T_MOSS: u32 = 216; // 4 variants: mushroom grove ground, moss with glowing spores
|
||||||
|
|
||||||
pub struct Gfx {
|
pub struct Gfx {
|
||||||
pub atlas: Texture2D,
|
pub atlas: Texture2D,
|
||||||
pub sprites: Texture2D,
|
pub sprites: Texture2D,
|
||||||
pub cursors: Texture2D, // mouse pointer frames, 16x16 each (see CURSOR_*)
|
pub cursors: Texture2D, // mouse pointer frames, 16x16 each (see CURSOR_*)
|
||||||
|
pub badges: Texture2D, // achievement badges, 32x32 each (see badges.rs)
|
||||||
pub font: Texture2D, // Dinowyde (glyphs of 6x8 px ordered by Unicode, see font_slot) and DINOBYTE
|
pub font: Texture2D, // Dinowyde (glyphs of 6x8 px ordered by Unicode, see font_slot) and DINOBYTE
|
||||||
pub font_map: FontMap, // which glyphs the two fonts hold
|
pub font_map: FontMap, // which glyphs the two fonts hold
|
||||||
}
|
}
|
||||||
@@ -803,6 +818,164 @@ fn gen_atlas() -> Image {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// drawbridge tool icon: a raised flap with its chains above a river
|
||||||
|
cv.cell(I_DRAWBRIDGE, |x, y| {
|
||||||
|
if (3..=5).contains(&x) && (2..=12).contains(&y) {
|
||||||
|
Some(if y % 3 == 0 { c(70, 46, 26) } else { c(150, 106, 60) })
|
||||||
|
} else if y == 12 && (5..=13).contains(&x) {
|
||||||
|
Some(c(110, 76, 44))
|
||||||
|
} else if (x - 5) * 2 == (y - 2) * 1 + 2 && (6..=12).contains(&x) {
|
||||||
|
Some(c(170, 170, 180)) // chain
|
||||||
|
} else if x == 13 && (3..=12).contains(&y) {
|
||||||
|
Some(c(90, 90, 100)) // post
|
||||||
|
} else if y >= 13 && (1..=14).contains(&x) {
|
||||||
|
Some(c(60, 110, 170))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// wall tool icon: a block of bricks with an arrow up
|
||||||
|
cv.cell(I_WALL, |x, y| {
|
||||||
|
if (3..=12).contains(&x) && (6..=14).contains(&y) {
|
||||||
|
let row = (y - 6) / 3;
|
||||||
|
let joint = (y - 6) % 3 == 2 || (x + row * 3) % 6 == 0;
|
||||||
|
Some(if joint { c(60, 40, 30) } else { c(150, 100, 70) })
|
||||||
|
} else if y <= 4 && (x - 8).abs() <= 4 - y.max(1) + 1 && y >= 1 {
|
||||||
|
Some(c(200, 140, 255))
|
||||||
|
} else if y == 5 && (7..=9).contains(&x) {
|
||||||
|
Some(c(200, 140, 255))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// siege icons: a ram (a trunk with an iron cap under a roof), a catapult (arm and stone)
|
||||||
|
cv.cell(I_RAM, |x, y| {
|
||||||
|
if (3..=12).contains(&x) && y == 4 + (x - 3).min(12 - x).min(3) * 0 && false {
|
||||||
|
None
|
||||||
|
} else if y >= 3 && y <= 5 && (x - 7).abs() <= 6 - (y - 3) * 0 && (x + y) % 2 == 0 {
|
||||||
|
Some(c(92, 58, 32))
|
||||||
|
} else if (7..=9).contains(&y) && (1..=13).contains(&x) {
|
||||||
|
Some(if x <= 3 { c(170, 175, 190) } else { c(140, 92, 52) })
|
||||||
|
} else if y >= 11 && y <= 13 && ((x - 4).abs() <= 1 || (x - 11).abs() <= 1) {
|
||||||
|
Some(c(30, 24, 26))
|
||||||
|
} else if y == 10 && (3..=12).contains(&x) {
|
||||||
|
Some(c(92, 58, 32))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cv.cell(I_CATAPULT, |x, y| {
|
||||||
|
let arm = x + y == 14 && (3..=11).contains(&x);
|
||||||
|
if (x - 12).pow(2) + (y - 3).pow(2) <= 3 {
|
||||||
|
Some(c(150, 152, 140))
|
||||||
|
} else if arm {
|
||||||
|
Some(c(140, 92, 52))
|
||||||
|
} else if y == 11 && (2..=13).contains(&x) {
|
||||||
|
Some(c(92, 58, 32))
|
||||||
|
} else if y >= 12 && ((x - 4).abs() <= 1 || (x - 11).abs() <= 1) {
|
||||||
|
Some(c(30, 24, 26))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// frost spell icon: a snowflake
|
||||||
|
cv.cell(I_FREEZE, |x, y| {
|
||||||
|
let (dx, dy) = (x - 8, y - 8);
|
||||||
|
let arm = dx == 0 || dy == 0 || dx == dy || dx == -dy;
|
||||||
|
let r = dx.abs().max(dy.abs());
|
||||||
|
if arm && r <= 6 {
|
||||||
|
Some(if r <= 1 { c(240, 250, 255) } else { c(150, 210, 255) })
|
||||||
|
} else if r == 4 && (dx.abs() == 1 || dy.abs() == 1) && (dx == 0 || dy == 0 || dx.abs() == dy.abs() + 1 || dy.abs() == dx.abs() + 1) {
|
||||||
|
Some(c(200, 235, 255))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// giant mushroom: pale ribbed stem, a violet cap with glowing spots
|
||||||
|
cv.cell(T_STEM, |x, y| {
|
||||||
|
let r = hash(x, y, 410);
|
||||||
|
Some(if x % 4 == 0 { c(150, 140, 130) } else if r % 9 == 0 { c(170, 160, 150) } else { c(200, 192, 178) })
|
||||||
|
});
|
||||||
|
cv.cell(T_CAP, |x, y| {
|
||||||
|
let spot = |cx: i32, cy: i32| (x - cx).pow(2) + (y - cy).pow(2) <= 2;
|
||||||
|
if spot(4, 4) || spot(11, 6) || spot(6, 11) || spot(12, 12) {
|
||||||
|
Some(c(150, 255, 230))
|
||||||
|
} else if (x + y) % 5 == 0 {
|
||||||
|
Some(c(90, 50, 130))
|
||||||
|
} else {
|
||||||
|
Some(c(120, 66, 170))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// tree: bark and two kinds of crown
|
||||||
|
cv.cell(T_BARK, |x, y| {
|
||||||
|
let r = hash(x, y, 420);
|
||||||
|
Some(if x % 5 == 0 || r % 7 == 0 { c(60, 40, 24) } else { c(96, 66, 40) })
|
||||||
|
});
|
||||||
|
for v in 0..2u32 {
|
||||||
|
cv.cell(T_LEAVES + v, |x, y| {
|
||||||
|
let r = hash(x, y, 430 + v);
|
||||||
|
// dark, bluish green: clearly apart from the light meadow
|
||||||
|
Some(if r % 5 == 0 { c(14, 40, 30) } else if r % 7 == 0 { c(52, 96, 62) } else if v == 0 { c(26, 64, 44) } else { c(32, 72, 40) })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// snow above ground in winter
|
||||||
|
for v in 0..4u32 {
|
||||||
|
cv.cell(T_SNOW + v, |x, y| {
|
||||||
|
let r = hash(x, y, 450 + v);
|
||||||
|
Some(if r % 9 == 0 { c(214, 222, 236) } else if r % 23 == 0 { c(150, 170, 120) } else { c(236, 240, 248) })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// a carved pumpkin: orange ribs, a grinning face
|
||||||
|
cv.cell(T_PUMPKIN, |x, y| {
|
||||||
|
let eye = (y == 5 || y == 6) && (x == 4 || x == 5 || x == 10 || x == 11);
|
||||||
|
let mouth = (y == 10 && (3..=12).contains(&x)) || (y == 11 && (5..=10).contains(&x) && x % 2 == 0);
|
||||||
|
if eye || mouth {
|
||||||
|
Some(c(255, 230, 90))
|
||||||
|
} else if x % 4 == 0 {
|
||||||
|
Some(c(190, 90, 20))
|
||||||
|
} else {
|
||||||
|
Some(c(240, 128, 30))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// grass above ground
|
||||||
|
for v in 0..4u32 {
|
||||||
|
cv.cell(T_GRASS + v, |x, y| {
|
||||||
|
let r = hash(x, y, 440 + v);
|
||||||
|
// a light, yellowish meadow with flowers
|
||||||
|
Some(if r % 11 == 0 { c(176, 200, 96) } else if r % 6 == 0 { c(112, 150, 62) } else if r % 23 == 0 { c(236, 220, 110) } else if r % 37 == 0 { c(230, 236, 240) } else { c(140, 176, 78) })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// mushroom grove: dark stones overgrown with teal moss, a few glowing spores
|
||||||
|
for v in 0..4u32 {
|
||||||
|
cv.cell(T_MOSS + v, |x, y| {
|
||||||
|
let r = hash(x, y, 520 + v);
|
||||||
|
let moss = (hash(x / 3, y / 3, 530 + v) % 5) < 3;
|
||||||
|
Some(if r % 41 == 0 {
|
||||||
|
c(150, 255, 220) // a spore glows
|
||||||
|
} else if r % 29 == 0 {
|
||||||
|
c(200, 150, 255)
|
||||||
|
} else if moss {
|
||||||
|
if r % 4 == 0 { c(46, 112, 96) } else if r % 3 == 0 { c(30, 84, 74) } else { c(38, 98, 84) }
|
||||||
|
} else {
|
||||||
|
stones(x, y, v, (52, 58, 56), (26, 30, 30), 8)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// obsidian: cooled lava, dark glass with a few glowing seams
|
||||||
|
for v in 0..4u32 {
|
||||||
|
cv.cell(T_OBSIDIAN + v, |x, y| {
|
||||||
|
let r = hash(x, y, v + 400);
|
||||||
|
if (x * 5 + y * 3 + v as i32 * 7) % 29 == 0 || ((x + 2 * y + v as i32) % 23 == 0 && r % 2 == 0) {
|
||||||
|
Some(c(230, 90, 30))
|
||||||
|
} else if r % 11 == 0 {
|
||||||
|
Some(c(70, 60, 90))
|
||||||
|
} else if (x + y) % 7 == 0 {
|
||||||
|
Some(c(34, 26, 40))
|
||||||
|
} else {
|
||||||
|
Some(c(22, 16, 26))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
// ---- more treasures: veins (earth with embedded pieces) and treasury piles
|
// ---- more treasures: veins (earth with embedded pieces) and treasury piles
|
||||||
let vein = |x: i32, y: i32, v: u32, seed: u32, hi: Color, mid: Color, lo: Color, size: f32| -> Color {
|
let vein = |x: i32, y: i32, v: u32, seed: u32, hi: Color, mid: Color, lo: Color, size: f32| -> Color {
|
||||||
let mut best = 99.0f32;
|
let mut best = 99.0f32;
|
||||||
@@ -1282,6 +1455,58 @@ fn sprite_rows(k: EK) -> [&'static str; 10] {
|
|||||||
"..kBkkBk..",
|
"..kBkkBk..",
|
||||||
"..kk..kk..",
|
"..kk..kk..",
|
||||||
],
|
],
|
||||||
|
// thief: a dark hood and mask, a dagger, a sack of gold on the back
|
||||||
|
EK::Thief => [
|
||||||
|
"...kkkk...",
|
||||||
|
"..kddddk..",
|
||||||
|
"..kdfkfk.Y",
|
||||||
|
"..kddddkYy",
|
||||||
|
".kdddddkYy",
|
||||||
|
"skdddddk..",
|
||||||
|
".kdkdddk..",
|
||||||
|
"..kddddk..",
|
||||||
|
"..kdkkdk..",
|
||||||
|
"..kk..kk..",
|
||||||
|
],
|
||||||
|
// monk: shaven head, an orange robe tied with a rope, bare fists
|
||||||
|
EK::Monk => [
|
||||||
|
"...kkkk...",
|
||||||
|
"..kffffk..",
|
||||||
|
"..kfkfkk..",
|
||||||
|
"..kffffk..",
|
||||||
|
".kooooook.",
|
||||||
|
"kfoooooofk",
|
||||||
|
".kooyyook.",
|
||||||
|
".kOooooOk.",
|
||||||
|
".kOooooOk.",
|
||||||
|
"..kkkkkk..",
|
||||||
|
],
|
||||||
|
// ranger: a green hood and cloak, a long bow, leather boots
|
||||||
|
EK::Ranger => [
|
||||||
|
"...kkkk.b.",
|
||||||
|
"..kgGGgkkb",
|
||||||
|
"..kGfkfk.b",
|
||||||
|
"..kGfffk.b",
|
||||||
|
".kGgggggkb",
|
||||||
|
".kGgbbggkb",
|
||||||
|
".kGggggk.b",
|
||||||
|
"..kBBBBk.b",
|
||||||
|
"..kBkkBkb.",
|
||||||
|
"..kk..kk..",
|
||||||
|
],
|
||||||
|
// inquisitor: a tall red hat, white and red robes, a glowing holy book
|
||||||
|
EK::Inquisitor => [
|
||||||
|
"...kRRk...",
|
||||||
|
"..kRrrRk..",
|
||||||
|
"..kRrrRk..",
|
||||||
|
"..kffffk..",
|
||||||
|
"..kfkfkk..",
|
||||||
|
".kwwrrwwk.",
|
||||||
|
"kwkwrrwkek",
|
||||||
|
".kwwrrwwkk",
|
||||||
|
".kRwwwwRk.",
|
||||||
|
"..kkkkkk..",
|
||||||
|
],
|
||||||
EK::Priest => [
|
EK::Priest => [
|
||||||
"....yy....",
|
"....yy....",
|
||||||
"...kkkk...",
|
"...kkkk...",
|
||||||
@@ -1419,6 +1644,71 @@ fn sprite_rows(k: EK) -> [&'static str; 10] {
|
|||||||
"...y..y...",
|
"...y..y...",
|
||||||
"..........",
|
"..........",
|
||||||
],
|
],
|
||||||
|
// trader: a hooded merchant with a big pack and a lantern
|
||||||
|
EK::Trader => [
|
||||||
|
"...kkk....",
|
||||||
|
"..kPPPk.BB",
|
||||||
|
".kPfefPkBb",
|
||||||
|
".kPffffkBb",
|
||||||
|
"..kPPPPkBB",
|
||||||
|
".kPPyPPPk.",
|
||||||
|
"ykPPPPPPk.",
|
||||||
|
"o.kPPPPk..",
|
||||||
|
"..kPk.kPk.",
|
||||||
|
"..kk...kk.",
|
||||||
|
],
|
||||||
|
// cave bear: shaggy brown fur, small yellow eyes
|
||||||
|
EK::CaveBear => [
|
||||||
|
".kk....kk.",
|
||||||
|
"kBBk..kBBk",
|
||||||
|
"kBBBBBBBBk",
|
||||||
|
"kBeBBBBeBk",
|
||||||
|
"kBBBkkBBBk",
|
||||||
|
".kBBffBBk.",
|
||||||
|
"kBBBBBBBBk",
|
||||||
|
"kBbBBBBbBk",
|
||||||
|
".kBBkkBBk.",
|
||||||
|
".kkk..kkk.",
|
||||||
|
],
|
||||||
|
// giant beetle: a shiny dark-green shell, mandibles, six legs
|
||||||
|
EK::Beetle => [
|
||||||
|
"..k....k..",
|
||||||
|
"...k..k...",
|
||||||
|
"..kGGGGk..",
|
||||||
|
".kGeGGeGk.",
|
||||||
|
"kGGgGGgGGk",
|
||||||
|
"kGgGGGGgGk",
|
||||||
|
"kGGgGGgGGk",
|
||||||
|
".kGGGGGGk.",
|
||||||
|
"k.kGGGGk.k",
|
||||||
|
".k.k..k.k.",
|
||||||
|
],
|
||||||
|
// battering ram: a roofed frame on wheels, an iron-capped trunk
|
||||||
|
EK::Ram => [
|
||||||
|
"..........",
|
||||||
|
"...BBBB...",
|
||||||
|
"..BbbbbB..",
|
||||||
|
".BbbbbbbB.",
|
||||||
|
"SsbbbbbbB.",
|
||||||
|
"SsBBBBBBB.",
|
||||||
|
"..b.b.b.b.",
|
||||||
|
".kBk..kBk.",
|
||||||
|
".kck..kck.",
|
||||||
|
"..k....k..",
|
||||||
|
],
|
||||||
|
// catapult: a throwing arm with a stone in its cup
|
||||||
|
EK::Catapult => [
|
||||||
|
"......cc..",
|
||||||
|
".....kCCk.",
|
||||||
|
".....kbk..",
|
||||||
|
"....kb....",
|
||||||
|
"...kb.....",
|
||||||
|
"..kb......",
|
||||||
|
".BBBBBBBB.",
|
||||||
|
".bkbbbbkb.",
|
||||||
|
".kck..kck.",
|
||||||
|
"..k....k..",
|
||||||
|
],
|
||||||
// battle mage: robes of the order, a burning staff
|
// battle mage: robes of the order, a burning staff
|
||||||
EK::BattleMage => [
|
EK::BattleMage => [
|
||||||
"....kk...o",
|
"....kk...o",
|
||||||
@@ -1501,7 +1791,7 @@ fn pal(ch: char) -> Option<Color> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const SPRITE_KINDS: [EK; 22] = [
|
pub const SPRITE_KINDS: [EK; 31] = [
|
||||||
EK::Worker,
|
EK::Worker,
|
||||||
EK::Kobold,
|
EK::Kobold,
|
||||||
EK::Spider,
|
EK::Spider,
|
||||||
@@ -1524,6 +1814,15 @@ pub const SPRITE_KINDS: [EK; 22] = [
|
|||||||
EK::Giant,
|
EK::Giant,
|
||||||
EK::Fairy,
|
EK::Fairy,
|
||||||
EK::BattleMage,
|
EK::BattleMage,
|
||||||
|
EK::Ram,
|
||||||
|
EK::Catapult,
|
||||||
|
EK::CaveBear,
|
||||||
|
EK::Beetle,
|
||||||
|
EK::Trader,
|
||||||
|
EK::Thief,
|
||||||
|
EK::Monk,
|
||||||
|
EK::Ranger,
|
||||||
|
EK::Inquisitor,
|
||||||
];
|
];
|
||||||
// width of the sprite texture in pixels
|
// width of the sprite texture in pixels
|
||||||
pub const SPRITE_TEX_W: f32 = (SPRITE_KINDS.len() * 12) as f32;
|
pub const SPRITE_TEX_W: f32 = (SPRITE_KINDS.len() * 12) as f32;
|
||||||
@@ -1541,7 +1840,7 @@ pub fn gear_tier(level: u32) -> u32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_hero_kind(k: EK) -> bool {
|
fn is_hero_kind(k: EK) -> bool {
|
||||||
matches!(k, EK::Knight | EK::Archer | EK::Priest | EK::Dwarf | EK::Paladin | EK::Giant | EK::Fairy | EK::BattleMage)
|
matches!(k, EK::Knight | EK::Archer | EK::Priest | EK::Dwarf | EK::Paladin | EK::Giant | EK::Fairy | EK::BattleMage | EK::Thief | EK::Monk | EK::Ranger | EK::Inquisitor)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Views of a sprite for eight directions (the others are mirrored): 0 front, 1 front
|
// Views of a sprite for eight directions (the others are mirrored): 0 front, 1 front
|
||||||
@@ -1773,39 +2072,41 @@ const CUR_POINT: [&str; 16] = [
|
|||||||
"...kkkkkkkk.....",
|
"...kkkkkkkk.....",
|
||||||
"................",
|
"................",
|
||||||
];
|
];
|
||||||
|
// the hand tool: an open goblin hand (four fingers, the thumb at the side), lit fingertips
|
||||||
const CUR_OPEN: [&str; 16] = [
|
const CUR_OPEN: [&str; 16] = [
|
||||||
|
"...kk.kk.kk.....",
|
||||||
|
"..klkklkklk.kk..",
|
||||||
|
"..kgkkgkkgk.klk.",
|
||||||
|
"..kgkkgkkgkkgk..",
|
||||||
|
"..kgkkgkkgkkgk..",
|
||||||
|
"..kggggggggggk..",
|
||||||
|
"kk.kgggggggggk..",
|
||||||
|
"klkkggggggggGk..",
|
||||||
|
"kggkggggggggGk..",
|
||||||
|
".kgggggggggGGk..",
|
||||||
|
"..kggggggggGk...",
|
||||||
|
"...kgggggggGk...",
|
||||||
|
"....kggggGGk....",
|
||||||
|
".....kkkkkk.....",
|
||||||
"................",
|
"................",
|
||||||
".kw.kw.kw.......",
|
|
||||||
".kgkkgkkgk..kw..",
|
|
||||||
".kgkkgkkgk.kgk..",
|
|
||||||
".kgkkgkkgkkggk..",
|
|
||||||
".kgggggggggggk..",
|
|
||||||
"kkgggggggggggk..",
|
|
||||||
"kgkggggggggggk..",
|
|
||||||
"kggkgggggggGk...",
|
|
||||||
".kggggggggggk...",
|
|
||||||
"..kgggggggGk....",
|
|
||||||
"...kggggggGk....",
|
|
||||||
"...kgggggGk.....",
|
|
||||||
"....kGGGGk......",
|
|
||||||
"....kkkkkk......",
|
|
||||||
"................",
|
"................",
|
||||||
];
|
];
|
||||||
|
// holding units: a fist, knuckles on top, the thumb across
|
||||||
const CUR_FIST: [&str; 16] = [
|
const CUR_FIST: [&str; 16] = [
|
||||||
"................",
|
"................",
|
||||||
"................",
|
"................",
|
||||||
"................",
|
"....kkkkkkk.....",
|
||||||
"...kkkkkkkk.....",
|
"...klklklklk....",
|
||||||
"..kgkgkgkgGk....",
|
"..kgkgkgkgGk....",
|
||||||
"..kgkgkgkgGk....",
|
"..kgkgkgkgGGk...",
|
||||||
".kkggggggggGk...",
|
".kkkkkkkggGGk...",
|
||||||
"kggkgggggggGk...",
|
"kllllllkgGGk....",
|
||||||
"kgggggggggGGk...",
|
"kggggggkgGGk....",
|
||||||
".kggggggggGk....",
|
".kkkkkkggGGk....",
|
||||||
"..kgggggggGk....",
|
"..kgggggggGk....",
|
||||||
"...kggggggGk....",
|
"...kggggggGk....",
|
||||||
"...kGGGGGGk.....",
|
"....kGGGGGk.....",
|
||||||
"....kkkkkk......",
|
".....kkkkk......",
|
||||||
"................",
|
"................",
|
||||||
"................",
|
"................",
|
||||||
];
|
];
|
||||||
@@ -1815,6 +2116,7 @@ fn cursor_pal(ch: char) -> Option<Color> {
|
|||||||
'k' => c(20, 16, 18),
|
'k' => c(20, 16, 18),
|
||||||
'g' => c(112, 152, 84),
|
'g' => c(112, 152, 84),
|
||||||
'G' => c(70, 100, 56),
|
'G' => c(70, 100, 56),
|
||||||
|
'l' => c(168, 206, 124),
|
||||||
'w' => c(236, 230, 208),
|
'w' => c(236, 230, 208),
|
||||||
'y' => c(255, 220, 80),
|
'y' => c(255, 220, 80),
|
||||||
's' => c(255, 255, 255),
|
's' => c(255, 255, 255),
|
||||||
@@ -1861,7 +2163,9 @@ impl Gfx {
|
|||||||
font.set_filter(FilterMode::Nearest);
|
font.set_filter(FilterMode::Nearest);
|
||||||
let cursors = Texture2D::from_image(&gen_cursors());
|
let cursors = Texture2D::from_image(&gen_cursors());
|
||||||
cursors.set_filter(FilterMode::Nearest);
|
cursors.set_filter(FilterMode::Nearest);
|
||||||
Gfx { atlas, sprites, cursors, font, font_map }
|
let badges = Texture2D::from_image(&crate::badges::gen_badges());
|
||||||
|
badges.set_filter(FilterMode::Nearest);
|
||||||
|
Gfx { atlas, sprites, cursors, badges, font, font_map }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cell(id: u32) -> Rect {
|
pub fn cell(id: u32) -> Rect {
|
||||||
@@ -1927,7 +2231,11 @@ impl Gfx {
|
|||||||
Kind::Tower => T_CLAIM + v,
|
Kind::Tower => T_CLAIM + v,
|
||||||
Kind::Water => T_WATER + v,
|
Kind::Water => T_WATER + v,
|
||||||
Kind::Lava => T_LAVA + v,
|
Kind::Lava => T_LAVA + v,
|
||||||
Kind::Bridge => T_BRIDGE + (v & 1),
|
Kind::Bridge | Kind::Drawbridge => T_BRIDGE + (v & 1),
|
||||||
|
Kind::Obsidian => T_OBSIDIAN + v,
|
||||||
|
Kind::Mushroom => T_FLOOR + v,
|
||||||
|
Kind::Tree => T_GRASS + v,
|
||||||
|
Kind::Grove => T_MOSS + v,
|
||||||
Kind::LavaBridge => T_STONEBRIDGE + (v & 1),
|
Kind::LavaBridge => T_STONEBRIDGE + (v & 1),
|
||||||
Kind::Shard => T_HEROFLOOR + v,
|
Kind::Shard => T_HEROFLOOR + v,
|
||||||
Kind::Aerie => T_AERIE + v,
|
Kind::Aerie => T_AERIE + v,
|
||||||
@@ -2137,8 +2445,8 @@ mod doc_images {
|
|||||||
for (k, t) in crate::traps::TRAP_KINDS.iter().enumerate() {
|
for (k, t) in crate::traps::TRAP_KINDS.iter().enumerate() {
|
||||||
save(&atlas, Gfx::cell(I_TRAPS + k as u32), 4, &format!("docs/img/icons/{}.png", t.id()));
|
save(&atlas, Gfx::cell(I_TRAPS + k as u32), 4, &format!("docs/img/icons/{}.png", t.id()));
|
||||||
}
|
}
|
||||||
for (k, s) in crate::spells::SPELLS.iter().enumerate() {
|
for s in crate::spells::SPELLS.iter() {
|
||||||
save(&atlas, Gfx::cell(I_SPELLS + k as u32), 4, &format!("docs/img/icons/{}.png", s.id()));
|
save(&atlas, Gfx::cell(crate::spells::spell_icon(*s)), 4, &format!("docs/img/icons/{}.png", s.id()));
|
||||||
}
|
}
|
||||||
for (id, cell) in [("lightning", I_BOLT), ("heal", I_HEAL), ("summon", I_SUMMON), ("arrow", I_ARROW), ("cannon", I_CANNON), ("magic", I_MAGIC), ("rune", I_RUNE)] {
|
for (id, cell) in [("lightning", I_BOLT), ("heal", I_HEAL), ("summon", I_SUMMON), ("arrow", I_ARROW), ("cannon", I_CANNON), ("magic", I_MAGIC), ("rune", I_RUNE)] {
|
||||||
save(&atlas, Gfx::cell(cell), 4, &format!("docs/img/icons/{}.png", id));
|
save(&atlas, Gfx::cell(cell), 4, &format!("docs/img/icons/{}.png", id));
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ const SOURCES: &[(&str, &str)] = &[
|
|||||||
("es", include_str!("../lang/es.lang")),
|
("es", include_str!("../lang/es.lang")),
|
||||||
("ru", include_str!("../lang/ru.lang")),
|
("ru", include_str!("../lang/ru.lang")),
|
||||||
("it", include_str!("../lang/it.lang")),
|
("it", include_str!("../lang/it.lang")),
|
||||||
|
("pt", include_str!("../lang/pt.lang")),
|
||||||
|
("nl", include_str!("../lang/nl.lang")),
|
||||||
|
("da", include_str!("../lang/da.lang")),
|
||||||
|
("no", include_str!("../lang/no.lang")),
|
||||||
|
("sv", include_str!("../lang/sv.lang")),
|
||||||
|
("fi", include_str!("../lang/fi.lang")),
|
||||||
|
("hr", include_str!("../lang/hr.lang")),
|
||||||
|
("hu", include_str!("../lang/hu.lang")),
|
||||||
|
("sr", include_str!("../lang/sr.lang")),
|
||||||
|
("ro", include_str!("../lang/ro.lang")),
|
||||||
|
("sk", include_str!("../lang/sk.lang")),
|
||||||
|
("cs", include_str!("../lang/cs.lang")),
|
||||||
|
("el", include_str!("../lang/el.lang")),
|
||||||
];
|
];
|
||||||
|
|
||||||
// order of the languages in the language dropdown
|
// order of the languages in the language dropdown
|
||||||
@@ -84,7 +97,13 @@ pub fn tr(key: &str) -> String {
|
|||||||
pub fn trf(key: &str, args: &[String]) -> String {
|
pub fn trf(key: &str, args: &[String]) -> String {
|
||||||
let mut s = tr(key);
|
let mut s = tr(key);
|
||||||
for (n, a) in args.iter().enumerate() {
|
for (n, a) in args.iter().enumerate() {
|
||||||
let v = if let Some(k) = a.strip_prefix('@') { tr(k) } else { a.clone() };
|
let v = if let Some(k) = a.strip_prefix('@') {
|
||||||
|
tr(k)
|
||||||
|
} else if let Some(n) = a.strip_prefix('%') {
|
||||||
|
crate::names::from_arg(n) // a creature's name (names.rs)
|
||||||
|
} else {
|
||||||
|
a.clone()
|
||||||
|
};
|
||||||
s = s.replace(&format!("{{{}}}", n), &v);
|
s = s.replace(&format!("{{{}}}", n), &v);
|
||||||
}
|
}
|
||||||
s
|
s
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub struct SeatUi {
|
|||||||
pub ready: bool,
|
pub ready: bool,
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
pub ai: bool,
|
pub ai: bool,
|
||||||
|
pub team: u8, // 0..3 = A..D (versus)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
@@ -39,6 +40,9 @@ pub struct RoomUi {
|
|||||||
pub sides: Vec<u8>,
|
pub sides: Vec<u8>,
|
||||||
pub started: bool,
|
pub started: bool,
|
||||||
pub paused: bool,
|
pub paused: bool,
|
||||||
|
pub mission: Option<usize>,
|
||||||
|
pub campaign: Option<String>, // the editor campaign of a co-op room
|
||||||
|
pub title: Option<String>, // and the title of its current mission
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
@@ -53,6 +57,7 @@ pub struct MatchUi {
|
|||||||
pub sides: Vec<u8>,
|
pub sides: Vec<u8>,
|
||||||
pub seats: Vec<SeatUi>,
|
pub seats: Vec<SeatUi>,
|
||||||
pub ai: Vec<u8>,
|
pub ai: Vec<u8>,
|
||||||
|
pub coop: bool, // the open room plays a co-op mission (no teams to choose)
|
||||||
pub rooms: Vec<RoomUi>,
|
pub rooms: Vec<RoomUi>,
|
||||||
pub started: bool,
|
pub started: bool,
|
||||||
pub paused: bool,
|
pub paused: bool,
|
||||||
@@ -82,6 +87,11 @@ pub struct StartInfo {
|
|||||||
// an editor campaign sent along by the host: `mission` is then one of its missions
|
// an editor campaign sent along by the host: `mission` is then one of its missions
|
||||||
pub doc: Option<crate::campaign_doc::CampaignDoc>,
|
pub doc: Option<crate::campaign_doc::CampaignDoc>,
|
||||||
pub editor: bool, // the start carried an editor campaign (even one that could not be read)
|
pub editor: bool, // the start carried an editor campaign (even one that could not be read)
|
||||||
|
pub teams: Vec<(u8, u8)>, // (side, team) of a versus match
|
||||||
|
pub next: bool, // the next mission of a running editor campaign co-op match
|
||||||
|
pub carry: Vec<(u8, crate::campaign::Carry)>, // what every side brings into that mission
|
||||||
|
pub surface: bool, // a free match whose eastern half lies above ground
|
||||||
|
pub events: bool, // random events in a free match
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
@@ -91,6 +101,7 @@ struct SeatInfo {
|
|||||||
ready: bool,
|
ready: bool,
|
||||||
connected: bool,
|
connected: bool,
|
||||||
ai: bool,
|
ai: bool,
|
||||||
|
team: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
@@ -103,6 +114,7 @@ struct RoomInfo {
|
|||||||
started: bool,
|
started: bool,
|
||||||
paused: bool,
|
paused: bool,
|
||||||
missing: bool,
|
missing: bool,
|
||||||
|
mission: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -119,6 +131,7 @@ pub struct MatchClient {
|
|||||||
chat: Vec<(Option<u8>, String, String)>,
|
chat: Vec<(Option<u8>, String, String)>,
|
||||||
chat_seen: usize,
|
chat_seen: usize,
|
||||||
start: Option<StartInfo>,
|
start: Option<StartInfo>,
|
||||||
|
doc: Option<crate::campaign_doc::CampaignDoc>, // the editor campaign of the running match
|
||||||
turns: Vec<(u32, Vec<Command>, Vec<u8>)>,
|
turns: Vec<(u32, Vec<Command>, Vec<u8>)>,
|
||||||
checks: Vec<(Option<u8>, u32, u64)>,
|
checks: Vec<(Option<u8>, u32, u64)>,
|
||||||
pub ping_ms: f32,
|
pub ping_ms: f32,
|
||||||
@@ -142,6 +155,7 @@ impl MatchClient {
|
|||||||
chat: Vec::new(),
|
chat: Vec::new(),
|
||||||
chat_seen: 0,
|
chat_seen: 0,
|
||||||
start: None,
|
start: None,
|
||||||
|
doc: None,
|
||||||
turns: Vec::new(),
|
turns: Vec::new(),
|
||||||
checks: Vec::new(),
|
checks: Vec::new(),
|
||||||
ping_ms: 0.0,
|
ping_ms: 0.0,
|
||||||
@@ -179,6 +193,7 @@ impl MatchClient {
|
|||||||
self.code = None;
|
self.code = None;
|
||||||
self.local_side = None;
|
self.local_side = None;
|
||||||
self.start = None;
|
self.start = None;
|
||||||
|
self.doc = None;
|
||||||
self.turns.clear();
|
self.turns.clear();
|
||||||
self.status = Some("offline".into());
|
self.status = Some("offline".into());
|
||||||
self.ping_ms = 0.0;
|
self.ping_ms = 0.0;
|
||||||
@@ -210,6 +225,19 @@ impl MatchClient {
|
|||||||
self.send_create(sides, map_w, map_h, difficulty, None);
|
self.send_create(sides, map_w, map_h, difficulty, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a free match; `surface`: its eastern half lies above ground
|
||||||
|
pub fn create_land(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, surface: bool, events: bool) {
|
||||||
|
let n = self.outbox.len();
|
||||||
|
self.send_create(sides, map_w, map_h, difficulty, None);
|
||||||
|
if let (true, Some(text)) = (surface || events, self.outbox.get_mut(n)) {
|
||||||
|
if let Ok(mut msg) = serde_json::from_str::<Value>(text) {
|
||||||
|
msg["surface"] = json!(surface);
|
||||||
|
msg["events"] = json!(events);
|
||||||
|
*text = msg.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_coop(&mut self, mission: usize, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8) {
|
pub fn create_coop(&mut self, mission: usize, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8) {
|
||||||
self.send_create(sides, map_w, map_h, difficulty, Some(mission));
|
self.send_create(sides, map_w, map_h, difficulty, Some(mission));
|
||||||
}
|
}
|
||||||
@@ -217,10 +245,23 @@ impl MatchClient {
|
|||||||
// a co-op match of an editor campaign: the whole campaign file goes to the relay, which hands
|
// a co-op match of an editor campaign: the whole campaign file goes to the relay, which hands
|
||||||
// it to every client with the start message
|
// it to every client with the start message
|
||||||
pub fn create_coop_doc(&mut self, doc: &crate::campaign_doc::CampaignDoc, mission: usize, sides: &[u8], difficulty: u8) {
|
pub fn create_coop_doc(&mut self, doc: &crate::campaign_doc::CampaignDoc, mission: usize, sides: &[u8], difficulty: u8) {
|
||||||
|
self.create_coop_doc_resumed(doc, mission, sides, difficulty, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the same, going on with an interrupted run: its seed and what every master carries along
|
||||||
|
pub fn create_coop_doc_resumed(&mut self, doc: &crate::campaign_doc::CampaignDoc, mission: usize, sides: &[u8], difficulty: u8, resume: Option<(u32, &[(u8, crate::campaign::Carry)])>) {
|
||||||
let Some(m) = doc.missions.get(mission) else { return };
|
let Some(m) = doc.missions.get(mission) else { return };
|
||||||
let (map_w, map_h) = m.size;
|
let (map_w, map_h) = m.size;
|
||||||
let doc = serde_json::to_value(doc).unwrap_or(Value::Null);
|
let doc = serde_json::to_value(doc).unwrap_or(Value::Null);
|
||||||
|
let n = self.outbox.len();
|
||||||
self.send_create_with(sides, map_w, map_h, difficulty, Some(mission), Some(doc));
|
self.send_create_with(sides, map_w, map_h, difficulty, Some(mission), Some(doc));
|
||||||
|
if let (Some((seed, carry)), Some(text)) = (resume, self.outbox.get_mut(n)) {
|
||||||
|
if let Ok(mut msg) = serde_json::from_str::<Value>(text) {
|
||||||
|
msg["seed"] = json!(seed);
|
||||||
|
msg["carry"] = serde_json::to_value(carry).unwrap_or(Value::Array(Vec::new()));
|
||||||
|
*text = msg.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option<usize>) {
|
fn send_create(&mut self, sides: &[u8], map_w: usize, map_h: usize, difficulty: u8, mission: Option<usize>) {
|
||||||
@@ -272,6 +313,28 @@ impl MatchClient {
|
|||||||
self.outbox.push_back(json!({"t":"set_ai","side":side,"ai":ai}).to_string());
|
self.outbox.push_back(json!({"t":"set_ai","side":side,"ai":ai}).to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the team (0..3 = A..D) of the own seat, or - for the host - of a free or AI seat
|
||||||
|
pub fn set_team(&mut self, side: Option<u8>, team: u8) {
|
||||||
|
if !self.connected {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut msg = json!({"t":"set_team","team":team});
|
||||||
|
if let Some(side) = side {
|
||||||
|
msg["side"] = json!(side);
|
||||||
|
}
|
||||||
|
self.outbox.push_back(msg.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// the host starts the next mission of the editor campaign for everybody: the run's seed and
|
||||||
|
// what every side carries along travel with it
|
||||||
|
pub fn next_mission(&mut self, mission: usize, seed: u32, carry: &[(u8, crate::campaign::Carry)]) {
|
||||||
|
if !self.connected {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let carry = serde_json::to_value(carry).unwrap_or(Value::Array(Vec::new()));
|
||||||
|
self.outbox.push_back(json!({"t":"next","mission":mission,"seed":seed,"carry":carry}).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ready(&mut self, ready: bool) {
|
pub fn ready(&mut self, ready: bool) {
|
||||||
if !self.connected {
|
if !self.connected {
|
||||||
return;
|
return;
|
||||||
@@ -358,6 +421,7 @@ impl MatchClient {
|
|||||||
"rooms" => self.parse_rooms(&v),
|
"rooms" => self.parse_rooms(&v),
|
||||||
"room" => self.parse_room(&v),
|
"room" => self.parse_room(&v),
|
||||||
"start" => self.parse_start(&v),
|
"start" => self.parse_start(&v),
|
||||||
|
"next" => self.parse_next(&v),
|
||||||
"turn" => self.parse_turn(&v),
|
"turn" => self.parse_turn(&v),
|
||||||
"sum" => self.parse_sum(&v),
|
"sum" => self.parse_sum(&v),
|
||||||
"chat" => {
|
"chat" => {
|
||||||
@@ -378,6 +442,7 @@ impl MatchClient {
|
|||||||
self.code = None;
|
self.code = None;
|
||||||
self.local_side = None;
|
self.local_side = None;
|
||||||
self.start = None;
|
self.start = None;
|
||||||
|
self.doc = None;
|
||||||
self.turns.clear();
|
self.turns.clear();
|
||||||
self.chat.clear();
|
self.chat.clear();
|
||||||
self.chat_seen = 0;
|
self.chat_seen = 0;
|
||||||
@@ -420,6 +485,9 @@ impl MatchClient {
|
|||||||
sides: array_bytes(r.get("sides")),
|
sides: array_bytes(r.get("sides")),
|
||||||
started: r.get("started").and_then(|x| x.as_bool()).unwrap_or(false),
|
started: r.get("started").and_then(|x| x.as_bool()).unwrap_or(false),
|
||||||
paused: r.get("paused").and_then(|x| x.as_bool()).unwrap_or(false),
|
paused: r.get("paused").and_then(|x| x.as_bool()).unwrap_or(false),
|
||||||
|
mission: r.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize),
|
||||||
|
campaign: r.get("campaign").and_then(|x| x.as_str()).map(|x| x.to_string()),
|
||||||
|
title: r.get("title").and_then(|x| x.as_str()).map(|x| x.to_string()),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
@@ -451,6 +519,9 @@ impl MatchClient {
|
|||||||
if let Some(x) = v.get("missing").and_then(|x| x.as_bool()) {
|
if let Some(x) = v.get("missing").and_then(|x| x.as_bool()) {
|
||||||
room.missing = x;
|
room.missing = x;
|
||||||
}
|
}
|
||||||
|
if v.get("mission").is_some() {
|
||||||
|
room.mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
|
||||||
|
}
|
||||||
if let Some(why) = v.get("why").and_then(|x| x.as_str()) {
|
if let Some(why) = v.get("why").and_then(|x| x.as_str()) {
|
||||||
self.status = Some(why.to_string());
|
self.status = Some(why.to_string());
|
||||||
}
|
}
|
||||||
@@ -471,6 +542,8 @@ impl MatchClient {
|
|||||||
let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
|
let mission = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize);
|
||||||
let editor = v.get("doc").map_or(false, |d| d.is_object());
|
let editor = v.get("doc").map_or(false, |d| d.is_object());
|
||||||
let doc = v.get("doc").filter(|d| d.is_object()).and_then(|d| <crate::campaign_doc::CampaignDoc as serde::Deserialize>::deserialize(d).ok());
|
let doc = v.get("doc").filter(|d| d.is_object()).and_then(|d| <crate::campaign_doc::CampaignDoc as serde::Deserialize>::deserialize(d).ok());
|
||||||
|
self.doc = doc.clone();
|
||||||
|
let teams = teams_from(&sides, v.get("teams"));
|
||||||
let mut ai = array_bytes(v.get("ai"));
|
let mut ai = array_bytes(v.get("ai"));
|
||||||
if ai.is_empty() {
|
if ai.is_empty() {
|
||||||
if let Some(r) = self.room.as_ref() {
|
if let Some(r) = self.room.as_ref() {
|
||||||
@@ -489,11 +562,46 @@ impl MatchClient {
|
|||||||
}
|
}
|
||||||
r.ai = ai.clone();
|
r.ai = ai.clone();
|
||||||
}
|
}
|
||||||
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor });
|
// a resumed editor campaign run goes on like the next mission of a running one
|
||||||
|
let resume = editor && v.get("resume").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||||
|
let carry: Vec<(u8, crate::campaign::Carry)> = if resume { v.get("carry").cloned().and_then(|c| serde_json::from_value(c).ok()).unwrap_or_default() } else { Vec::new() };
|
||||||
|
let surface = v.get("surface").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||||
|
let events = v.get("events").and_then(|x| x.as_bool()).unwrap_or(false);
|
||||||
|
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission, ai, doc, editor, teams, next: resume, carry, surface, events });
|
||||||
self.error = None;
|
self.error = None;
|
||||||
self.status = None;
|
self.status = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the next mission of the running editor campaign: built from the campaign of the start
|
||||||
|
fn parse_next(&mut self, v: &Value) {
|
||||||
|
let Some(doc) = self.doc.clone() else { return };
|
||||||
|
let Some(mission) = v.get("mission").and_then(|x| x.as_u64()).map(|x| x as usize) else { return };
|
||||||
|
let Some(m) = doc.missions.get(mission) else { return };
|
||||||
|
let (map_w, map_h) = m.size;
|
||||||
|
let seed = v.get("seed").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
|
||||||
|
let difficulty = v.get("difficulty").and_then(|x| x.as_u64()).unwrap_or(1) as u8;
|
||||||
|
let sides = array_bytes(v.get("sides"));
|
||||||
|
let ai = array_bytes(v.get("ai"));
|
||||||
|
let teams = teams_from(&sides, v.get("teams"));
|
||||||
|
let carry: Vec<(u8, crate::campaign::Carry)> = v.get("carry").cloned().and_then(|c| serde_json::from_value(c).ok()).unwrap_or_default();
|
||||||
|
// turns that came before belong to the mission that ended
|
||||||
|
self.turns.clear();
|
||||||
|
let local_side = self.room.as_ref().and_then(|r| r.seats.iter().find(|s| s.name == self.name && s.connected).map(|s| s.side)).or(self.local_side);
|
||||||
|
self.local_side = local_side;
|
||||||
|
if let Some(r) = self.room.as_mut() {
|
||||||
|
r.turn = 0;
|
||||||
|
r.paused = false;
|
||||||
|
r.missing = false;
|
||||||
|
r.mission = Some(mission);
|
||||||
|
if !sides.is_empty() {
|
||||||
|
r.sides = sides.clone();
|
||||||
|
}
|
||||||
|
r.ai = ai.clone();
|
||||||
|
}
|
||||||
|
self.start = Some(StartInfo { seed, map_w, map_h, difficulty, sides, local_side, mission: Some(mission), ai, doc: Some(doc), editor: true, teams, next: true, carry, surface: false, events: false });
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_turn(&mut self, v: &Value) {
|
fn parse_turn(&mut self, v: &Value) {
|
||||||
let turn = v.get("turn").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
|
let turn = v.get("turn").and_then(|x| x.as_u64()).unwrap_or(0) as u32;
|
||||||
if let Some(r) = self.room.as_mut() {
|
if let Some(r) = self.room.as_mut() {
|
||||||
@@ -534,7 +642,8 @@ impl MatchClient {
|
|||||||
is_host,
|
is_host,
|
||||||
sides: room.sides.clone(),
|
sides: room.sides.clone(),
|
||||||
ai: room.ai.clone(),
|
ai: room.ai.clone(),
|
||||||
seats: room.seats.iter().map(|s| SeatUi { side: s.side, name: s.name.clone(), ready: s.ready, connected: s.connected, ai: s.ai }).collect(),
|
coop: room.mission.is_some(),
|
||||||
|
seats: room.seats.iter().map(|s| SeatUi { side: s.side, name: s.name.clone(), ready: s.ready, connected: s.connected, ai: s.ai, team: s.team }).collect(),
|
||||||
rooms: self.rooms.clone(),
|
rooms: self.rooms.clone(),
|
||||||
started: room.started,
|
started: room.started,
|
||||||
paused: room.paused,
|
paused: room.paused,
|
||||||
@@ -547,12 +656,34 @@ impl MatchClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// every seat starts as a team of its own (the relay does the same)
|
||||||
|
fn default_team(side: u8) -> u8 {
|
||||||
|
crate::map::Owner::MASTER_SIDES.iter().position(|s| *s == side).unwrap_or(0) as u8
|
||||||
|
}
|
||||||
|
|
||||||
fn array_bytes(v: Option<&Value>) -> Vec<u8> {
|
fn array_bytes(v: Option<&Value>) -> Vec<u8> {
|
||||||
v.and_then(|x| x.as_array())
|
v.and_then(|x| x.as_array())
|
||||||
.map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect())
|
.map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (side, team) pairs from the teams list that goes along with `sides`
|
||||||
|
fn teams_from(sides: &[u8], v: Option<&Value>) -> Vec<(u8, u8)> {
|
||||||
|
let teams = array_bytes(v);
|
||||||
|
sides.iter().copied().zip(teams).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// versus map sizes and how many master seats each one has
|
||||||
|
pub const MATCH_SIZES: [usize; 7] = [80, 96, 128, 160, 192, 224, 256];
|
||||||
|
|
||||||
|
pub fn seats_for(size: usize) -> &'static [u8] {
|
||||||
|
match size {
|
||||||
|
0..=80 => &[0, 2],
|
||||||
|
81..=96 => &[0, 2, 3],
|
||||||
|
_ => &[0, 2, 3, 4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> {
|
fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> {
|
||||||
v.and_then(|x| x.as_array())
|
v.and_then(|x| x.as_array())
|
||||||
.map(|arr| {
|
.map(|arr| {
|
||||||
@@ -563,6 +694,7 @@ fn seats_from(v: Option<&Value>) -> Vec<SeatInfo> {
|
|||||||
ready: s.get("ready").and_then(|x| x.as_bool()).unwrap_or(false),
|
ready: s.get("ready").and_then(|x| x.as_bool()).unwrap_or(false),
|
||||||
connected: s.get("connected").and_then(|x| x.as_bool()).unwrap_or(false),
|
connected: s.get("connected").and_then(|x| x.as_bool()).unwrap_or(false),
|
||||||
ai: s.get("ai").and_then(|x| x.as_bool()).unwrap_or(false),
|
ai: s.get("ai").and_then(|x| x.as_bool()).unwrap_or(false),
|
||||||
|
team: s.get("team").and_then(|x| x.as_u64()).unwrap_or_else(|| default_team(s.get("side").and_then(|x| x.as_u64()).unwrap_or(0) as u8) as u64) as u8,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
@@ -684,7 +816,7 @@ mod tests {
|
|||||||
use crate::blocks::{Block, Val};
|
use crate::blocks::{Block, Val};
|
||||||
use crate::campaign_doc::*;
|
use crate::campaign_doc::*;
|
||||||
let mut doc = CampaignDoc { name: "Coop test".into(), ..CampaignDoc::default() };
|
let mut doc = CampaignDoc { name: "Coop test".into(), ..CampaignDoc::default() };
|
||||||
let mut m = MissionDoc { key: "c1".into(), title: "Random".into(), size: (80, 80), seed: 5, goals: vec!["KillAll".into(), "Rivals".into()], waves: true, first_wave: 3.0, gen: GenDoc { rivers: 1, lava: 1, forts: 1, rivals: 1, difficulty: 1 }, ..MissionDoc::default() };
|
let mut m = MissionDoc { key: "c1".into(), title: "Random".into(), size: (80, 80), seed: 5, goals: vec!["KillAll".into(), "Rivals".into()], waves: true, first_wave: 3.0, gen: GenDoc { rivers: 1, lava: 1, forts: 1, rivals: 1, difficulty: 1, surface: false }, ..MissionDoc::default() };
|
||||||
m.scripts.start = vec![Block::Gold { amount: Val::num(250.0) }];
|
m.scripts.start = vec![Block::Gold { amount: Val::num(250.0) }];
|
||||||
m.scripts.triggers = vec![TriggerDoc { name: "wave".into(), kind: TriggerKind::Timer { secs: 2.0, every: true }, once: false, enabled: true, script: vec![Block::Wave { count: Val::num(1.0) }] }];
|
m.scripts.triggers = vec![TriggerDoc { name: "wave".into(), kind: TriggerKind::Timer { secs: 2.0, every: true }, once: false, enabled: true, script: vec![Block::Wave { count: Val::num(1.0) }] }];
|
||||||
doc.missions.push(m.clone());
|
doc.missions.push(m.clone());
|
||||||
@@ -704,9 +836,136 @@ mod tests {
|
|||||||
g.coop = true;
|
g.coop = true;
|
||||||
assert!(g.start_match(&info.sides, info.local_side.unwrap()), "the masters fit the map");
|
assert!(g.start_match(&info.sides, info.local_side.unwrap()), "the masters fit the map");
|
||||||
g.sync_ai_sides(&info.ai, info.difficulty);
|
g.sync_ai_sides(&info.ai, info.difficulty);
|
||||||
|
crate::campaign::apply_coop_carry(&mut g, &info.carry);
|
||||||
g
|
g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_clients_play_two_editor_missions_in_a_row_with_carry() {
|
||||||
|
use crate::game::EK;
|
||||||
|
let doc = coop_doc();
|
||||||
|
let mut clients = [MatchClient::new(), MatchClient::new()];
|
||||||
|
let room = r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","ready":true,"connected":true},{"side":2,"name":"green","ready":true,"connected":true}],"ai":[],"turn":0,"started":false,"mission":0}"#;
|
||||||
|
let start = json!({"t":"start","seed":99,"map_w":80,"map_h":80,"difficulty":1,"sides":[0,2],"teams":[0,1],"mission":0,"ai":[],"doc":serde_json::to_value(&doc).unwrap()}).to_string();
|
||||||
|
let mut games = Vec::new();
|
||||||
|
for (c, name) in clients.iter_mut().zip(["red", "green"]) {
|
||||||
|
c.on_connect(name);
|
||||||
|
c.outbox.clear();
|
||||||
|
c.feed(room);
|
||||||
|
c.feed(&start);
|
||||||
|
let info = c.take_start().expect("start info");
|
||||||
|
assert!(!info.next && info.carry.is_empty());
|
||||||
|
games.push(coop_game(&info));
|
||||||
|
}
|
||||||
|
for t in 0..60u32 {
|
||||||
|
for g in games.iter_mut() {
|
||||||
|
g.receive_turn(&[]);
|
||||||
|
}
|
||||||
|
if t % 20 == 19 {
|
||||||
|
assert_eq!(games[0].checksum(), games[1].checksum(), "mission 1, turn {t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// green raised a strong creature, red has a weak one; then the goal is reached on both
|
||||||
|
for g in games.iter_mut() {
|
||||||
|
let (cx, cy) = g.core_of_side(2);
|
||||||
|
let local = g.local_side;
|
||||||
|
g.switch_to(2);
|
||||||
|
let ogre = g.spawn(EK::Ogre, false, cx as f32 + 4.5, cy as f32 + 1.5, 6);
|
||||||
|
g.switch_to(0);
|
||||||
|
let (rx, ry) = g.core_of_side(0);
|
||||||
|
g.spawn(EK::Kobold, false, rx as f32 + 4.5, ry as f32 + 1.5, 1);
|
||||||
|
g.switch_to(local);
|
||||||
|
assert!(g.ents.iter().any(|e| e.id == ogre && e.side == 2));
|
||||||
|
g.state = 1;
|
||||||
|
}
|
||||||
|
let (next, _) = doc.next_mission_choice(0, |goal| games[0].match_goal_done(goal));
|
||||||
|
assert_eq!(next, Some(1), "the run goes on with the next mission");
|
||||||
|
let carry = crate::campaign::coop_carry_from(&games[0]);
|
||||||
|
assert_eq!(serde_json::to_string(&carry).unwrap(), serde_json::to_string(&crate::campaign::coop_carry_from(&games[1])).unwrap(), "every client sees the same carry");
|
||||||
|
let green_carry = &carry.iter().find(|c| c.0 == 2).unwrap().1;
|
||||||
|
assert!(green_carry.creatures.iter().any(|c| c.0 == EK::Ogre && c.1 == 6), "green takes his ogre along");
|
||||||
|
assert_eq!(green_carry.gold, (games[0].gold_of(2) * crate::campaign::CARRY_GOLD).floor());
|
||||||
|
|
||||||
|
// the host asks the relay for the next mission; the relay hands it to everybody
|
||||||
|
clients[0].next_mission(1, 99, &carry);
|
||||||
|
let req = pop(&mut clients[0]);
|
||||||
|
assert_eq!(req["t"].as_str(), Some("next"));
|
||||||
|
assert_eq!(req["mission"].as_u64(), Some(1));
|
||||||
|
let next = json!({"t":"next","seed":req["seed"],"difficulty":1,"sides":[0,2],"teams":[0,1],"mission":1,"ai":[],"carry":req["carry"]}).to_string();
|
||||||
|
let mut games2 = Vec::new();
|
||||||
|
for c in clients.iter_mut() {
|
||||||
|
c.feed(r#"{"t":"turn","turn":61,"cmds":[]}"#); // a late turn of the old mission
|
||||||
|
c.feed(&next);
|
||||||
|
let info = c.take_start().expect("the next mission starts");
|
||||||
|
assert!(info.next && info.editor && info.doc.is_some());
|
||||||
|
assert_eq!(info.mission, Some(1));
|
||||||
|
assert_eq!(info.carry.len(), 2);
|
||||||
|
assert!(c.take_turns().is_empty(), "turns of the old mission are dropped");
|
||||||
|
games2.push(coop_game(&info));
|
||||||
|
}
|
||||||
|
let (a, b) = (&games2[0], &games2[1]);
|
||||||
|
assert_eq!(a.checksum(), b.checksum(), "mission 2: the same start with the carry");
|
||||||
|
assert!(a.ents.iter().any(|e| e.alive && e.side == 2 && e.kind == EK::Ogre && e.level == 6), "the ogre came along with green");
|
||||||
|
let mut games2 = games2;
|
||||||
|
let (cx, cy) = games2[0].core_of_side(0);
|
||||||
|
for t in 0..80u32 {
|
||||||
|
let cmds = if t % 10 == 0 { vec![Command { side: 0, seq: t, act: Act::Dig(Rect::new(cx + 5, cy + (t / 10) as i32 - 3, cx + 6, cy + (t / 10) as i32 - 2)) }] } else { Vec::new() };
|
||||||
|
for g in games2.iter_mut() {
|
||||||
|
g.receive_turn(&cmds);
|
||||||
|
}
|
||||||
|
if t % 10 == 9 {
|
||||||
|
assert_eq!(games2[0].checksum(), games2[1].checksum(), "mission 2: identical up to turn {t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_bridge_sends_teams_and_reads_them_back() {
|
||||||
|
let mut client = MatchClient::new();
|
||||||
|
client.on_connect("red");
|
||||||
|
pop(&mut client); // rooms
|
||||||
|
client.set_team(None, 1);
|
||||||
|
let req = pop(&mut client);
|
||||||
|
assert_eq!((req["t"].as_str(), req["team"].as_u64(), req.get("side")), (Some("set_team"), Some(1), None));
|
||||||
|
client.set_team(Some(3), 2);
|
||||||
|
assert_eq!(pop(&mut client)["side"].as_u64(), Some(3));
|
||||||
|
client.feed(r#"{"t":"room","code":"ABC123","sides":[0,2],"seats":[{"side":0,"name":"red","connected":true,"team":1},{"side":2,"name":"","connected":false,"team":1}],"mission":null}"#);
|
||||||
|
let ui = client.ui();
|
||||||
|
assert!(!ui.coop);
|
||||||
|
assert_eq!(ui.seats.iter().map(|s| s.team).collect::<Vec<_>>(), vec![1, 1]);
|
||||||
|
client.feed(r#"{"t":"rooms","rooms":[{"code":"ABC123","players":1,"sides":[0,2],"mission":0,"editor":true,"campaign":"Deep Run","title":"First"}]}"#);
|
||||||
|
let ui = client.ui();
|
||||||
|
assert_eq!(ui.rooms[0].campaign.as_deref(), Some("Deep Run"));
|
||||||
|
assert_eq!(ui.rooms[0].title.as_deref(), Some("First"));
|
||||||
|
client.feed(r#"{"t":"start","seed":7,"sides":[0,2],"teams":[1,1]}"#);
|
||||||
|
let start = client.take_start().unwrap();
|
||||||
|
assert_eq!(start.teams, vec![(0, 1), (2, 1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_interrupted_coop_run_goes_on_with_its_seed_and_carry() {
|
||||||
|
let doc = coop_doc();
|
||||||
|
let mut host = MatchClient::new();
|
||||||
|
host.on_connect("red");
|
||||||
|
pop(&mut host); // rooms
|
||||||
|
let carry = vec![(0u8, crate::campaign::Carry { gold: 250.0, ..Default::default() })];
|
||||||
|
host.create_coop_doc_resumed(&doc, 1, &[0, 2, 3, 4], 1, Some((4242, &carry)));
|
||||||
|
let create = pop(&mut host);
|
||||||
|
assert_eq!(create["seed"].as_u64(), Some(4242));
|
||||||
|
assert_eq!(create["carry"][0][0].as_u64(), Some(0));
|
||||||
|
assert_eq!(create["mission"].as_u64(), Some(1));
|
||||||
|
let start = json!({"t":"start","seed":4242,"map_w":96,"map_h":96,"difficulty":1,"sides":[0,2],"mission":1,"ai":[],"doc":serde_json::to_value(&doc).unwrap(),"carry":create["carry"].clone(),"resume":true}).to_string();
|
||||||
|
host.feed(&start);
|
||||||
|
let info = host.take_start().unwrap();
|
||||||
|
assert!(info.next, "a resumed run keeps its progress");
|
||||||
|
assert_eq!(info.seed, 4242);
|
||||||
|
assert_eq!(info.carry.len(), 1);
|
||||||
|
assert_eq!(info.carry[0].1.gold, 250.0);
|
||||||
|
// old stored progress without the run position still reads
|
||||||
|
let p: crate::campaign_doc::CampaignProgress = serde_json::from_str(r#"{"done":[true],"completed_choice":[null],"run_seed":5}"#).unwrap();
|
||||||
|
assert_eq!((p.next, p.carry.len()), (None, 0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_clients_play_the_same_editor_campaign_mission_in_coop() {
|
fn two_clients_play_the_same_editor_campaign_mission_in_coop() {
|
||||||
let doc = coop_doc();
|
let doc = coop_doc();
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
|
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
|
||||||
|
// Windows release builds are a window program: no console opens beside the game
|
||||||
|
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod achievements;
|
||||||
|
mod ai;
|
||||||
|
mod armory;
|
||||||
mod audio;
|
mod audio;
|
||||||
|
mod badges;
|
||||||
mod campaign;
|
mod campaign;
|
||||||
mod campaign_doc;
|
mod campaign_doc;
|
||||||
mod cloud;
|
mod cloud;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod demo;
|
mod demo;
|
||||||
mod editor;
|
mod editor;
|
||||||
|
mod events;
|
||||||
mod dwarves;
|
mod dwarves;
|
||||||
mod fog;
|
mod fog;
|
||||||
mod fortress;
|
mod fortress;
|
||||||
@@ -24,23 +31,37 @@ mod particles;
|
|||||||
mod props;
|
mod props;
|
||||||
mod research;
|
mod research;
|
||||||
mod rival;
|
mod rival;
|
||||||
|
mod rogues;
|
||||||
|
mod balance;
|
||||||
|
mod talents;
|
||||||
|
mod weather;
|
||||||
|
mod names;
|
||||||
mod render3d;
|
mod render3d;
|
||||||
mod rooms;
|
mod rooms;
|
||||||
mod save;
|
mod save;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod scenes;
|
mod scenes;
|
||||||
|
mod beasts;
|
||||||
mod blocks;
|
mod blocks;
|
||||||
|
mod bosses;
|
||||||
|
mod bridges;
|
||||||
mod mscript;
|
mod mscript;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
mod siege;
|
||||||
mod sfx;
|
mod sfx;
|
||||||
mod tracks;
|
mod tracks;
|
||||||
|
mod traders;
|
||||||
mod traps;
|
mod traps;
|
||||||
mod sound;
|
mod sound;
|
||||||
|
mod surface;
|
||||||
mod spells;
|
mod spells;
|
||||||
|
mod stats;
|
||||||
mod touch;
|
mod touch;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod voice;
|
mod voice;
|
||||||
|
mod walls;
|
||||||
mod wilds;
|
mod wilds;
|
||||||
|
mod wood;
|
||||||
mod xm;
|
mod xm;
|
||||||
|
|
||||||
use cloud::{Cloud, CloudEvent};
|
use cloud::{Cloud, CloudEvent};
|
||||||
@@ -83,6 +104,7 @@ struct Input {
|
|||||||
rlast: (f32, f32),
|
rlast: (f32, f32),
|
||||||
rdrag: bool,
|
rdrag: bool,
|
||||||
mlast: Option<(f32, f32)>, // middle button drag
|
mlast: Option<(f32, f32)>, // middle button drag
|
||||||
|
mclick: f64, // time of the last middle click (a double click resets the view)
|
||||||
minimap_drag: bool,
|
minimap_drag: bool,
|
||||||
// touch
|
// touch
|
||||||
tlast: Option<Vec2>, // last position of a one-finger pan (device pixels)
|
tlast: Option<Vec2>, // last position of a one-finger pan (device pixels)
|
||||||
@@ -106,7 +128,7 @@ fn tool_tile(cam: &Cam, map: &map::Map, tool: Tool, mx: f32, my: f32) -> Option<
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn area_tool(t: Tool) -> bool {
|
fn area_tool(t: Tool) -> bool {
|
||||||
matches!(t, Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel)
|
matches!(t, Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel | Tool::Drawbridge | Tool::Wall)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the active tool on the world at (mx, my) (CSS pixels): a click, or a tap on touch screens
|
// Use the active tool on the world at (mx, my) (CSS pixels): a click, or a tap on touch screens
|
||||||
@@ -119,10 +141,19 @@ fn world_action(app: &mut App, ui: &mut Ui, r3d: &R3d, inp: &mut Input, mx: f32,
|
|||||||
let ground = app.cam.ground_point(mx, my);
|
let ground = app.cam.ground_point(mx, my);
|
||||||
let (wx, wy) = (ground.x, ground.y);
|
let (wx, wy) = (ground.x, ground.y);
|
||||||
match (ui.tool, tile) {
|
match (ui.tool, tile) {
|
||||||
(Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel, Some(t)) => inp.ldown = Some(t),
|
(Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel | Tool::Drawbridge | Tool::Wall, Some(t)) => inp.ldown = Some(t),
|
||||||
(Tool::Hand, _) => {
|
(Tool::Hand, _) => {
|
||||||
let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
|
let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
|
||||||
let Some(j) = j else { return };
|
let Some(j) = j else {
|
||||||
|
// a memorial stone: the hall of the fallen opens
|
||||||
|
let (gx, gy) = (wx.floor() as i32, wy.floor() as i32);
|
||||||
|
if map::inb(gx, gy) && app.game.memorial_at(map::idx(gx, gy)).is_some() {
|
||||||
|
ui.show_overview = true;
|
||||||
|
ui.ov_fallen = true;
|
||||||
|
ui.ov_scroll = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
let (id, hero, dwarf) = (app.game.ents[j].id, app.game.ents[j].hero, app.game.ents[j].dwarf());
|
let (id, hero, dwarf) = (app.game.ents[j].id, app.game.ents[j].hero, app.game.ents[j].dwarf());
|
||||||
if dwarf {
|
if dwarf {
|
||||||
return;
|
return;
|
||||||
@@ -142,6 +173,7 @@ fn world_action(app: &mut App, ui: &mut Ui, r3d: &R3d, inp: &mut Input, mx: f32,
|
|||||||
(Tool::Tower(k), Some((x, z))) => app.game.queue(Act::Tower { kind: k, x, y: z }),
|
(Tool::Tower(k), Some((x, z))) => app.game.queue(Act::Tower { kind: k, x, y: z }),
|
||||||
(Tool::Trap(k), Some((x, z))) => app.game.queue(Act::Trap { kind: k, x, y: z }),
|
(Tool::Trap(k), Some((x, z))) => app.game.queue(Act::Trap { kind: k, x, y: z }),
|
||||||
(Tool::Spell(sp), _) => app.game.queue(Act::Cast { spell: sp, x: wx, y: wy }),
|
(Tool::Spell(sp), _) => app.game.queue(Act::Cast { spell: sp, x: wx, y: wy }),
|
||||||
|
(Tool::Siege(which), Some((tx, ty))) => app.game.queue(Act::Siege { which, x: tx, y: ty }),
|
||||||
(Tool::Order(0), _) => {
|
(Tool::Order(0), _) => {
|
||||||
let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
|
let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
|
||||||
let ent = j.filter(|&j| j < app.game.ents.len()).map(|j| app.game.ents[j].id);
|
let ent = j.filter(|&j| j < app.game.ents.len()).map(|j| app.game.ents[j].id);
|
||||||
@@ -162,13 +194,21 @@ fn finish_rect(app: &mut App, tool: Tool, start: (i32, i32), mx: f32, my: f32) {
|
|||||||
Tool::Room(room) => app.game.queue(Act::Room { room: room as u8, r }),
|
Tool::Room(room) => app.game.queue(Act::Room { room: room as u8, r }),
|
||||||
Tool::Sell => app.game.queue(Act::Sell(r)),
|
Tool::Sell => app.game.queue(Act::Sell(r)),
|
||||||
Tool::Bridge => app.game.queue(Act::Bridge(r)),
|
Tool::Bridge => app.game.queue(Act::Bridge(r)),
|
||||||
|
Tool::Drawbridge => app.game.queue(Act::Drawbridge(r)),
|
||||||
|
Tool::Wall => app.game.queue(Act::Wall(r)),
|
||||||
Tool::Steel => app.game.queue(Act::Steel(r)),
|
Tool::Steel => app.game.queue(Act::Steel(r)),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_game(seed: u32, s: &Settings) -> (Game, Cam) {
|
fn new_game(seed: u32, s: &Settings) -> (Game, Cam) {
|
||||||
let g = Game::new_free(seed, s.map_w, s.map_h, s.difficulty);
|
let mut g = Game::new_free(seed, s.map_w, s.map_h, s.difficulty);
|
||||||
|
if s.surface {
|
||||||
|
// the eastern half above ground: forest, paths, cave mouths
|
||||||
|
g.add_surface(seed, 0.5, 2 + (s.map_h / 64) as u32);
|
||||||
|
g.init_fog();
|
||||||
|
}
|
||||||
|
g.set_events(s.events);
|
||||||
let (cx, cy) = g.map.core;
|
let (cx, cy) = g.map.core;
|
||||||
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
||||||
(g, cam)
|
(g, cam)
|
||||||
@@ -222,6 +262,63 @@ fn store_progress(p: &campaign::Progress) {
|
|||||||
platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default());
|
platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// progress of editor campaigns played in co-op, kept by the host: campaign name -> progress
|
||||||
|
const COOP_PROGRESS_KEY: &str = "dc.coop_editor_progress";
|
||||||
|
|
||||||
|
pub(crate) fn coop_progress_all() -> std::collections::BTreeMap<String, campaign_doc::CampaignProgress> {
|
||||||
|
platform::storage_get(COOP_PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// where an interrupted co-op run of this campaign goes on: mission, run seed, carry
|
||||||
|
pub(crate) fn coop_resume_of(doc: &campaign_doc::CampaignDoc) -> Option<(usize, u32, Vec<(u8, campaign::Carry)>)> {
|
||||||
|
let p = coop_progress_all().remove(&doc.name)?;
|
||||||
|
let next = p.next.filter(|n| doc.missions.get(*n).map_or(false, campaign::is_editor_coop_mission))?;
|
||||||
|
Some((next, p.run_seed, p.carry))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_coop_progress(name: &str, p: &campaign_doc::CampaignProgress) {
|
||||||
|
let mut all = coop_progress_all();
|
||||||
|
all.insert(name.to_string(), p.clone());
|
||||||
|
platform::storage_set(COOP_PROGRESS_KEY, &serde_json::to_string(&all).unwrap_or_default());
|
||||||
|
}
|
||||||
|
|
||||||
|
// a co-op run through an editor campaign: the mission being played, the run's seed, what came
|
||||||
|
// along into it, and - once it is over - where the host goes next and what everybody takes along
|
||||||
|
struct CoopRun {
|
||||||
|
doc: campaign_doc::CampaignDoc,
|
||||||
|
mission: usize,
|
||||||
|
run_seed: u32,
|
||||||
|
carry_in: Vec<(u8, campaign::Carry)>,
|
||||||
|
next: Option<usize>,
|
||||||
|
carry_out: Vec<(u8, campaign::Carry)>,
|
||||||
|
sent: bool, // the host asked the relay for the next mission
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoopRun {
|
||||||
|
// the mission is over: the host decides where the run goes on (the branch of the reached goal,
|
||||||
|
// or the same mission again after a defeat) and stores the progress
|
||||||
|
fn finish(&mut self, g: &Game) {
|
||||||
|
let won = g.state == 1;
|
||||||
|
if won {
|
||||||
|
let (next, choice) = self.doc.next_mission_choice(self.mission, |goal| g.match_goal_done(goal));
|
||||||
|
self.next = next.filter(|n| self.doc.missions.get(*n).map_or(false, campaign::is_editor_coop_mission));
|
||||||
|
self.carry_out = campaign::coop_carry_from(g);
|
||||||
|
if g.local_side == 0 {
|
||||||
|
let mut p = coop_progress_all().remove(&self.doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&self.doc));
|
||||||
|
p.run_seed = self.run_seed;
|
||||||
|
p.mark_done(self.mission, self.doc.missions.len(), choice);
|
||||||
|
// the run goes on with the next mission (or is over)
|
||||||
|
p.next = self.next;
|
||||||
|
p.carry = self.carry_out.clone();
|
||||||
|
store_coop_progress(&self.doc.name, &p);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.next = Some(self.mission);
|
||||||
|
self.carry_out = self.carry_in.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// login, account and standalone actions of the menu
|
// login, account and standalone actions of the menu
|
||||||
fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut App, now: f64) {
|
fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut App, now: f64) {
|
||||||
match a {
|
match a {
|
||||||
@@ -279,7 +376,11 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::MatchJoin(code) => app.client.join(&code),
|
MenuAction::MatchJoin(code) => app.client.join(&code),
|
||||||
MenuAction::MatchCreate => app.client.create(&[0, 2, 3, 4], app.settings.map_w, app.settings.map_h, app.settings.difficulty),
|
MenuAction::MatchCreate => {
|
||||||
|
// the map size decides the number of master seats
|
||||||
|
let n = app.settings.match_size;
|
||||||
|
app.client.create_land(lobby::seats_for(n), n, n, app.settings.difficulty, app.settings.surface, app.settings.events)
|
||||||
|
}
|
||||||
MenuAction::MatchCoopCreate(mission) => {
|
MenuAction::MatchCoopCreate(mission) => {
|
||||||
let all = campaign::missions();
|
let all = campaign::missions();
|
||||||
if let Some(d) = all.get(mission) {
|
if let Some(d) = all.get(mission) {
|
||||||
@@ -289,14 +390,44 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MenuAction::MatchCoopEditorCreate(mission) => {
|
MenuAction::MatchCoopEditorCreate(mission) => {
|
||||||
if menu.campaign_doc.missions.get(mission).map_or(false, campaign::is_editor_coop_mission) {
|
let doc = menu.coop_doc().clone();
|
||||||
app.client.create_coop_doc(&menu.campaign_doc, mission, &[0, 2, 3, 4], app.settings.difficulty);
|
if doc.missions.get(mission).map_or(false, campaign::is_editor_coop_mission) {
|
||||||
|
app.client.create_coop_doc(&doc, mission, &[0, 2, 3, 4], app.settings.difficulty);
|
||||||
} else {
|
} else {
|
||||||
menu.error = Some(i18n::tr("net.coop_editor_none"));
|
menu.error = Some(i18n::tr("net.coop_editor_none"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MenuAction::MatchCoopSolo(doc, mission) => {
|
||||||
|
// the open lobby connection is not needed: the game runs on this computer alone
|
||||||
|
if app.start_offline_coop(doc, mission, None, Vec::new()) {
|
||||||
|
menu.page = None;
|
||||||
|
menu.error = None;
|
||||||
|
app.set_status(i18n::tr("net.coop_solo_started"), now);
|
||||||
|
} else {
|
||||||
|
menu.error = Some(i18n::tr("net.coop_editor_none"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuAction::MatchCoopResume(doc) => {
|
||||||
|
// with the relay: a room that goes on with the run; without it: offline with an AI partner
|
||||||
|
match coop_resume_of(&doc) {
|
||||||
|
Some((mission, seed, carry)) if app.client.connected => {
|
||||||
|
app.client.create_coop_doc_resumed(&doc, mission, &[0, 2, 3, 4], app.settings.difficulty, Some((seed, &carry)));
|
||||||
|
}
|
||||||
|
Some((mission, seed, carry)) => {
|
||||||
|
if app.start_offline_coop(Some(doc), mission, Some(seed), carry) {
|
||||||
|
menu.page = None;
|
||||||
|
menu.error = None;
|
||||||
|
app.set_status(i18n::tr("net.coop_solo_started"), now);
|
||||||
|
} else {
|
||||||
|
menu.error = Some(i18n::tr("net.coop_editor_none"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => menu.error = Some(i18n::tr("net.coop_editor_none")),
|
||||||
|
}
|
||||||
|
}
|
||||||
MenuAction::MatchTakeSide(side) => app.client.take_side(side),
|
MenuAction::MatchTakeSide(side) => app.client.take_side(side),
|
||||||
MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai),
|
MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai),
|
||||||
|
MenuAction::MatchSetTeam(side, team, own) => app.client.set_team(if own { None } else { Some(side) }, team),
|
||||||
MenuAction::MatchReady(ready) => app.client.ready(ready),
|
MenuAction::MatchReady(ready) => app.client.ready(ready),
|
||||||
MenuAction::MatchStart => app.client.start(),
|
MenuAction::MatchStart => app.client.start(),
|
||||||
MenuAction::MatchResume => app.client.resume(),
|
MenuAction::MatchResume => app.client.resume(),
|
||||||
@@ -348,7 +479,7 @@ fn apply_settings(s: &Settings, r3d: &mut R3d) {
|
|||||||
r3d.outlines = s.outlines;
|
r3d.outlines = s.outlines;
|
||||||
r3d.set_wall_detail(s.wall_subdivisions());
|
r3d.set_wall_detail(s.wall_subdivisions());
|
||||||
r3d.max_lights = s.max_lights as usize;
|
r3d.max_lights = s.max_lights as usize;
|
||||||
r3d.render_div = s.render_scale.clamp(1, 16) as u32;
|
r3d.pixel_rows = if [270, 360, 480].contains(&s.pixel_rows) { s.pixel_rows as u32 } else { 360 };
|
||||||
audio::set_volumes(s.music_volume, s.volume, s.muted);
|
audio::set_volumes(s.music_volume, s.volume, s.muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +503,8 @@ impl MusicDirector {
|
|||||||
t
|
t
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, g: &Game, title: bool, cam: (f32, f32)) {
|
// `chosen`: a track picked in the music player; it replaces the calm music
|
||||||
|
fn update(&mut self, g: &Game, title: bool, cam: (f32, f32), chosen: Option<usize>) {
|
||||||
use audio::Category as C;
|
use audio::Category as C;
|
||||||
let alive = |f: &dyn Fn(&game::Ent) -> bool| g.ents.iter().any(|e| e.alive && f(e));
|
let alive = |f: &dyn Fn(&game::Ent) -> bool| g.ents.iter().any(|e| e.alive && f(e));
|
||||||
let near_room = |k: map::Kind| {
|
let near_room = |k: map::Kind| {
|
||||||
@@ -387,6 +519,19 @@ impl MusicDirector {
|
|||||||
}
|
}
|
||||||
n >= 4
|
n >= 4
|
||||||
};
|
};
|
||||||
|
// the camera looks at the land above ground: the music of the surface
|
||||||
|
let above = {
|
||||||
|
let (cx, cz) = (cam.0 as i32, cam.1 as i32);
|
||||||
|
let mut n = 0;
|
||||||
|
for y in (cz - 4..=cz + 4).step_by(2) {
|
||||||
|
for x in (cx - 4..=cx + 4).step_by(2) {
|
||||||
|
if map::inb(x, y) && g.map.sky(map::idx(x, y)) {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n >= 13
|
||||||
|
};
|
||||||
let want = if title {
|
let want = if title {
|
||||||
C::Title
|
C::Title
|
||||||
} else if g.state == 1 {
|
} else if g.state == 1 {
|
||||||
@@ -408,11 +553,35 @@ impl MusicDirector {
|
|||||||
} else {
|
} else {
|
||||||
C::Build
|
C::Build
|
||||||
};
|
};
|
||||||
let rotate = matches!(want, C::Build | C::Title) && self.current == Some(want) && audio::music_loops() >= 2;
|
let want = match want {
|
||||||
if self.current != Some(want) || rotate || audio::current_track().is_none() {
|
C::Battle if above => C::SurfaceBattle,
|
||||||
|
C::Build | C::Torture | C::Temple if above => C::Surface,
|
||||||
|
w => w,
|
||||||
|
};
|
||||||
|
let calm = matches!(want, C::Build | C::Title | C::Surface | C::Torture | C::Temple);
|
||||||
|
if let (true, Some(t)) = (calm, chosen) {
|
||||||
|
if audio::current_track() != Some(t) {
|
||||||
|
let fade = if self.current.map_or(false, |c| matches!(c, C::Victory | C::Defeat)) { 1.0 } else { 2.5 };
|
||||||
|
audio::play_music(t, fade);
|
||||||
|
}
|
||||||
|
self.current = Some(want);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rotate = matches!(want, C::Build | C::Title | C::Surface) && self.current == Some(want) && audio::music_loops() >= 2;
|
||||||
|
// after the chosen track was let go, the director picks again
|
||||||
|
let foreign = chosen.is_none() && audio::current_track().map_or(false, |t| crate::tracks::TRACKS.get(t).map_or(false, |x| x.1 != want)) && calm && self.current == Some(want);
|
||||||
|
if self.current != Some(want) || rotate || foreign || audio::current_track().is_none() {
|
||||||
|
let before = self.current;
|
||||||
self.current = Some(want);
|
self.current = Some(want);
|
||||||
let t = self.pick(want);
|
let t = self.pick(want);
|
||||||
let fade = if matches!(want, C::Victory | C::Defeat) { 1.0 } else { 2.5 };
|
// between the dungeon and the surface the music changes slowly
|
||||||
|
let fade = if matches!(want, C::Victory | C::Defeat) {
|
||||||
|
1.0
|
||||||
|
} else if matches!((before, want), (Some(C::Surface | C::SurfaceBattle), _) | (_, C::Surface | C::SurfaceBattle)) {
|
||||||
|
4.0
|
||||||
|
} else {
|
||||||
|
2.5
|
||||||
|
};
|
||||||
audio::play_music(t, fade);
|
audio::play_music(t, fade);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,6 +619,9 @@ struct App {
|
|||||||
match_mode: bool,
|
match_mode: bool,
|
||||||
coop: bool,
|
coop: bool,
|
||||||
coop_editor: bool, // the co-op match plays a mission of an editor campaign
|
coop_editor: bool, // the co-op match plays a mission of an editor campaign
|
||||||
|
coop_run: Option<CoopRun>, // ... and this is where the run through that campaign stands
|
||||||
|
offline: bool, // a co-op game played without the server: the other seat is an AI partner
|
||||||
|
tutorial: bool, // the tutorial mission is being played (its end leads back to the title)
|
||||||
fps: f32,
|
fps: f32,
|
||||||
renderer: String,
|
renderer: String,
|
||||||
frame_ms: f32,
|
frame_ms: f32,
|
||||||
@@ -472,20 +644,20 @@ impl App {
|
|||||||
self.status = Some((text, now));
|
self.status = Some((text, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load a savegame (settings in the savegame are always applied)
|
// Load a savegame (only the game state; the settings stay as they are). The camera looks at
|
||||||
|
// the heart (screenshot scenes bring their own camera).
|
||||||
fn load_bytes(&mut self, bytes: &[u8], ui: &mut Ui, menu: &mut Menu, r3d: &mut R3d, now: f64) {
|
fn load_bytes(&mut self, bytes: &[u8], ui: &mut Ui, menu: &mut Menu, r3d: &mut R3d, now: f64) {
|
||||||
match save::from_bytes(bytes) {
|
match save::from_bytes(bytes) {
|
||||||
Ok(l) => {
|
Ok(l) => {
|
||||||
self.game = l.game;
|
self.game = l.game;
|
||||||
if let Some(c) = l.camera {
|
match l.camera {
|
||||||
self.cam = c;
|
Some(c) => self.cam = c,
|
||||||
|
None => {
|
||||||
|
let (cx, cy) = self.game.map.core;
|
||||||
|
self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.cam.snap();
|
self.cam.snap();
|
||||||
}
|
|
||||||
if let Some(s) = l.settings {
|
|
||||||
self.settings = s;
|
|
||||||
apply_settings(&self.settings, r3d);
|
|
||||||
store_settings(&self.settings);
|
|
||||||
}
|
|
||||||
apply_game_settings(&mut self.game, &self.settings);
|
apply_game_settings(&mut self.game, &self.settings);
|
||||||
self.game.coop = false;
|
self.game.coop = false;
|
||||||
self.coop = false;
|
self.coop = false;
|
||||||
@@ -501,7 +673,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn autosave_json(&self) -> String {
|
fn autosave_json(&self) -> String {
|
||||||
save::to_json(&self.game, &self.cam, &self.settings)
|
save::to_json(&self.game)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn connect_match(&mut self, cloud: &Cloud) -> bool {
|
fn connect_match(&mut self, cloud: &Cloud) -> bool {
|
||||||
@@ -574,9 +746,32 @@ impl App {
|
|||||||
self.match_mode = false;
|
self.match_mode = false;
|
||||||
self.coop = false;
|
self.coop = false;
|
||||||
self.coop_editor = false;
|
self.coop_editor = false;
|
||||||
|
self.offline = false;
|
||||||
self.client.on_disconnect();
|
self.client.on_disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A co-op mission without the server: built like a match start with the player on side 0 and an
|
||||||
|
// AI partner on side 2, then the world runs on this computer alone (no turns, no relay).
|
||||||
|
// `doc` is an editor campaign (None: a built-in co-op mission); `run_seed`/`carry` continue a run.
|
||||||
|
fn start_offline_coop(&mut self, doc: Option<campaign_doc::CampaignDoc>, mission: usize, run_seed: Option<u32>, carry: Vec<(u8, campaign::Carry)>) -> bool {
|
||||||
|
let size = match &doc {
|
||||||
|
Some(d) => d.missions.get(mission).map(|m| m.size),
|
||||||
|
None => campaign::missions().get(mission).map(|m| m.size),
|
||||||
|
};
|
||||||
|
let Some((map_w, map_h)) = size else { return false };
|
||||||
|
let next = run_seed.is_some();
|
||||||
|
let seed = run_seed.unwrap_or_else(|| next_seed(self.seed));
|
||||||
|
let editor = doc.is_some();
|
||||||
|
let info = StartInfo { seed, map_w, map_h, difficulty: self.settings.difficulty, sides: vec![0, 2], local_side: Some(0), mission: Some(mission), ai: vec![2], doc, editor, teams: Vec::new(), next, carry, surface: false, events: false };
|
||||||
|
if !self.start_match_game(info) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.game.end_match();
|
||||||
|
self.offline = true;
|
||||||
|
self.recorded = false;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
// the shared part of a co-op start (built-in or editor mission): the masters, AI seats, settings
|
// the shared part of a co-op start (built-in or editor mission): the masters, AI seats, settings
|
||||||
fn start_coop_game(&mut self, mut game: Game, info: &StartInfo) -> bool {
|
fn start_coop_game(&mut self, mut game: Game, info: &StartInfo) -> bool {
|
||||||
let local = info.local_side.unwrap_or(0);
|
let local = info.local_side.unwrap_or(0);
|
||||||
@@ -586,6 +781,8 @@ 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);
|
||||||
|
// what the masters bring from the last mission of an editor campaign run (same on every client)
|
||||||
|
campaign::apply_coop_carry(&mut game, &info.carry);
|
||||||
apply_game_settings(&mut game, &self.settings);
|
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;
|
||||||
@@ -598,6 +795,7 @@ impl App {
|
|||||||
|
|
||||||
fn start_match_game(&mut self, info: StartInfo) -> bool {
|
fn start_match_game(&mut self, info: StartInfo) -> bool {
|
||||||
self.coop_editor = false;
|
self.coop_editor = false;
|
||||||
|
self.coop_run = None;
|
||||||
if !info.sides.contains(&0) {
|
if !info.sides.contains(&0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -614,7 +812,19 @@ impl App {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.coop_editor = true;
|
self.coop_editor = true;
|
||||||
|
self.recorded = false;
|
||||||
self.pending_briefing = briefing_of(doc, mission);
|
self.pending_briefing = briefing_of(doc, mission);
|
||||||
|
// the host keeps the progress of the run (a new run starts with the room's seed) and
|
||||||
|
// where it stands, so an interrupted run can go on later
|
||||||
|
if info.local_side == Some(0) {
|
||||||
|
let mut p = coop_progress_all().remove(&doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(doc));
|
||||||
|
p.ensure(doc.missions.len());
|
||||||
|
p.run_seed = info.seed;
|
||||||
|
p.next = Some(mission);
|
||||||
|
p.carry = info.carry.clone();
|
||||||
|
store_coop_progress(&doc.name, &p);
|
||||||
|
}
|
||||||
|
self.coop_run = Some(CoopRun { doc: doc.clone(), mission, run_seed: info.seed, carry_in: info.carry.clone(), next: None, carry_out: Vec::new(), sent: false });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if let Some(mission) = info.mission {
|
if let Some(mission) = info.mission {
|
||||||
@@ -632,7 +842,13 @@ impl App {
|
|||||||
if !self.game.start_match(&info.sides, local) {
|
if !self.game.start_match(&info.sides, local) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if info.surface {
|
||||||
|
// the same land on every client: built from the shared seed
|
||||||
|
self.game.add_surface(info.seed, 0.5, 2 + (info.map_h / 64) as u32);
|
||||||
|
}
|
||||||
|
self.game.set_events(info.events);
|
||||||
self.game.coop = false;
|
self.game.coop = false;
|
||||||
|
self.game.set_teams(&info.teams);
|
||||||
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);
|
||||||
apply_game_settings(&mut self.game, &self.settings);
|
apply_game_settings(&mut self.game, &self.settings);
|
||||||
@@ -675,6 +891,12 @@ async fn main() {
|
|||||||
let mut r3d = R3d::new();
|
let mut r3d = R3d::new();
|
||||||
let mut sounds = SoundBank::new();
|
let mut sounds = SoundBank::new();
|
||||||
let mut music = MusicDirector::new();
|
let mut music = MusicDirector::new();
|
||||||
|
// lifetime statistics and achievements (browser storage, merged with the server after a login)
|
||||||
|
let stored_profile: achievements::Profile = platform::storage_get(achievements::PROFILE_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
|
||||||
|
let mut tracker = achievements::Tracker::new(stored_profile);
|
||||||
|
let mut want_profile = false; // fetch the server's profile when the client is free
|
||||||
|
let mut profile_store_t = 0.0f32;
|
||||||
|
let mut profile_upload_t = 0.0f32;
|
||||||
let mut ui = Ui::new();
|
let mut ui = Ui::new();
|
||||||
let mut menu = Menu::new();
|
let mut menu = Menu::new();
|
||||||
let mut cloud = Cloud::new(); // savegame service; files + browser autosave when unreachable
|
let mut cloud = Cloud::new(); // savegame service; files + browser autosave when unreachable
|
||||||
@@ -695,10 +917,10 @@ 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, coop_editor: 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, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: None };
|
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, coop_editor: false, coop_run: None, offline: false, tutorial: 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, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: 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();
|
||||||
apply_game_settings(&mut app.game, &app.settings);
|
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, mclick: -1.0, 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();
|
||||||
let mut menu_swipe: Option<(f32, f32)> = None; // a swipe over a menu: last finger y, unused distance
|
let mut menu_swipe: Option<(f32, f32)> = None; // a swipe over a menu: last finger y, unused distance
|
||||||
@@ -709,8 +931,12 @@ async fn main() {
|
|||||||
let mut voice = voice::Voice::new();
|
let mut voice = voice::Voice::new();
|
||||||
let mut was_briefing = false;
|
let mut was_briefing = false;
|
||||||
let mut cache_t = 0.0f32;
|
let mut cache_t = 0.0f32;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
audio::native::start();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
audio::native::pump();
|
||||||
let raw_dt = get_frame_time();
|
let raw_dt = get_frame_time();
|
||||||
let dt = raw_dt.min(0.1);
|
let dt = raw_dt.min(0.1);
|
||||||
let now = get_time();
|
let now = get_time();
|
||||||
@@ -739,6 +965,7 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
if app.editor.is_some() && !app.editor_testing {
|
if app.editor.is_some() && !app.editor_testing {
|
||||||
editor_frame(&mut app, &mut menu, &mut cloud, &mut r3d, &gfx, &mut ui, dt, now);
|
editor_frame(&mut app, &mut menu, &mut cloud, &mut r3d, &gfx, &mut ui, dt, now);
|
||||||
|
ui::end_frame();
|
||||||
next_frame().await;
|
next_frame().await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -758,6 +985,22 @@ async fn main() {
|
|||||||
_ => auto_touch,
|
_ => auto_touch,
|
||||||
};
|
};
|
||||||
ui::set_touch(touch_mode);
|
ui::set_touch(touch_mode);
|
||||||
|
// pixel look: the interface on a virtual screen whose pixels are whole parts of the world's
|
||||||
|
let rows = if [270, 360, 480].contains(&app.settings.pixel_rows) { app.settings.pixel_rows as u32 } else { 360 };
|
||||||
|
// the interface needs room: the smallest whole multiple of the world's rows with at least
|
||||||
|
// 900 rows (360 -> 1080), on a desktop and on a touch screen alike (a phone has about
|
||||||
|
// 1080 device rows in landscape; a smaller screen draws in device pixels)
|
||||||
|
let need: u32 = 900;
|
||||||
|
// measured on the shorter side: upright (portrait) the width gets those pixels and the
|
||||||
|
// height grows with the screen, so the world and the menus keep their size. The setting
|
||||||
|
// "interface size" picks the interface pixels per world pixel: large 2, medium 3, small 4
|
||||||
|
let short = match app.settings.ui_factor {
|
||||||
|
k @ 2..=4 => rows * k as u32,
|
||||||
|
_ => rows * need.div_ceil(rows),
|
||||||
|
};
|
||||||
|
let (w, h) = (screen_width().max(1.0), screen_height().max(1.0));
|
||||||
|
let vrows = if h > w { (short as f32 * h / w).round() as u32 } else { short };
|
||||||
|
ui::set_virtual_rows(vrows);
|
||||||
if touch_mode {
|
if touch_mode {
|
||||||
// the pointer is the finger, or the position of this frame's gesture
|
// the pointer is the finger, or the position of this frame's gesture
|
||||||
let gpos = gestures.iter().rev().find_map(|g| match *g {
|
let gpos = gestures.iter().rev().find_map(|g| match *g {
|
||||||
@@ -820,6 +1063,7 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
CloudEvent::LoggedIn => {
|
CloudEvent::LoggedIn => {
|
||||||
|
want_profile = true;
|
||||||
if menu.page == Some(Page::Login) {
|
if menu.page == Some(Page::Login) {
|
||||||
menu.page = Some(Page::Title);
|
menu.page = Some(Page::Title);
|
||||||
}
|
}
|
||||||
@@ -852,6 +1096,8 @@ 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(now);
|
app.poll_match(now);
|
||||||
menu.in_match = app.match_mode || app.game.in_match();
|
menu.in_match = app.match_mode || app.game.in_match();
|
||||||
|
menu.gold_allies = app.game.gold_allies();
|
||||||
|
ui.coop_end = app.coop_run.as_ref().filter(|_| app.coop_editor).map(|r| (app.game.local_side == 0, r.next.is_some(), r.sent));
|
||||||
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());
|
||||||
}
|
}
|
||||||
@@ -963,6 +1209,7 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
let p = mouse_position();
|
let p = mouse_position();
|
||||||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, p, false, now);
|
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, p, false, now);
|
||||||
|
ui::end_frame();
|
||||||
next_frame().await;
|
next_frame().await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1114,6 +1361,16 @@ async fn main() {
|
|||||||
if is_key_down(key(Action::TiltDown)) {
|
if is_key_down(key(Action::TiltDown)) {
|
||||||
cam.pitch -= rot * 0.6;
|
cam.pitch -= rot * 0.6;
|
||||||
}
|
}
|
||||||
|
// a double click with the middle button: the standard angle and turn (same place)
|
||||||
|
if is_mouse_button_pressed(MouseButton::Middle) && !touch_mode {
|
||||||
|
let now = get_time();
|
||||||
|
if now - inp.mclick < 0.35 {
|
||||||
|
cam.reset_view();
|
||||||
|
inp.mclick = -1.0;
|
||||||
|
} else {
|
||||||
|
inp.mclick = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
if is_mouse_button_down(MouseButton::Middle) && !touch_mode {
|
if is_mouse_button_down(MouseButton::Middle) && !touch_mode {
|
||||||
if let Some((lx, ly)) = inp.mlast {
|
if let Some((lx, ly)) = inp.mlast {
|
||||||
let inv = if keys.invert_mouse { -1.0 } else { 1.0 };
|
let inv = if keys.invert_mouse { -1.0 } else { 1.0 };
|
||||||
@@ -1174,8 +1431,8 @@ async fn main() {
|
|||||||
if !inp.rdrag && !over_ui && app.game.state == 0 {
|
if !inp.rdrag && !over_ui && app.game.state == 0 {
|
||||||
if inp.ldown.is_some() {
|
if inp.ldown.is_some() {
|
||||||
inp.ldown = None;
|
inp.ldown = None;
|
||||||
} else if app.game.hand.is_empty() && matches!(ui.tool, Tool::Order(_)) {
|
} else if app.game.hand.is_empty() && matches!(ui.tool, Tool::Order(_) | Tool::Flag) {
|
||||||
app.game.queue(Act::CancelOrders);
|
app.game.queue(Act::Unmark { x: wx, y: wy });
|
||||||
} else {
|
} else {
|
||||||
app.game.queue(Act::Drop { x: wx, y: wy });
|
app.game.queue(Act::Drop { x: wx, y: wy });
|
||||||
}
|
}
|
||||||
@@ -1215,6 +1472,7 @@ async fn main() {
|
|||||||
match menu.click(ux, uy, &mut app.settings, &cloud) {
|
match menu.click(ux, uy, &mut app.settings, &cloud) {
|
||||||
MenuAction::SettingsChanged => settings_changed = true,
|
MenuAction::SettingsChanged => settings_changed = true,
|
||||||
MenuAction::Resume => {}
|
MenuAction::Resume => {}
|
||||||
|
MenuAction::SendGold(to, amount) => app.game.queue(Act::SendGold { to, amount }),
|
||||||
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);
|
||||||
@@ -1245,7 +1503,7 @@ async fn main() {
|
|||||||
MenuAction::Save | MenuAction::FileSave => {
|
MenuAction::Save | MenuAction::FileSave => {
|
||||||
let name = save_file_name();
|
let name = save_file_name();
|
||||||
platform::download(&name, app.autosave_json().as_bytes());
|
platform::download(&name, app.autosave_json().as_bytes());
|
||||||
app.set_status(i18n::trf("save.saved", &[name]), now);
|
app.set_status(i18n::trf("save.saved", &[platform::saved_as(&name)]), now);
|
||||||
}
|
}
|
||||||
MenuAction::CloudSave(id, name) => {
|
MenuAction::CloudSave(id, name) => {
|
||||||
if cloud.save(id.as_deref(), &name, &app.autosave_json()) {
|
if cloud.save(id.as_deref(), &name, &app.autosave_json()) {
|
||||||
@@ -1292,7 +1550,7 @@ async fn main() {
|
|||||||
MenuAction::CampaignSave => {
|
MenuAction::CampaignSave => {
|
||||||
let name = menu.campaign_doc.file_name();
|
let name = menu.campaign_doc.file_name();
|
||||||
platform::download(&name, menu.campaign_doc.save().as_bytes());
|
platform::download(&name, menu.campaign_doc.save().as_bytes());
|
||||||
app.set_status(i18n::trf("campaign.saved", &[name]), now);
|
app.set_status(i18n::trf("campaign.saved", &[platform::saved_as(&name)]), now);
|
||||||
}
|
}
|
||||||
MenuAction::CampaignEditName => {
|
MenuAction::CampaignEditName => {
|
||||||
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_name"), &menu.campaign_doc.name, 60) {
|
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_name"), &menu.campaign_doc.name, 60) {
|
||||||
@@ -1381,6 +1639,24 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MenuAction::Tutorial => {
|
||||||
|
let doc = editor::templates::tutorial_campaign();
|
||||||
|
let progress = campaign_doc::CampaignProgress::for_doc(&doc);
|
||||||
|
(app.game, app.cam) = custom_mission_game(&doc, 0, progress.run_seed);
|
||||||
|
app.pending_briefing = briefing_of(&doc, 0);
|
||||||
|
apply_game_settings(&mut app.game, &app.settings);
|
||||||
|
app.game.coop = false;
|
||||||
|
app.coop = false;
|
||||||
|
app.recorded = false;
|
||||||
|
app.custom_campaign = Some(doc);
|
||||||
|
app.custom_progress = Some(progress);
|
||||||
|
app.custom_mission_index = Some(0);
|
||||||
|
app.tutorial = true;
|
||||||
|
menu.page = None;
|
||||||
|
ui.log.clear();
|
||||||
|
ui.show_help = false;
|
||||||
|
autosave_t = 0.0;
|
||||||
|
}
|
||||||
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),
|
||||||
MenuAction::OpenOpenCode => platform::open_url(menu::OPENCODE_URL),
|
MenuAction::OpenOpenCode => platform::open_url(menu::OPENCODE_URL),
|
||||||
@@ -1398,12 +1674,15 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
|
|||||||
| MenuAction::MatchCoopCreate(..)
|
| MenuAction::MatchCoopCreate(..)
|
||||||
| MenuAction::MatchTakeSide(..)
|
| MenuAction::MatchTakeSide(..)
|
||||||
| MenuAction::MatchSetAi(..)
|
| MenuAction::MatchSetAi(..)
|
||||||
|
| MenuAction::MatchSetTeam(..)
|
||||||
| MenuAction::MatchReady(..)
|
| MenuAction::MatchReady(..)
|
||||||
| MenuAction::MatchStart
|
| MenuAction::MatchStart
|
||||||
| MenuAction::MatchResume
|
| MenuAction::MatchResume
|
||||||
| MenuAction::MatchAbort
|
| MenuAction::MatchAbort
|
||||||
| MenuAction::MatchLeave
|
| MenuAction::MatchLeave
|
||||||
| MenuAction::MatchCoopEditorCreate(..)
|
| MenuAction::MatchCoopEditorCreate(..)
|
||||||
|
| MenuAction::MatchCoopSolo(..)
|
||||||
|
| MenuAction::MatchCoopResume(..)
|
||||||
| MenuAction::MatchChat(..)
|
| MenuAction::MatchChat(..)
|
||||||
| MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now),
|
| MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now),
|
||||||
MenuAction::EditName => {
|
MenuAction::EditName => {
|
||||||
@@ -1415,6 +1694,7 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
|
|||||||
MenuAction::BackToEditor => back_to_editor(&mut app, &mut menu, &mut r3d),
|
MenuAction::BackToEditor => back_to_editor(&mut app, &mut menu, &mut r3d),
|
||||||
MenuAction::MainMenu if app.editor_testing => back_to_editor(&mut app, &mut menu, &mut r3d),
|
MenuAction::MainMenu if app.editor_testing => back_to_editor(&mut app, &mut menu, &mut r3d),
|
||||||
MenuAction::MainMenu => {
|
MenuAction::MainMenu => {
|
||||||
|
app.tutorial = false;
|
||||||
if app.match_mode || app.game.in_match() {
|
if app.match_mode || app.game.in_match() {
|
||||||
app.leave_match();
|
app.leave_match();
|
||||||
} else if app.game.state == 0 {
|
} else if app.game.state == 0 {
|
||||||
@@ -1447,7 +1727,46 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
|
|||||||
}
|
}
|
||||||
// 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::EndCampaign if app.editor_testing && app.editor_test_only => back_to_editor(&mut app, &mut menu, &mut r3d),
|
UiAction::EndNext | UiAction::EndCampaign if app.editor_testing && app.editor_test_only => back_to_editor(&mut app, &mut menu, &mut r3d),
|
||||||
// a co-op mission of an editor campaign is over: the match ends, back to the lobby
|
// a co-op mission of an editor campaign is over: the host starts the next one (or the
|
||||||
|
// same one again) for everybody; the others wait for it
|
||||||
|
UiAction::EndNext if app.coop_editor && app.game.local_side == 0 && app.coop_run.as_ref().map_or(false, |r| r.next.is_some() && !r.sent) => {
|
||||||
|
if app.offline {
|
||||||
|
// no relay: the next mission of the run starts here at once
|
||||||
|
let run = app.coop_run.take().expect("a co-op run");
|
||||||
|
let next = run.next.unwrap_or(run.mission);
|
||||||
|
if app.start_offline_coop(Some(run.doc), next, Some(run.run_seed), run.carry_out) {
|
||||||
|
ui.log.clear();
|
||||||
|
autosave_t = 0.0;
|
||||||
|
}
|
||||||
|
} else if let Some(run) = app.coop_run.as_mut() {
|
||||||
|
if let Some(next) = run.next {
|
||||||
|
app.client.next_mission(next, run.run_seed, &run.carry_out);
|
||||||
|
run.sent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// a built-in co-op mission without the server: the next one after a victory, else again
|
||||||
|
UiAction::EndNext if app.offline && !app.coop_editor => {
|
||||||
|
let i = app.game.mission.as_ref().map_or(0, |m| m.index);
|
||||||
|
let next = if app.game.state == 1 { campaign::next_coop_mission(i, 1) } else { i };
|
||||||
|
if next <= i && app.game.state == 1 {
|
||||||
|
app.leave_match();
|
||||||
|
menu.page = Some(Page::Title);
|
||||||
|
app.seed = next_seed(app.seed);
|
||||||
|
(app.game, app.cam) = Game::new_demo(app.seed);
|
||||||
|
} else if app.start_offline_coop(None, next, None, Vec::new()) {
|
||||||
|
ui.log.clear();
|
||||||
|
autosave_t = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiAction::EndCampaign if app.offline && !app.coop_editor => {
|
||||||
|
app.leave_match();
|
||||||
|
menu.page = Some(Page::Title);
|
||||||
|
app.seed = next_seed(app.seed);
|
||||||
|
(app.game, app.cam) = Game::new_demo(app.seed);
|
||||||
|
}
|
||||||
|
UiAction::EndNext if app.coop_editor && app.coop_run.as_ref().map_or(false, |r| r.next.is_some()) => {}
|
||||||
|
// ... or the run is over (or the player leaves): the match ends, back to the lobby
|
||||||
UiAction::EndNext | UiAction::EndCampaign if app.coop_editor => {
|
UiAction::EndNext | UiAction::EndCampaign if app.coop_editor => {
|
||||||
app.leave_match();
|
app.leave_match();
|
||||||
app.seed = next_seed(app.seed);
|
app.seed = next_seed(app.seed);
|
||||||
@@ -1457,6 +1776,15 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
|
|||||||
menu.error = Some(i18n::tr("net.status_offline"));
|
menu.error = Some(i18n::tr("net.status_offline"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UiAction::EndNext | UiAction::EndCampaign if app.tutorial && app.game.state == 1 => {
|
||||||
|
app.tutorial = false;
|
||||||
|
app.custom_campaign = None;
|
||||||
|
app.custom_progress = None;
|
||||||
|
app.custom_mission_index = None;
|
||||||
|
menu.page = Some(Page::Title);
|
||||||
|
app.seed = next_seed(app.seed);
|
||||||
|
(app.game, app.cam) = Game::new_demo(app.seed);
|
||||||
|
}
|
||||||
UiAction::EndNext => {
|
UiAction::EndNext => {
|
||||||
if app.custom_campaign.is_some() {
|
if app.custom_campaign.is_some() {
|
||||||
let campaign = app.custom_campaign.clone();
|
let campaign = app.custom_campaign.clone();
|
||||||
@@ -1521,6 +1849,8 @@ app.pending_briefing = briefing_of(&campaign, i);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiAction::OpenMenu => menu.page = Some(Page::Pause),
|
UiAction::OpenMenu => menu.page = Some(Page::Pause),
|
||||||
|
UiAction::SendGold(to, amount) => app.game.queue(Act::SendGold { to, amount }),
|
||||||
|
UiAction::LearnTalent(branch) => app.game.queue(Act::Talent { branch }),
|
||||||
UiAction::Locked => {
|
UiAction::Locked => {
|
||||||
let locked = ui.last_locked.unwrap_or(usize::MAX);
|
let locked = ui.last_locked.unwrap_or(usize::MAX);
|
||||||
app.game.msg(if app.game.can_research(locked) { "msg.tool_research" } else { "msg.tool_locked" }, vec![]);
|
app.game.msg(if app.game.can_research(locked) { "msg.tool_research" } else { "msg.tool_locked" }, vec![]);
|
||||||
@@ -1662,15 +1992,34 @@ app.pending_briefing = briefing_of(&campaign, i);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.game.smooth_display(dt);
|
app.game.smooth_display(dt);
|
||||||
|
// winter: snow falls on the land above ground near the camera
|
||||||
|
if render3d::season() == 2 && !app.game.map.sky.is_empty() && app.game.parts.len() < 3000 {
|
||||||
|
let (cx, cz) = (app.cam.tx, app.cam.tz);
|
||||||
|
for _ in 0..3 {
|
||||||
|
let x = cx + (rand::gen_range(-1.0f32, 1.0)) * app.cam.zoom * 0.8;
|
||||||
|
let z = cz + (rand::gen_range(-1.0f32, 1.0)) * app.cam.zoom * 0.8;
|
||||||
|
if inb(x as i32, z as i32) && app.game.map.sky(map::idx(x as i32, z as i32)) {
|
||||||
|
app.game.parts.push(particles::Part { kind: particles::EMBER, x, y: z, h: 4.0, vx: rand::gen_range(-0.2f32, 0.2), vy: 0.0, vh: -0.6, t: 0.0, max: 6.0, col: particles::abgr(245, 248, 255), size: 0.04 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let hear = app.cam.zoom * 1.2;
|
let hear = app.cam.zoom * 1.2;
|
||||||
let (right, _) = app.cam.ground_axes();
|
let (right, _) = app.cam.ground_axes();
|
||||||
for ev in app.game.events.drain(..) {
|
for ev in app.game.events.drain(..) {
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
sounds.ambience(&app.game, (app.cam.rx, app.cam.rz), now);
|
||||||
// a won mission goes into the campaign progress (with the carry-over); a co-op mission of an
|
// a won mission goes into the campaign progress (with the carry-over); a co-op mission of an
|
||||||
// editor campaign keeps no progress (its index is not a built-in mission)
|
// editor campaign keeps no progress (its index is not a built-in mission)
|
||||||
if app.coop_editor {
|
if app.coop_editor {
|
||||||
app.recorded = app.game.state != 0;
|
if app.game.state != 0 && !app.recorded {
|
||||||
|
app.recorded = true;
|
||||||
|
if let Some(run) = app.coop_run.as_mut() {
|
||||||
|
run.finish(&app.game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if app.coop {
|
||||||
|
// a built-in co-op mission is no step of the single-player campaign: no progress
|
||||||
} else if app.custom_campaign.is_none() {
|
} else if app.custom_campaign.is_none() {
|
||||||
if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
|
if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
|
||||||
if app.game.state != 0 {
|
if app.game.state != 0 {
|
||||||
@@ -1713,8 +2062,48 @@ app.pending_briefing = briefing_of(&campaign, i);
|
|||||||
| Some(Page::Login)
|
| Some(Page::Login)
|
||||||
| Some(Page::Account)
|
| Some(Page::Account)
|
||||||
| Some(Page::Bestiary { from_title: true, .. })
|
| Some(Page::Bestiary { from_title: true, .. })
|
||||||
|
| Some(Page::Match { from_title: true })
|
||||||
|
| Some(Page::Achievements { from_title: true })
|
||||||
);
|
);
|
||||||
music.update(&app.game, on_title, (app.cam.rx, app.cam.rz));
|
music.update(&app.game, on_title, (app.cam.rx, app.cam.rz), audio::track_by_id(&app.settings.music_track));
|
||||||
|
// ---------------- achievements: count the game, unlock, store and share the profile
|
||||||
|
{
|
||||||
|
let ctx = achievements::Context { coop: app.coop, editor_test: app.editor_testing };
|
||||||
|
let unix = miniquad::date::now() as u64;
|
||||||
|
let counting = !on_title && (playing || app.game.state != 0);
|
||||||
|
for id in if counting { tracker.update(&app.game, &ctx, dt, unix) } else { Vec::new() } {
|
||||||
|
let (name, _) = achievements::texts(&id);
|
||||||
|
app.game.msgs.push(game::Msg { key: "msg.achievement", args: vec![name] });
|
||||||
|
audio::play_sfx(game::Sfx::Victory.index(), 0.8, 0.0);
|
||||||
|
}
|
||||||
|
if let Some(json) = cloud.profile_loaded.take() {
|
||||||
|
if let Ok(p) = serde_json::from_str::<achievements::Profile>(&json) {
|
||||||
|
tracker.profile.merge(&p);
|
||||||
|
tracker.profile.check_tiers(unix);
|
||||||
|
tracker.dirty = true;
|
||||||
|
tracker.upload = tracker.profile != p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if want_profile && cloud.session.is_some() && cloud.load_profile() {
|
||||||
|
want_profile = false;
|
||||||
|
}
|
||||||
|
profile_store_t += dt;
|
||||||
|
profile_upload_t += dt;
|
||||||
|
if tracker.dirty && (profile_store_t > 10.0 || tracker.upload) {
|
||||||
|
profile_store_t = 0.0;
|
||||||
|
tracker.dirty = false;
|
||||||
|
platform::storage_set(achievements::PROFILE_KEY, &serde_json::to_string(&tracker.profile).unwrap_or_default());
|
||||||
|
}
|
||||||
|
if tracker.upload && cloud.session.is_some() && profile_upload_t > 30.0 && !want_profile {
|
||||||
|
if cloud.save_profile(&serde_json::to_string(&tracker.profile).unwrap_or_default()) {
|
||||||
|
tracker.upload = false;
|
||||||
|
profile_upload_t = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches!(menu.page, Some(Page::Achievements { .. })) {
|
||||||
|
menu.profile = tracker.profile.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
for m in app.game.msgs.drain(..) {
|
for m in app.game.msgs.drain(..) {
|
||||||
if app.settings.voice && voice::speaks(m.key) {
|
if app.settings.voice && voice::speaks(m.key) {
|
||||||
voice.say(&i18n::trf(m.key, &m.args), now, 12);
|
voice.say(&i18n::trf(m.key, &m.args), now, 12);
|
||||||
@@ -1736,6 +2125,7 @@ app.pending_briefing = briefing_of(&campaign, i);
|
|||||||
voice.update(now, if app.settings.muted { 0.0 } else { app.settings.volume });
|
voice.update(now, if app.settings.muted { 0.0 } else { app.settings.volume });
|
||||||
|
|
||||||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, (mx, my), touch_mode, now);
|
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, (mx, my), touch_mode, now);
|
||||||
|
ui::end_frame();
|
||||||
next_frame().await
|
next_frame().await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1755,7 +2145,11 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
|||||||
let ground = app.cam.ground_point(mx, my);
|
let ground = app.cam.ground_point(mx, my);
|
||||||
clear_background(Color::from_rgba(4, 3, 5, 255));
|
clear_background(Color::from_rgba(4, 3, 5, 255));
|
||||||
let (color, radius) = match ui.tool {
|
let (color, radius) = match ui.tool {
|
||||||
Tool::Dig => (Color::new(1.0, 0.85, 0.3, 1.0), None),
|
Tool::Dig => {
|
||||||
|
// the dig selection in the master's colour (like the marks)
|
||||||
|
let c = render3d::master_rgb(app.game.local_side);
|
||||||
|
(Color::new(c.x, c.y, c.z, 1.0), None)
|
||||||
|
}
|
||||||
Tool::Hand => (Color::new(0.9, 0.75, 0.6, 1.0), None),
|
Tool::Hand => (Color::new(0.9, 0.75, 0.6, 1.0), None),
|
||||||
Tool::Room(_) => (Color::new(0.4, 0.85, 1.0, 1.0), None),
|
Tool::Room(_) => (Color::new(0.4, 0.85, 1.0, 1.0), None),
|
||||||
Tool::Sell => (Color::new(1.0, 0.5, 0.3, 1.0), None),
|
Tool::Sell => (Color::new(1.0, 0.5, 0.3, 1.0), None),
|
||||||
@@ -1763,7 +2157,9 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
|||||||
Tool::Lightning => (Color::new(1.0, 1.0, 0.6, 1.0), Some((ground.x, ground.y, 1.6))),
|
Tool::Lightning => (Color::new(1.0, 1.0, 0.6, 1.0), Some((ground.x, ground.y, 1.6))),
|
||||||
Tool::Heal => (Color::new(0.4, 1.0, 0.5, 1.0), Some((ground.x, ground.y, 2.55))),
|
Tool::Heal => (Color::new(0.4, 1.0, 0.5, 1.0), Some((ground.x, ground.y, 2.55))),
|
||||||
Tool::Summon => (Color::new(0.8, 0.5, 1.0, 1.0), None),
|
Tool::Summon => (Color::new(0.8, 0.5, 1.0, 1.0), None),
|
||||||
Tool::Bridge => (Color::new(0.9, 0.7, 0.4, 1.0), None),
|
Tool::Bridge | Tool::Drawbridge => (Color::new(0.9, 0.7, 0.4, 1.0), None),
|
||||||
|
Tool::Wall => (Color::new(0.8, 0.4, 1.0, 1.0), None),
|
||||||
|
Tool::Siege(_) => (Color::new(0.9, 0.7, 0.4, 1.0), None),
|
||||||
Tool::Steel => (Color::new(0.75, 0.8, 0.9, 1.0), None),
|
Tool::Steel => (Color::new(0.75, 0.8, 0.9, 1.0), None),
|
||||||
Tool::Tower(_) => (Color::new(1.0, 0.6, 0.25, 1.0), None),
|
Tool::Tower(_) => (Color::new(1.0, 0.6, 0.25, 1.0), None),
|
||||||
Tool::Trap(_) => (Color::new(1.0, 0.4, 0.3, 1.0), None),
|
Tool::Trap(_) => (Color::new(1.0, 0.4, 0.3, 1.0), None),
|
||||||
@@ -1781,6 +2177,24 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
|||||||
if app.settings.debug_trigger_zones || app.settings.debug_path_lines || app.editor_testing {
|
if app.settings.debug_trigger_zones || app.settings.debug_path_lines || app.editor_testing {
|
||||||
draw_debug_3d(app, now);
|
draw_debug_3d(app, now);
|
||||||
}
|
}
|
||||||
|
// planned walls of this master: a pulsing frame where the wall will rise
|
||||||
|
let pulse = 0.55 + 0.25 * ((now * 3.0).sin() as f32);
|
||||||
|
let me = app.game.local_side;
|
||||||
|
for &(i, side) in app.game.wall_plans.iter().filter(|p| p.1 == me).take(200) {
|
||||||
|
let (x, z) = map::xy(i);
|
||||||
|
let (x0, z0, x1, z1) = (x as f32 + 0.08, z as f32 + 0.08, x as f32 + 0.92, z as f32 + 0.92);
|
||||||
|
let c = Color::new(0.75, 0.45, 1.0, pulse);
|
||||||
|
let _ = side;
|
||||||
|
for h in [0.02, 0.9] {
|
||||||
|
line3(vec3(x0, h, z0), vec3(x1, h, z0), 0.04, c);
|
||||||
|
line3(vec3(x1, h, z0), vec3(x1, h, z1), 0.04, c);
|
||||||
|
line3(vec3(x1, h, z1), vec3(x0, h, z1), 0.04, c);
|
||||||
|
line3(vec3(x0, h, z1), vec3(x0, h, z0), 0.04, c);
|
||||||
|
}
|
||||||
|
for (a, b) in [(x0, z0), (x1, z0), (x1, z1), (x0, z1)] {
|
||||||
|
line3(vec3(a, 0.02, b), vec3(a, 0.9, b), 0.04, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
let title_screen = matches!(
|
let title_screen = matches!(
|
||||||
menu.page,
|
menu.page,
|
||||||
Some(Page::Title)
|
Some(Page::Title)
|
||||||
@@ -1792,6 +2206,8 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
|||||||
| Some(Page::Login)
|
| Some(Page::Login)
|
||||||
| Some(Page::Account)
|
| Some(Page::Account)
|
||||||
| Some(Page::Bestiary { from_title: true, .. })
|
| Some(Page::Bestiary { from_title: true, .. })
|
||||||
|
| Some(Page::Match { from_title: true })
|
||||||
|
| Some(Page::Achievements { from_title: true })
|
||||||
);
|
);
|
||||||
if !title_screen {
|
if !title_screen {
|
||||||
let info = if over_ui || (touch && inp.inspect.is_none()) {
|
let info = if over_ui || (touch && inp.inspect.is_none()) {
|
||||||
@@ -1939,7 +2355,11 @@ app.pending_briefing = briefing_of(&ed.doc, i);
|
|||||||
ui.show_help = false;
|
ui.show_help = false;
|
||||||
r3d.invalidate();
|
r3d.invalidate();
|
||||||
}
|
}
|
||||||
editor::EdAction::SaveFile => platform::download(&ed.doc.file_name(), ed.doc.save().as_bytes()),
|
editor::EdAction::SaveFile => {
|
||||||
|
let name = ed.doc.file_name();
|
||||||
|
platform::download(&name, ed.doc.save().as_bytes());
|
||||||
|
ed.set_status(i18n::trf("campaign.saved", &[platform::saved_as(&name)]));
|
||||||
|
}
|
||||||
editor::EdAction::OpenFile => platform::open_file(),
|
editor::EdAction::OpenFile => platform::open_file(),
|
||||||
editor::EdAction::ServerSave => {
|
editor::EdAction::ServerSave => {
|
||||||
if !cloud.available() {
|
if !cloud.available() {
|
||||||
|
|||||||
@@ -68,8 +68,21 @@ pub enum Kind {
|
|||||||
GuardPost, // room: creatures hold the position
|
GuardPost, // room: creatures hold the position
|
||||||
Arena, // room: sparring
|
Arena, // room: sparring
|
||||||
Altar, // room: sacrifices
|
Altar, // room: sacrifices
|
||||||
|
Drawbridge, // bridge that can be raised (res: see DB_RAISED, DB_AUTO, DB_LAVA)
|
||||||
|
Obsidian, // lava cooled by frost: walkable until it melts again
|
||||||
|
Mushroom, // giant mushroom: imps fell it for wood once it is grown (food: growth left, 0 = grown; res: MUSH_GROVE)
|
||||||
|
Tree, // tree above ground: imps fell it for wood
|
||||||
|
Grove, // mushroom grove: mossy ground nobody can claim, giant mushrooms grow (back) here
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a giant mushroom of a grove: when felled, the ground turns back into grove (Tile::res)
|
||||||
|
pub const MUSH_GROVE: u8 = 1;
|
||||||
|
|
||||||
|
// drawbridge state in Tile::res
|
||||||
|
pub const DB_RAISED: u8 = 1; // raised: nobody can cross
|
||||||
|
pub const DB_AUTO: u8 = 2; // rises by itself when enemies come near
|
||||||
|
pub const DB_LAVA: u8 = 4; // spans lava (stone), else water (wood)
|
||||||
|
|
||||||
// options for the map generator (campaign missions)
|
// options for the map generator (campaign missions)
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct GenOpts {
|
pub struct GenOpts {
|
||||||
@@ -96,7 +109,7 @@ pub struct River {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// order matters for the packed savegame codec: only append new kinds
|
// order matters for the packed savegame codec: only append new kinds
|
||||||
const KINDS: [Kind; 34] = [
|
const KINDS: [Kind; 39] = [
|
||||||
Kind::Rock,
|
Kind::Rock,
|
||||||
Kind::Earth,
|
Kind::Earth,
|
||||||
Kind::Gold,
|
Kind::Gold,
|
||||||
@@ -131,6 +144,11 @@ const KINDS: [Kind; 34] = [
|
|||||||
Kind::GuardPost,
|
Kind::GuardPost,
|
||||||
Kind::Arena,
|
Kind::Arena,
|
||||||
Kind::Altar,
|
Kind::Altar,
|
||||||
|
Kind::Drawbridge,
|
||||||
|
Kind::Obsidian,
|
||||||
|
Kind::Mushroom,
|
||||||
|
Kind::Tree,
|
||||||
|
Kind::Grove,
|
||||||
];
|
];
|
||||||
|
|
||||||
// treasures (veins, treasury content, imp loads): 0 gold, 1 silver, 2 ruby, 3 diamond
|
// treasures (veins, treasury content, imp loads): 0 gold, 1 silver, 2 ruby, 3 diamond
|
||||||
@@ -151,6 +169,18 @@ pub enum Owner {
|
|||||||
const OWNERS: [Owner; 6] = [Owner::None, Owner::Player, Owner::Hero, Owner::Rival1, Owner::Rival2, Owner::Rival3];
|
const OWNERS: [Owner; 6] = [Owner::None, Owner::Player, Owner::Hero, Owner::Rival1, Owner::Rival2, Owner::Rival3];
|
||||||
|
|
||||||
impl Owner {
|
impl Owner {
|
||||||
|
// the side of a ground owner (255: nobody)
|
||||||
|
pub fn side(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Owner::Player => 0,
|
||||||
|
Owner::Hero => 1,
|
||||||
|
Owner::Rival1 => 2,
|
||||||
|
Owner::Rival2 => 3,
|
||||||
|
Owner::Rival3 => 4,
|
||||||
|
Owner::None => 255,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn of_side(side: u8) -> Owner {
|
pub fn of_side(side: u8) -> Owner {
|
||||||
match side {
|
match side {
|
||||||
0 => Owner::Player,
|
0 => Owner::Player,
|
||||||
@@ -192,17 +222,31 @@ pub struct Tile {
|
|||||||
pub reinforced: bool, // wall reinforced by imps
|
pub reinforced: bool, // wall reinforced by imps
|
||||||
#[serde(rename = "rs", default)]
|
#[serde(rename = "rs", default)]
|
||||||
pub res: u8, // treasury: kind of treasure stored here (see RES_GOLD)
|
pub res: u8, // treasury: kind of treasure stored here (see RES_GOLD)
|
||||||
|
#[serde(rename = "mb", default)]
|
||||||
|
pub mark_by: u8, // side of the master who marked it for digging (its colour)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tile {
|
impl Tile {
|
||||||
pub fn new(kind: Kind) -> Tile {
|
pub fn new(kind: Kind) -> Tile {
|
||||||
Tile { kind, owner: Owner::None, hp: 0.0, gold: 0, marked: false, reserved: 0, food: 0.0, bed: 0, var: 0, reinforced: false, res: 0 }
|
Tile { kind, owner: Owner::None, hp: 0.0, gold: 0, marked: false, reserved: 0, food: 0.0, bed: 0, var: 0, reinforced: false, res: 0, mark_by: 0 }
|
||||||
}
|
}
|
||||||
pub fn solid(&self) -> bool {
|
pub fn solid(&self) -> bool {
|
||||||
matches!(self.kind, Kind::Rock | Kind::Earth | Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond | Kind::Steel)
|
matches!(self.kind, Kind::Rock | Kind::Earth | Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond | Kind::Steel)
|
||||||
}
|
}
|
||||||
pub fn diggable(&self) -> bool {
|
pub fn diggable(&self) -> bool {
|
||||||
matches!(self.kind, Kind::Earth | Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond | Kind::Steel)
|
matches!(self.kind, Kind::Earth | Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond | Kind::Steel | Kind::Tree) || (self.kind == Kind::Mushroom && self.grown())
|
||||||
|
}
|
||||||
|
// a giant mushroom is felled only when it is fully grown (everything else counts as grown)
|
||||||
|
pub fn grown(&self) -> bool {
|
||||||
|
self.kind != Kind::Mushroom || self.food <= 0.0
|
||||||
|
}
|
||||||
|
// how far a giant mushroom has grown: 0 a sprout .. 1 fully grown
|
||||||
|
pub fn growth(&self) -> f32 {
|
||||||
|
if self.kind == Kind::Mushroom { (1.0 - self.food).clamp(0.0, 1.0) } else { 1.0 }
|
||||||
|
}
|
||||||
|
// a giant mushroom or a tree: felled like a wall is dug, gives wood
|
||||||
|
pub fn plant(&self) -> bool {
|
||||||
|
matches!(self.kind, Kind::Mushroom | Kind::Tree)
|
||||||
}
|
}
|
||||||
// the treasure of a vein (None for everything else)
|
// the treasure of a vein (None for everything else)
|
||||||
pub fn vein(&self) -> Option<u8> {
|
pub fn vein(&self) -> Option<u8> {
|
||||||
@@ -240,20 +284,27 @@ impl Tile {
|
|||||||
| Kind::GuardPost
|
| Kind::GuardPost
|
||||||
| Kind::Arena
|
| Kind::Arena
|
||||||
| Kind::Altar
|
| Kind::Altar
|
||||||
)
|
| Kind::Obsidian
|
||||||
|
| Kind::Grove
|
||||||
|
) || (self.kind == Kind::Drawbridge && self.res & DB_RAISED == 0)
|
||||||
}
|
}
|
||||||
// river tiles (open, but nobody can walk there)
|
// river tiles (open, but nobody can walk there)
|
||||||
pub fn liquid(&self) -> bool {
|
pub fn liquid(&self) -> bool {
|
||||||
matches!(self.kind, Kind::Water | Kind::Lava)
|
matches!(self.kind, Kind::Water | Kind::Lava)
|
||||||
}
|
}
|
||||||
pub fn bridge(&self) -> bool {
|
pub fn bridge(&self) -> bool {
|
||||||
matches!(self.kind, Kind::Bridge | Kind::LavaBridge)
|
matches!(self.kind, Kind::Bridge | Kind::LavaBridge | Kind::Drawbridge)
|
||||||
}
|
}
|
||||||
// the river below a bridge or river tile
|
pub fn raised(&self) -> bool {
|
||||||
|
self.kind == Kind::Drawbridge && self.res & DB_RAISED != 0
|
||||||
|
}
|
||||||
|
// the river below a bridge or river tile (obsidian: the lava under the crust)
|
||||||
pub fn river(&self) -> Option<Kind> {
|
pub fn river(&self) -> Option<Kind> {
|
||||||
match self.kind {
|
match self.kind {
|
||||||
Kind::Water | Kind::Bridge => Some(Kind::Water),
|
Kind::Water | Kind::Bridge => Some(Kind::Water),
|
||||||
Kind::Lava | Kind::LavaBridge => Some(Kind::Lava),
|
Kind::Lava | Kind::LavaBridge | Kind::Obsidian => Some(Kind::Lava),
|
||||||
|
Kind::Drawbridge if self.res & DB_LAVA != 0 => Some(Kind::Lava),
|
||||||
|
Kind::Drawbridge => Some(Kind::Water),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +366,10 @@ pub struct Map {
|
|||||||
pub explored: Vec<bool>, // fog of war: tiles the dungeon knows (empty: no fog)
|
pub explored: Vec<bool>, // fog of war: tiles the dungeon knows (empty: no fog)
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub visible: Vec<bool>, // fog of war: tiles in sight right now
|
pub visible: Vec<bool>, // fog of war: tiles in sight right now
|
||||||
|
#[serde(default, with = "crate::fog::bits")]
|
||||||
|
pub sky: Vec<bool>, // tiles above ground (daylight, grass); empty: all underground
|
||||||
|
#[serde(default)]
|
||||||
|
pub upgrades: Vec<(u8, u8, u8)>, // researched room levels for the look: (master side, room index, level)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_size() -> usize {
|
fn default_size() -> usize {
|
||||||
@@ -332,6 +387,21 @@ impl Map {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tile i lies above ground
|
||||||
|
pub fn sky(&self, i: usize) -> bool {
|
||||||
|
self.sky.get(i).copied().unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_sky(&mut self, i: usize, on: bool) {
|
||||||
|
if self.sky.len() != self.t.len() {
|
||||||
|
if !on {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.sky = vec![false; self.t.len()];
|
||||||
|
}
|
||||||
|
self.sky[i] = on;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get(&self, x: i32, y: i32) -> Option<&Tile> {
|
pub fn get(&self, x: i32, y: i32) -> Option<&Tile> {
|
||||||
if inb(x, y) {
|
if inb(x, y) {
|
||||||
Some(&self.t[idx(x, y)])
|
Some(&self.t[idx(x, y)])
|
||||||
@@ -349,9 +419,10 @@ impl Map {
|
|||||||
self.portals.iter().position(|&(px, py)| x >= px && x < px + 3 && y >= py && y < py + 3)
|
self.portals.iter().position(|&(px, py)| x >= px && x < px + 3 && y >= py && y < py + 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn portal_owned(&self, k: usize) -> bool {
|
// whose portal k is (any master can claim one)
|
||||||
|
pub fn portal_owner(&self, k: usize) -> Owner {
|
||||||
let (px, py) = self.portals[k];
|
let (px, py) = self.portals[k];
|
||||||
self.t[idx(px, py)].owner == Owner::Player
|
self.t[idx(px, py)].owner
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate(seed: u32, w: usize, h: usize) -> Map {
|
pub fn generate(seed: u32, w: usize, h: usize) -> Map {
|
||||||
@@ -552,7 +623,7 @@ impl Map {
|
|||||||
tl.hp = 6.0;
|
tl.hp = 6.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Map { w, h, t, core, portals, legacy_portal: None, gate, treasures, explored: Vec::new(), visible: Vec::new() }
|
Map { w, h, t, core, portals, legacy_portal: None, gate, treasures, explored: Vec::new(), visible: Vec::new(), sky: Vec::new(), upgrades: Vec::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Carves a meandering river; the player's area, the core and portals stay dry.
|
// Carves a meandering river; the player's area, the core and portals stay dry.
|
||||||
@@ -724,6 +795,8 @@ impl Map {
|
|||||||
Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond => Some(12),
|
Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond => Some(12),
|
||||||
Kind::Rock | Kind::Core | Kind::Tower | Kind::Water | Kind::Lava => None,
|
Kind::Rock | Kind::Core | Kind::Tower | Kind::Water | Kind::Lava => None,
|
||||||
Kind::Door => Some(10),
|
Kind::Door => Some(10),
|
||||||
|
Kind::Mushroom | Kind::Tree => Some(6),
|
||||||
|
Kind::Drawbridge if tl.res & DB_RAISED != 0 => None,
|
||||||
_ => Some(1),
|
_ => Some(1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -808,7 +881,7 @@ fn tile_record(t: &Tile) -> [u8; REC] {
|
|||||||
let mut r = [0u8; REC];
|
let mut r = [0u8; REC];
|
||||||
r[0] = KINDS.iter().position(|&k| k == t.kind).unwrap_or(0) as u8;
|
r[0] = KINDS.iter().position(|&k| k == t.kind).unwrap_or(0) as u8;
|
||||||
r[1] = OWNERS.iter().position(|&o| o == t.owner).unwrap_or(0) as u8;
|
r[1] = OWNERS.iter().position(|&o| o == t.owner).unwrap_or(0) as u8;
|
||||||
r[2] = t.marked as u8 | (t.reinforced as u8) << 1 | (t.res & 3) << 2;
|
r[2] = t.marked as u8 | (t.reinforced as u8) << 1 | (t.res & 3) << 2 | (t.mark_by & 7) << 4;
|
||||||
r[3..7].copy_from_slice(&t.hp.to_le_bytes());
|
r[3..7].copy_from_slice(&t.hp.to_le_bytes());
|
||||||
r[7..11].copy_from_slice(&t.gold.to_le_bytes());
|
r[7..11].copy_from_slice(&t.gold.to_le_bytes());
|
||||||
r[11..15].copy_from_slice(&t.food.to_le_bytes());
|
r[11..15].copy_from_slice(&t.food.to_le_bytes());
|
||||||
@@ -873,6 +946,7 @@ fn tiles_de<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<Tile>, D::Error> {
|
|||||||
marked: r[2] & 1 != 0,
|
marked: r[2] & 1 != 0,
|
||||||
reinforced: r[2] & 2 != 0,
|
reinforced: r[2] & 2 != 0,
|
||||||
res: (r[2] >> 2) & 3,
|
res: (r[2] >> 2) & 3,
|
||||||
|
mark_by: (r[2] >> 4) & 7,
|
||||||
hp: f32::from_le_bytes(f(3)),
|
hp: f32::from_le_bytes(f(3)),
|
||||||
gold: i32::from_le_bytes(f(7)),
|
gold: i32::from_le_bytes(f(7)),
|
||||||
food: f32::from_le_bytes(f(11)),
|
food: f32::from_le_bytes(f(11)),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use crate::settings::{all_actions, key_name, Action, Settings};
|
|||||||
use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, side_color, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT};
|
use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, side_color, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT};
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
|
|
||||||
|
mod achievements;
|
||||||
mod bestiary;
|
mod bestiary;
|
||||||
pub mod flags;
|
pub mod flags;
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ pub enum Page {
|
|||||||
Account, // password, logout, users (admin)
|
Account, // password, logout, users (admin)
|
||||||
Match { from_title: bool }, // multiplayer lobby and match status
|
Match { from_title: bool }, // multiplayer lobby and match status
|
||||||
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
|
||||||
|
Achievements { from_title: bool },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const TABS: [&str; 6] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.debug", "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"];
|
||||||
@@ -62,17 +64,19 @@ pub enum Item {
|
|||||||
DebugPathLines,
|
DebugPathLines,
|
||||||
GameSpeed,
|
GameSpeed,
|
||||||
Hints,
|
Hints,
|
||||||
|
Events,
|
||||||
TouchUi,
|
TouchUi,
|
||||||
Voice,
|
Voice,
|
||||||
|
Track,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tab_items(tab: usize) -> &'static [Item] {
|
fn tab_items(tab: usize) -> &'static [Item] {
|
||||||
use Item::*;
|
use Item::*;
|
||||||
match tab {
|
match tab {
|
||||||
0 => &[Language, Volume, MusicVolume, Mute, Voice],
|
0 => &[Language, Volume, MusicVolume, Track, 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],
|
3 => &[GameSpeed, Hints, Events, TouchUi],
|
||||||
4 => &[DebugFps, DebugFrameTime, DebugPing, DebugTriggerZones, DebugCommandMarkers, DebugPathLines],
|
4 => &[DebugFps, DebugFrameTime, DebugPing, DebugTriggerZones, DebugCommandMarkers, DebugPathLines],
|
||||||
_ => &[],
|
_ => &[],
|
||||||
}
|
}
|
||||||
@@ -102,8 +106,10 @@ fn item_label(it: Item) -> String {
|
|||||||
Item::DebugPathLines => "set.debug_path_lines",
|
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::Events => "set.events",
|
||||||
Item::TouchUi => "set.touch_ui",
|
Item::TouchUi => "set.touch_ui",
|
||||||
Item::Voice => "set.voice",
|
Item::Voice => "set.voice",
|
||||||
|
Item::Track => "set.track",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +128,7 @@ fn item_value(it: Item, s: &Settings) -> String {
|
|||||||
Item::MusicVolume => pct(s.music_volume),
|
Item::MusicVolume => pct(s.music_volume),
|
||||||
Item::Mute => onoff(s.muted),
|
Item::Mute => onoff(s.muted),
|
||||||
Item::Voice => onoff(s.voice),
|
Item::Voice => onoff(s.voice),
|
||||||
|
Item::Track => crate::audio::track_by_id(&s.music_track).map_or_else(|| tr("val.track_auto"), crate::audio::track_title),
|
||||||
Item::RotateSpeed => pct(s.rotate_speed),
|
Item::RotateSpeed => pct(s.rotate_speed),
|
||||||
Item::PanSpeed => pct(s.pan_speed),
|
Item::PanSpeed => pct(s.pan_speed),
|
||||||
Item::ZoomSpeed => pct(s.zoom_speed),
|
Item::ZoomSpeed => pct(s.zoom_speed),
|
||||||
@@ -134,30 +141,22 @@ fn item_value(it: Item, s: &Settings) -> String {
|
|||||||
_ => "val.medium",
|
_ => "val.medium",
|
||||||
}),
|
}),
|
||||||
Item::Lights => s.max_lights.to_string(),
|
Item::Lights => s.max_lights.to_string(),
|
||||||
Item::UiScale => {
|
Item::UiScale => tr(match s.ui_factor {
|
||||||
if s.ui_factor == 0 {
|
2 => "val.large",
|
||||||
trf("val.auto", &[crate::ui::F().to_string()])
|
3 => "val.medium",
|
||||||
} else {
|
4 => "val.small",
|
||||||
format!("{}x", s.ui_factor)
|
_ => "val.auto_plain",
|
||||||
}
|
}),
|
||||||
}
|
|
||||||
Item::DebugFps => onoff(s.debug_fps),
|
Item::DebugFps => onoff(s.debug_fps),
|
||||||
Item::DebugFrameTime => onoff(s.debug_frame_time),
|
Item::DebugFrameTime => onoff(s.debug_frame_time),
|
||||||
Item::DebugPing => onoff(s.debug_ping),
|
Item::DebugPing => onoff(s.debug_ping),
|
||||||
Item::DebugTriggerZones => onoff(s.debug_trigger_zones),
|
Item::DebugTriggerZones => onoff(s.debug_trigger_zones),
|
||||||
Item::DebugCommandMarkers => onoff(s.debug_command_markers),
|
Item::DebugCommandMarkers => onoff(s.debug_command_markers),
|
||||||
Item::DebugPathLines => onoff(s.debug_path_lines),
|
Item::DebugPathLines => onoff(s.debug_path_lines),
|
||||||
Item::RenderScale => {
|
Item::RenderScale => crate::i18n::trf("set.pixel_rows_val", &[s.pixel_rows.to_string()]),
|
||||||
// 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());
|
|
||||||
if now as u8 == s.render_scale.max(1) {
|
|
||||||
format!("1/{}", s.render_scale.max(1))
|
|
||||||
} else {
|
|
||||||
format!("1/{} (1/{})", s.render_scale.max(1), now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Item::GameSpeed => format!("{}x", s.game_speed as i32),
|
Item::GameSpeed => format!("{}x", s.game_speed as i32),
|
||||||
Item::Hints => onoff(s.hints),
|
Item::Hints => onoff(s.hints),
|
||||||
|
Item::Events => onoff(s.events),
|
||||||
Item::TouchUi => match s.touch_ui {
|
Item::TouchUi => match s.touch_ui {
|
||||||
1 => tr("val.on"),
|
1 => tr("val.on"),
|
||||||
2 => tr("val.off"),
|
2 => tr("val.off"),
|
||||||
@@ -187,6 +186,13 @@ fn item_change(it: Item, s: &mut Settings, d: i32) {
|
|||||||
Item::MusicVolume => s.music_volume = step(s.music_volume, d, 0.0, 1.0, 0.1),
|
Item::MusicVolume => s.music_volume = step(s.music_volume, d, 0.0, 1.0, 0.1),
|
||||||
Item::Mute => s.muted = !s.muted,
|
Item::Mute => s.muted = !s.muted,
|
||||||
Item::Voice => s.voice = !s.voice,
|
Item::Voice => s.voice = !s.voice,
|
||||||
|
Item::Track => {
|
||||||
|
// automatic, then every calm track
|
||||||
|
let mut opts: Vec<String> = vec![String::new()];
|
||||||
|
opts.extend(crate::audio::player_tracks().into_iter().map(|t| crate::audio::track_id(t).to_string()));
|
||||||
|
let i = opts.iter().position(|o| *o == s.music_track).unwrap_or(0) as i32;
|
||||||
|
s.music_track = opts[(i + d).rem_euclid(opts.len() as i32) as usize].clone();
|
||||||
|
}
|
||||||
Item::RotateSpeed => s.rotate_speed = step(s.rotate_speed, d, 0.25, 2.5, 0.25),
|
Item::RotateSpeed => s.rotate_speed = step(s.rotate_speed, d, 0.25, 2.5, 0.25),
|
||||||
Item::PanSpeed => s.pan_speed = step(s.pan_speed, d, 0.25, 2.5, 0.25),
|
Item::PanSpeed => s.pan_speed = step(s.pan_speed, d, 0.25, 2.5, 0.25),
|
||||||
Item::ZoomSpeed => s.zoom_speed = step(s.zoom_speed, d, 0.25, 2.5, 0.25),
|
Item::ZoomSpeed => s.zoom_speed = step(s.zoom_speed, d, 0.25, 2.5, 0.25),
|
||||||
@@ -195,8 +201,8 @@ fn item_change(it: Item, s: &mut Settings, d: i32) {
|
|||||||
Item::Outlines => s.outlines = !s.outlines,
|
Item::Outlines => s.outlines = !s.outlines,
|
||||||
Item::WallDetail => s.wall_detail = cycle(&[1u8, 2, 3], s.wall_detail, d),
|
Item::WallDetail => s.wall_detail = cycle(&[1u8, 2, 3], s.wall_detail, d),
|
||||||
Item::Lights => s.max_lights = cycle(&[8u8, 16, 24], s.max_lights, d),
|
Item::Lights => s.max_lights = cycle(&[8u8, 16, 24], s.max_lights, d),
|
||||||
Item::UiScale => s.ui_factor = cycle(&[0u8, 1, 2, 3, 4], s.ui_factor, d),
|
Item::UiScale => s.ui_factor = cycle(&[0u8, 2, 3, 4], if [0, 2, 3, 4].contains(&s.ui_factor) { s.ui_factor } else { 0 }, d),
|
||||||
Item::RenderScale => s.render_scale = cycle(&(1u8..=16).collect::<Vec<u8>>(), s.render_scale.clamp(1, 16), d),
|
Item::RenderScale => s.pixel_rows = cycle(&[270u16, 360, 480], if [270, 360, 480].contains(&s.pixel_rows) { s.pixel_rows } else { 360 }, 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::DebugFrameTime => s.debug_frame_time = !s.debug_frame_time,
|
||||||
Item::DebugPing => s.debug_ping = !s.debug_ping,
|
Item::DebugPing => s.debug_ping = !s.debug_ping,
|
||||||
@@ -205,6 +211,7 @@ fn item_change(it: Item, s: &mut Settings, d: i32) {
|
|||||||
Item::DebugPathLines => s.debug_path_lines = !s.debug_path_lines,
|
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::Events => s.events = !s.events,
|
||||||
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +237,8 @@ enum Btn {
|
|||||||
Tab(usize),
|
Tab(usize),
|
||||||
Change(usize, i32),
|
Change(usize, i32),
|
||||||
MapW(i32),
|
MapW(i32),
|
||||||
|
MatchSize(i32),
|
||||||
|
Land,
|
||||||
MapH(i32),
|
MapH(i32),
|
||||||
Difficulty(i32),
|
Difficulty(i32),
|
||||||
Start,
|
Start,
|
||||||
@@ -279,8 +288,13 @@ enum Btn {
|
|||||||
MatchCoopNext,
|
MatchCoopNext,
|
||||||
MatchCoopCreate,
|
MatchCoopCreate,
|
||||||
MatchCoopSource, // switch between the built-in campaign and the editor campaign
|
MatchCoopSource, // switch between the built-in campaign and the editor campaign
|
||||||
|
MatchCoopSolo, // the selected co-op mission without the server, with an AI partner
|
||||||
|
MatchCoopResume, // go on with the interrupted co-op run of the selected campaign
|
||||||
MatchTake(usize),
|
MatchTake(usize),
|
||||||
MatchAi(usize),
|
MatchAi(usize),
|
||||||
|
MatchTeam(usize), // next team for that seat
|
||||||
|
GoldTo, // pause menu: the next ally to receive gold
|
||||||
|
Gold(u32), // pause menu: send that much gold (GOLD_ALL: everything)
|
||||||
MatchReady,
|
MatchReady,
|
||||||
MatchStart,
|
MatchStart,
|
||||||
MatchResume,
|
MatchResume,
|
||||||
@@ -288,6 +302,9 @@ enum Btn {
|
|||||||
MatchLeave,
|
MatchLeave,
|
||||||
MatchChat,
|
MatchChat,
|
||||||
Bestiary,
|
Bestiary,
|
||||||
|
Achievements,
|
||||||
|
AchHall, // achievements page: switch to the hall of the fallen and back
|
||||||
|
Tutorial,
|
||||||
BestTab(usize),
|
BestTab(usize),
|
||||||
BestSel(i32),
|
BestSel(i32),
|
||||||
BestPick(usize),
|
BestPick(usize),
|
||||||
@@ -379,6 +396,7 @@ pub enum MenuAction {
|
|||||||
CampaignStart(usize),
|
CampaignStart(usize),
|
||||||
OpenRepo,
|
OpenRepo,
|
||||||
OpenClaudeCode,
|
OpenClaudeCode,
|
||||||
|
Tutorial, // start the tutorial mission
|
||||||
BackToEditor,
|
BackToEditor,
|
||||||
OpenOpenCode,
|
OpenOpenCode,
|
||||||
Login(String, String),
|
Login(String, String),
|
||||||
@@ -394,8 +412,12 @@ pub enum MenuAction {
|
|||||||
MatchCreate,
|
MatchCreate,
|
||||||
MatchCoopCreate(usize),
|
MatchCoopCreate(usize),
|
||||||
MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc`
|
MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc`
|
||||||
|
MatchCoopSolo(Option<crate::campaign_doc::CampaignDoc>, usize), // offline with an AI partner: editor campaign or built-in, mission
|
||||||
|
MatchCoopResume(crate::campaign_doc::CampaignDoc), // an interrupted run of this campaign goes on
|
||||||
MatchTakeSide(u8),
|
MatchTakeSide(u8),
|
||||||
MatchSetAi(u8, bool),
|
MatchSetAi(u8, bool),
|
||||||
|
MatchSetTeam(u8, u8, bool), // side, team, the own seat
|
||||||
|
SendGold(u8, u32), // ally side, amount
|
||||||
MatchReady(bool),
|
MatchReady(bool),
|
||||||
MatchStart,
|
MatchStart,
|
||||||
MatchResume,
|
MatchResume,
|
||||||
@@ -416,9 +438,14 @@ pub struct Menu {
|
|||||||
pub error: Option<String>, // message on the login and account pages
|
pub error: Option<String>, // message on the login and account pages
|
||||||
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 coop_editor: bool, // the Match page offers the editor campaign instead of the built-in one
|
match_size: usize, // the versus map size (from the settings, for drawing)
|
||||||
|
pub coop_editor: bool, // the Match page offers a campaign document instead of the built-in missions
|
||||||
|
pub coop_league: bool, // ... and that document is the co-op campaign "League against the Lightwatch"
|
||||||
|
league_doc: Option<CampaignDoc>, // the co-op campaign, built the first time it is chosen
|
||||||
pub coop_editor_mission: usize, // mission of `campaign_doc` selected for co-op
|
pub coop_editor_mission: usize, // mission of `campaign_doc` selected for co-op
|
||||||
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 gold_allies: Vec<u8>, // allies of the local master that can get gold (pause menu)
|
||||||
|
pub gold_to: usize, // the one chosen among them
|
||||||
pub editor_test: bool, // a mission of the editor is being tested: back to the editor
|
pub editor_test: bool, // a mission of the editor is being tested: back to the editor
|
||||||
pub custom_briefing: (String, String), // title and text of an editor mission's briefing
|
pub custom_briefing: (String, String), // title and text of an editor mission's briefing
|
||||||
pub campaign_doc: CampaignDoc,
|
pub campaign_doc: CampaignDoc,
|
||||||
@@ -427,6 +454,8 @@ pub struct Menu {
|
|||||||
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,
|
||||||
|
pub profile: crate::achievements::Profile, // shown on the achievements page
|
||||||
|
ach_hall: bool, // the achievements page shows the hall of the fallen
|
||||||
buttons: Vec<(Rect, Btn)>,
|
buttons: Vec<(Rect, Btn)>,
|
||||||
lang_open: Option<Rect>, // the language dropdown is open below/above this field
|
lang_open: Option<Rect>, // the language dropdown is open below/above this field
|
||||||
lang_scroll: usize, // first language shown in the open dropdown
|
lang_scroll: usize, // first language shown in the open dropdown
|
||||||
@@ -438,8 +467,24 @@ pub struct Menu {
|
|||||||
fn button(gfx: &Gfx, r: Rect, label: &str, hot: bool, accent: Color) {
|
fn button(gfx: &Gfx, r: Rect, label: &str, hot: bool, accent: Color) {
|
||||||
draw_rectangle(r.x, r.y, r.w, r.h, if hot { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.13, 0.09, 0.08, 1.0) });
|
draw_rectangle(r.x, r.y, r.w, r.h, if hot { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.13, 0.09, 0.08, 1.0) });
|
||||||
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else { accent });
|
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else { accent });
|
||||||
let s = S();
|
// a label too wide for the button gets the small font, and is cut short if that is not enough
|
||||||
gfx.text(label, r.x + (r.w - gfx.measure(label, s)) * 0.5, r.y + (r.h - GH * s) * 0.5, s, if hot { FRAME_HI } else { TXT });
|
let mut s = S();
|
||||||
|
let room = r.w - 8.0;
|
||||||
|
let mut text = label.to_string();
|
||||||
|
if gfx.measure(&text, s) > room {
|
||||||
|
s = SMALL();
|
||||||
|
while gfx.measure(&text, s) > room && text.chars().count() > 2 {
|
||||||
|
text.pop();
|
||||||
|
while text.ends_with(' ') {
|
||||||
|
text.pop();
|
||||||
|
}
|
||||||
|
if gfx.measure(&format!("{text}…"), s) <= room {
|
||||||
|
text.push('…');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gfx.text(&text, r.x + (r.w - gfx.measure(&text, s)) * 0.5, r.y + (r.h - GH * s) * 0.5, s, if hot { FRAME_HI } else { TXT });
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Menu {
|
impl Menu {
|
||||||
@@ -454,10 +499,16 @@ impl Menu {
|
|||||||
focus: 0,
|
focus: 0,
|
||||||
error: None,
|
error: None,
|
||||||
match_ui: MatchUi::default(),
|
match_ui: MatchUi::default(),
|
||||||
|
ach_hall: false,
|
||||||
coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0),
|
coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0),
|
||||||
|
match_size: 128,
|
||||||
coop_editor: false,
|
coop_editor: false,
|
||||||
|
coop_league: false,
|
||||||
|
league_doc: None,
|
||||||
coop_editor_mission: 0,
|
coop_editor_mission: 0,
|
||||||
in_match: false,
|
in_match: false,
|
||||||
|
gold_allies: Vec::new(),
|
||||||
|
gold_to: 0,
|
||||||
editor_test: false,
|
editor_test: false,
|
||||||
custom_briefing: (String::new(), String::new()),
|
custom_briefing: (String::new(), String::new()),
|
||||||
campaign_doc: CampaignDoc::default(),
|
campaign_doc: CampaignDoc::default(),
|
||||||
@@ -471,6 +522,7 @@ impl Menu {
|
|||||||
lang_rows: 0,
|
lang_rows: 0,
|
||||||
cur_lang: String::new(),
|
cur_lang: String::new(),
|
||||||
rows_visible: 1,
|
rows_visible: 1,
|
||||||
|
profile: Default::default(),
|
||||||
buttons: Vec::new(),
|
buttons: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,6 +548,7 @@ impl Menu {
|
|||||||
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 }),
|
||||||
Some(Page::Bestiary { from_title: true, .. }) => Some(Page::Title),
|
Some(Page::Bestiary { from_title: true, .. }) => Some(Page::Title),
|
||||||
Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause),
|
Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause),
|
||||||
|
Some(Page::Achievements { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }),
|
||||||
Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause),
|
Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause),
|
||||||
Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign),
|
Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign),
|
||||||
Some(Page::CustomBriefing) => None,
|
Some(Page::CustomBriefing) => None,
|
||||||
@@ -634,6 +687,12 @@ impl Menu {
|
|||||||
MenuAction::Consumed
|
MenuAction::Consumed
|
||||||
}
|
}
|
||||||
Some(Btn::Start) => MenuAction::NewGame,
|
Some(Btn::Start) => MenuAction::NewGame,
|
||||||
|
Some(Btn::MatchSize(d)) => {
|
||||||
|
let sizes = crate::lobby::MATCH_SIZES;
|
||||||
|
let k = sizes.iter().position(|&s| s >= settings.match_size).unwrap_or(2) as i32;
|
||||||
|
settings.match_size = sizes[(k + d).clamp(0, sizes.len() as i32 - 1) as usize];
|
||||||
|
MenuAction::SettingsChanged
|
||||||
|
}
|
||||||
Some(Btn::MapW(d)) => {
|
Some(Btn::MapW(d)) => {
|
||||||
settings.map_w = (settings.map_w as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize;
|
settings.map_w = (settings.map_w as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize;
|
||||||
MenuAction::SettingsChanged
|
MenuAction::SettingsChanged
|
||||||
@@ -642,6 +701,10 @@ impl Menu {
|
|||||||
settings.difficulty = (settings.difficulty as i32 + d).rem_euclid(3) as u8;
|
settings.difficulty = (settings.difficulty as i32 + d).rem_euclid(3) as u8;
|
||||||
MenuAction::SettingsChanged
|
MenuAction::SettingsChanged
|
||||||
}
|
}
|
||||||
|
Some(Btn::Land) => {
|
||||||
|
settings.surface = !settings.surface;
|
||||||
|
MenuAction::SettingsChanged
|
||||||
|
}
|
||||||
Some(Btn::MapH(d)) => {
|
Some(Btn::MapH(d)) => {
|
||||||
settings.map_h = (settings.map_h as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize;
|
settings.map_h = (settings.map_h as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize;
|
||||||
MenuAction::SettingsChanged
|
MenuAction::SettingsChanged
|
||||||
@@ -755,7 +818,7 @@ impl Menu {
|
|||||||
Some(b @ (Btn::MatchCoopPrev | Btn::MatchCoopNext)) => {
|
Some(b @ (Btn::MatchCoopPrev | Btn::MatchCoopNext)) => {
|
||||||
let dir = if b == Btn::MatchCoopPrev { -1 } else { 1 };
|
let dir = if b == Btn::MatchCoopPrev { -1 } else { 1 };
|
||||||
if self.coop_editor {
|
if self.coop_editor {
|
||||||
self.coop_editor_mission = crate::campaign::next_editor_coop_mission(&self.campaign_doc, self.coop_editor_mission, dir);
|
self.coop_editor_mission = crate::campaign::next_editor_coop_mission(self.coop_doc(), self.coop_editor_mission, dir);
|
||||||
} else {
|
} else {
|
||||||
self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, dir);
|
self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, dir);
|
||||||
}
|
}
|
||||||
@@ -763,11 +826,31 @@ impl Menu {
|
|||||||
MenuAction::Consumed
|
MenuAction::Consumed
|
||||||
}
|
}
|
||||||
Some(Btn::MatchCoopSource) => {
|
Some(Btn::MatchCoopSource) => {
|
||||||
self.coop_editor = !self.coop_editor && !self.campaign_doc.missions.is_empty();
|
// built-in missions -> the league -> the editor's campaign (if it has missions) -> ...
|
||||||
self.coop_editor_mission = crate::campaign::next_editor_coop_mission(&self.campaign_doc, self.coop_editor_mission, 0);
|
match (self.coop_editor, self.coop_league) {
|
||||||
|
(false, _) => {
|
||||||
|
if self.league_doc.is_none() {
|
||||||
|
self.league_doc = Some(crate::editor::coop_campaign::coop_campaign());
|
||||||
|
}
|
||||||
|
self.coop_editor = true;
|
||||||
|
self.coop_league = true;
|
||||||
|
}
|
||||||
|
(true, true) if !self.campaign_doc.missions.is_empty() => self.coop_league = false,
|
||||||
|
_ => {
|
||||||
|
self.coop_editor = false;
|
||||||
|
self.coop_league = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.coop_editor_mission = 0;
|
||||||
|
if self.coop_editor {
|
||||||
|
self.coop_editor_mission = crate::campaign::next_editor_coop_mission(self.coop_doc(), 0, 0);
|
||||||
|
}
|
||||||
self.confirm = None;
|
self.confirm = None;
|
||||||
MenuAction::Consumed
|
MenuAction::Consumed
|
||||||
}
|
}
|
||||||
|
Some(Btn::MatchCoopResume) => MenuAction::MatchCoopResume(self.coop_doc().clone()),
|
||||||
|
Some(Btn::MatchCoopSolo) if self.coop_editor => MenuAction::MatchCoopSolo(Some(self.coop_doc().clone()), self.coop_editor_mission),
|
||||||
|
Some(Btn::MatchCoopSolo) => MenuAction::MatchCoopSolo(None, self.coop_mission),
|
||||||
Some(Btn::MatchCoopCreate) if self.coop_editor => MenuAction::MatchCoopEditorCreate(self.coop_editor_mission),
|
Some(Btn::MatchCoopCreate) if self.coop_editor => MenuAction::MatchCoopEditorCreate(self.coop_editor_mission),
|
||||||
Some(Btn::MatchCoopCreate) => MenuAction::MatchCoopCreate(self.coop_mission),
|
Some(Btn::MatchCoopCreate) => MenuAction::MatchCoopCreate(self.coop_mission),
|
||||||
Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready),
|
Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready),
|
||||||
@@ -791,10 +874,23 @@ impl Menu {
|
|||||||
Some(s) => MenuAction::MatchTakeSide(s.side),
|
Some(s) => MenuAction::MatchTakeSide(s.side),
|
||||||
None => MenuAction::Consumed,
|
None => MenuAction::Consumed,
|
||||||
},
|
},
|
||||||
|
Some(Btn::MatchTeam(i)) => match self.match_ui.seats.get(i) {
|
||||||
|
Some(s) => MenuAction::MatchSetTeam(s.side, (s.team + 1) % 4, Some(s.side) == self.match_ui.local_side),
|
||||||
|
None => MenuAction::Consumed,
|
||||||
|
},
|
||||||
|
Some(Btn::GoldTo) => {
|
||||||
|
self.gold_to = (self.gold_to + 1) % self.gold_allies.len().max(1);
|
||||||
|
MenuAction::Consumed
|
||||||
|
}
|
||||||
|
Some(Btn::Gold(amount)) => match self.gold_allies.get(self.gold_to.min(self.gold_allies.len().saturating_sub(1))) {
|
||||||
|
Some(&to) => MenuAction::SendGold(to, amount),
|
||||||
|
None => MenuAction::Consumed,
|
||||||
|
},
|
||||||
Some(Btn::MatchAi(i)) => match self.match_ui.seats.get(i) {
|
Some(Btn::MatchAi(i)) => match self.match_ui.seats.get(i) {
|
||||||
Some(s) if s.side != 0 => MenuAction::MatchSetAi(s.side, !s.ai),
|
Some(s) if s.side != 0 => MenuAction::MatchSetAi(s.side, !s.ai),
|
||||||
_ => MenuAction::Consumed,
|
_ => MenuAction::Consumed,
|
||||||
},
|
},
|
||||||
|
Some(Btn::Tutorial) => MenuAction::Tutorial,
|
||||||
Some(Btn::Campaign) => {
|
Some(Btn::Campaign) => {
|
||||||
self.page = Some(Page::Campaign);
|
self.page = Some(Page::Campaign);
|
||||||
self.scroll = 0;
|
self.scroll = 0;
|
||||||
@@ -846,6 +942,16 @@ impl Menu {
|
|||||||
}
|
}
|
||||||
MenuAction::Consumed
|
MenuAction::Consumed
|
||||||
}
|
}
|
||||||
|
Some(Btn::AchHall) => {
|
||||||
|
self.ach_hall = !self.ach_hall;
|
||||||
|
self.scroll = 0;
|
||||||
|
MenuAction::Consumed
|
||||||
|
}
|
||||||
|
Some(Btn::Achievements) => {
|
||||||
|
self.page = Some(Page::Achievements { from_title });
|
||||||
|
self.scroll = 0;
|
||||||
|
MenuAction::Consumed
|
||||||
|
}
|
||||||
Some(Btn::Bestiary) => {
|
Some(Btn::Bestiary) => {
|
||||||
self.open_bestiary(from_title);
|
self.open_bestiary(from_title);
|
||||||
MenuAction::Consumed
|
MenuAction::Consumed
|
||||||
@@ -863,6 +969,7 @@ impl Menu {
|
|||||||
self.buttons.clear();
|
self.buttons.clear();
|
||||||
self.lang_opts.clear();
|
self.lang_opts.clear();
|
||||||
self.cur_lang = settings.language.clone();
|
self.cur_lang = settings.language.clone();
|
||||||
|
self.match_size = settings.match_size;
|
||||||
let Some(page) = self.page else {
|
let Some(page) = self.page else {
|
||||||
self.lang_open = None;
|
self.lang_open = None;
|
||||||
return;
|
return;
|
||||||
@@ -884,6 +991,7 @@ impl Menu {
|
|||||||
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
|
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
|
||||||
Page::CustomBriefing => self.draw_custom_briefing(gfx),
|
Page::CustomBriefing => self.draw_custom_briefing(gfx),
|
||||||
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::Achievements { .. } => self.draw_achievements(gfx),
|
||||||
Page::KeyCapture { action, .. } => {
|
Page::KeyCapture { action, .. } => {
|
||||||
self.draw_settings(gfx, settings, 5);
|
self.draw_settings(gfx, settings, 5);
|
||||||
self.buttons.clear();
|
self.buttons.clear();
|
||||||
@@ -918,9 +1026,13 @@ impl Menu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn column(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, items: &[(Btn, String)]) {
|
fn column(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, items: &[(Btn, String)]) {
|
||||||
|
self.column_pitch(gfx, x, y, w, items, CH() * 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn column_pitch(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, items: &[(Btn, String)], pitch: f32) {
|
||||||
let (mx, my) = mouse();
|
let (mx, my) = mouse();
|
||||||
for (k, (b, label)) in items.iter().enumerate() {
|
for (k, (b, label)) in items.iter().enumerate() {
|
||||||
let r = Rect::new(x, y + k as f32 * CH() * 3.0, w, CH() * 2.0);
|
let r = Rect::new(x, y + k as f32 * pitch, w, CH() * 2.0);
|
||||||
button(gfx, r, label, r.contains(vec2(mx, my)), FRAME);
|
button(gfx, r, label, r.contains(vec2(mx, my)), FRAME);
|
||||||
self.buttons.push((r, *b));
|
self.buttons.push((r, *b));
|
||||||
}
|
}
|
||||||
@@ -1018,6 +1130,7 @@ 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::Tutorial, tr("menu.tutorial")));
|
||||||
items.push((Btn::CampaignEditor, tr("menu.campaign_editor")));
|
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")));
|
||||||
@@ -1028,10 +1141,25 @@ impl Menu {
|
|||||||
}
|
}
|
||||||
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::Achievements, tr("menu.achievements")));
|
||||||
items.push((Btn::Help, tr("menu.help")));
|
items.push((Btn::Help, tr("menu.help")));
|
||||||
let w = 26.0 * CW();
|
let w = 26.0 * CW();
|
||||||
let y = ty + GH * big + CH() * 4.0;
|
let y = ty + GH * big + CH() * 4.0;
|
||||||
|
// the buttons end above the hints and credits; on a short screen they move closer
|
||||||
|
// together or into two columns
|
||||||
|
let bottom = sh - CH() * 9.0;
|
||||||
|
let fits = |pitch: f32| y + items.len() as f32 * pitch <= bottom;
|
||||||
|
if fits(CH() * 3.0) {
|
||||||
self.column(gfx, grid_center(sw, w), y, w, &items);
|
self.column(gfx, grid_center(sw, w), y, w, &items);
|
||||||
|
} else if fits(CH() * 2.5) {
|
||||||
|
self.column_pitch(gfx, grid_center(sw, w), y, w, &items, CH() * 2.5);
|
||||||
|
} else {
|
||||||
|
let half = (items.len() + 1) / 2;
|
||||||
|
let gap = CW() * 2.0;
|
||||||
|
let x0 = grid_center(sw, w * 2.0 + gap);
|
||||||
|
self.column_pitch(gfx, x0, y, w, &items[..half], CH() * 2.5);
|
||||||
|
self.column_pitch(gfx, x0 + w + gap, y, w, &items[half..], CH() * 2.5);
|
||||||
|
}
|
||||||
let hint = tr("menu.drop_hint");
|
let hint = tr("menu.drop_hint");
|
||||||
gfx.text(&hint, grid_center(sw, gfx.measure(&hint, SMALL())), sh - CH() * 8.0, SMALL(), DIM);
|
gfx.text(&hint, grid_center(sw, gfx.measure(&hint, SMALL())), sh - CH() * 8.0, SMALL(), DIM);
|
||||||
let credit = tr("help.font");
|
let credit = tr("help.font");
|
||||||
@@ -1088,27 +1216,30 @@ impl Menu {
|
|||||||
let sh = sh();
|
let sh = sh();
|
||||||
let (mx, my) = mouse();
|
let (mx, my) = mouse();
|
||||||
let cols = 48.min((sw / CW()) as i32);
|
let cols = 48.min((sw / CW()) as i32);
|
||||||
let rows = 22;
|
let rows = 25;
|
||||||
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
|
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);
|
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("menu.new"), FRAME_HI);
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.new"), FRAME_HI);
|
||||||
let diff = tr(&format!("diff.{}", s.difficulty.min(2)));
|
let diff = tr(&format!("diff.{}", s.difficulty.min(2)));
|
||||||
let lines = [("newgame.width", s.map_w.to_string(), 0), ("newgame.height", s.map_h.to_string(), 1), ("newgame.difficulty", diff, 2)];
|
let land = tr(if s.surface { "newgame.land1" } else { "newgame.land0" });
|
||||||
|
let lines = [("newgame.width", s.map_w.to_string(), 0), ("newgame.height", s.map_h.to_string(), 1), ("newgame.difficulty", diff, 2), ("newgame.land", land, 3)];
|
||||||
for (k, (key, val, which)) in lines.iter().enumerate() {
|
for (k, (key, val, which)) in lines.iter().enumerate() {
|
||||||
let (key, which) = (*key, *which);
|
let (key, which) = (*key, *which);
|
||||||
let by = y + CH() * (2.0 + k as f32 * 2.5);
|
let by = y + CH() * (2.0 + k as f32 * 2.5);
|
||||||
gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.5, S(), TXT);
|
gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.5, S(), TXT);
|
||||||
let vw = 14.0 * CW();
|
let vw = 18.0 * CW();
|
||||||
let vx = x + cols as f32 * CW() - vw - CW() * 3.0;
|
let vx = x + cols as f32 * CW() - vw - CW() * 3.0;
|
||||||
let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0);
|
let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0);
|
||||||
let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0);
|
let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0);
|
||||||
button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME);
|
button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME);
|
||||||
button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME);
|
button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME);
|
||||||
let v = val.clone();
|
let v = val.clone();
|
||||||
gfx.text(&v, vx + (vw - gfx.measure(&v, S())) * 0.5, by + CH() * 0.5, S(), GOLD);
|
let vs = if gfx.measure(&v, S()) > vw - CW() * 5.5 { SMALL() } else { S() };
|
||||||
|
gfx.text(&v, vx + (vw - gfx.measure(&v, vs)) * 0.5, by + CH() * 0.5, vs, GOLD);
|
||||||
let (l, r) = match which {
|
let (l, r) = match which {
|
||||||
0 => (Btn::MapW(-1), Btn::MapW(1)),
|
0 => (Btn::MapW(-1), Btn::MapW(1)),
|
||||||
1 => (Btn::MapH(-1), Btn::MapH(1)),
|
1 => (Btn::MapH(-1), Btn::MapH(1)),
|
||||||
|
3 => (Btn::Land, Btn::Land),
|
||||||
_ => (Btn::Difficulty(-1), Btn::Difficulty(1)),
|
_ => (Btn::Difficulty(-1), Btn::Difficulty(1)),
|
||||||
};
|
};
|
||||||
self.buttons.push((left, l));
|
self.buttons.push((left, l));
|
||||||
@@ -1118,14 +1249,14 @@ impl Menu {
|
|||||||
let area = (s.map_w * s.map_h) as f32 / 4096.0;
|
let area = (s.map_w * s.map_h) as f32 / 4096.0;
|
||||||
let portals = (area.round() as usize).max(1);
|
let portals = (area.round() as usize).max(1);
|
||||||
let info = trf("newgame.info", &[portals.to_string(), (portals - 1).to_string()]);
|
let info = trf("newgame.info", &[portals.to_string(), (portals - 1).to_string()]);
|
||||||
gfx.text(&info, x + CW() * 3.0, y + CH() * 10.0, S(), DIM);
|
gfx.text(&info, x + CW() * 3.0, y + CH() * 12.5, S(), DIM);
|
||||||
let opp = tr(if s.map_w * s.map_h > 128 * 128 { "newgame.opponents2" } else { "newgame.opponents1" });
|
let opp = tr(if s.map_w * s.map_h > 128 * 128 { "newgame.opponents2" } else { "newgame.opponents1" });
|
||||||
let maxc = (cols - 6) as usize;
|
let maxc = (cols - 6) as usize;
|
||||||
for (k, l) in wrap(&opp, maxc).iter().take(2).enumerate() {
|
for (k, l) in wrap(&opp, maxc).iter().take(2).enumerate() {
|
||||||
gfx.text(l, x + CW() * 3.0, y + CH() * (11.0 + k as f32), S(), DIM);
|
gfx.text(l, x + CW() * 3.0, y + CH() * (13.5 + k as f32), S(), DIM);
|
||||||
}
|
}
|
||||||
let w = (cols - 6) as f32 * CW();
|
let w = (cols - 6) as f32 * CW();
|
||||||
self.column(gfx, x + CW() * 3.0, y + CH() * 14.0, w, &[(Btn::Start, tr("newgame.start")), (Btn::Back, tr("menu.back"))]);
|
self.column(gfx, x + CW() * 3.0, y + CH() * 16.5, w, &[(Btn::Start, tr("newgame.start")), (Btn::Back, tr("menu.back"))]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_pause(&mut self, gfx: &Gfx) {
|
fn draw_pause(&mut self, gfx: &Gfx) {
|
||||||
@@ -1142,6 +1273,7 @@ impl Menu {
|
|||||||
items.extend([
|
items.extend([
|
||||||
(Btn::Settings, tr("menu.settings")),
|
(Btn::Settings, tr("menu.settings")),
|
||||||
(Btn::Bestiary, tr("menu.bestiary")),
|
(Btn::Bestiary, tr("menu.bestiary")),
|
||||||
|
(Btn::Achievements, tr("menu.achievements")),
|
||||||
(Btn::Help, tr("menu.help")),
|
(Btn::Help, tr("menu.help")),
|
||||||
(Btn::MainMenu, tr("net.leave")),
|
(Btn::MainMenu, tr("net.leave")),
|
||||||
]);
|
]);
|
||||||
@@ -1152,17 +1284,37 @@ impl Menu {
|
|||||||
(Btn::Match, tr("menu.multiplayer")),
|
(Btn::Match, tr("menu.multiplayer")),
|
||||||
(Btn::Settings, tr("menu.settings")),
|
(Btn::Settings, tr("menu.settings")),
|
||||||
(Btn::Bestiary, tr("menu.bestiary")),
|
(Btn::Bestiary, tr("menu.bestiary")),
|
||||||
|
(Btn::Achievements, tr("menu.achievements")),
|
||||||
(Btn::Help, tr("menu.help")),
|
(Btn::Help, tr("menu.help")),
|
||||||
(Btn::MainMenu, tr("menu.main")),
|
(Btn::MainMenu, tr("menu.main")),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
let cols = 32;
|
// a match with allies: gold for one of them (the ally button switches between them)
|
||||||
let rows = items.len() as i32 * 3 + 4;
|
let gold = self.in_match && !self.gold_allies.is_empty();
|
||||||
|
let cols = if gold { 40 } else { 32 };
|
||||||
|
let rows = items.len() as i32 * 3 + 4 + if gold { 6 } else { 0 };
|
||||||
let x = grid_center(sw, cols as f32 * CW());
|
let x = grid_center(sw, cols as f32 * CW());
|
||||||
let y = (grid_center_y(sh, rows as f32 * CH())).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("menu.paused"), FRAME_HI);
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.paused"), FRAME_HI);
|
||||||
let w = (cols - 6) as f32 * CW();
|
let w = (cols - 6) as f32 * CW();
|
||||||
self.column(gfx, x + CW() * 3.0, y + CH() * 2.0, w, &items);
|
self.column(gfx, x + CW() * 3.0, y + CH() * 2.0, w, &items);
|
||||||
|
if gold {
|
||||||
|
let (mx, my) = mouse();
|
||||||
|
self.gold_to = self.gold_to.min(self.gold_allies.len() - 1);
|
||||||
|
let to = self.gold_allies[self.gold_to];
|
||||||
|
let gy = y + CH() * (2.0 + items.len() as f32 * 3.0);
|
||||||
|
let label = trf("gold.send_to", &[tr(&format!("net.side{to}"))]);
|
||||||
|
let r = Rect::new(x + CW() * 3.0, gy, w, CH() * 2.0);
|
||||||
|
button(gfx, r, &label, r.contains(vec2(mx, my)), side_color(Some(to)));
|
||||||
|
self.buttons.push((r, Btn::GoldTo));
|
||||||
|
let bw = (w - CW() * 3.0) / 4.0;
|
||||||
|
for (k, amount) in crate::netplay::GOLD_AMOUNTS.iter().enumerate() {
|
||||||
|
let label = if *amount == crate::netplay::GOLD_ALL { tr("gold.all") } else { amount.to_string() };
|
||||||
|
let r = Rect::new(x + CW() * 3.0 + k as f32 * (bw + CW()), gy + CH() * 2.6, bw, CH() * 2.0);
|
||||||
|
button(gfx, r, &label, r.contains(vec2(mx, my)), GOLD);
|
||||||
|
self.buttons.push((r, Btn::Gold(*amount)));
|
||||||
|
}
|
||||||
|
}
|
||||||
let ver = tr("menu.version");
|
let ver = tr("menu.version");
|
||||||
let line = format!("{} {}", ver, version());
|
let line = format!("{} {}", ver, version());
|
||||||
gfx.text(&line, x + (cols as f32 * CW() - gfx.measure(&line, SMALL())) * 0.5, y + (rows as f32 - 1.7) * CH(), SMALL(), DIM);
|
gfx.text(&line, x + (cols as f32 * CW() - gfx.measure(&line, SMALL())) * 0.5, y + (rows as f32 - 1.7) * CH(), SMALL(), DIM);
|
||||||
@@ -1677,10 +1829,18 @@ impl Menu {
|
|||||||
self.column(gfx, x + (cols as f32 * CW() - w) * 0.5, y + (rows - 4) as f32 * CH(), w, &[(Btn::Resume, label)]);
|
self.column(gfx, x + (cols as f32 * CW() - w) * 0.5, y + (rows - 4) as f32 * CH(), w, &[(Btn::Resume, label)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the campaign document the co-op create offers: the league or the editor's campaign
|
||||||
|
pub fn coop_doc(&self) -> &CampaignDoc {
|
||||||
|
match (&self.league_doc, self.coop_league) {
|
||||||
|
(Some(d), true) => d,
|
||||||
|
_ => &self.campaign_doc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn scrollable(&self) -> bool {
|
pub fn scrollable(&self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self.page,
|
self.page,
|
||||||
Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }) | Some(Page::CustomBriefing) | Some(Page::Settings { tab: 5, .. }) | Some(Page::Match { .. }) | Some(Page::Account)
|
Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }) | Some(Page::CustomBriefing) | Some(Page::Settings { tab: 5, .. }) | Some(Page::Match { .. }) | Some(Page::Account) | Some(Page::Achievements { .. })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1880,15 +2040,16 @@ impl Menu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// the editor campaign can only be offered while it has missions (it may have been replaced)
|
// the editor campaign can only be offered while it has missions (it may have been replaced)
|
||||||
let editor_coop = self.coop_editor && !self.campaign_doc.missions.is_empty();
|
let doc = self.coop_doc().clone();
|
||||||
let editor_list = if editor_coop { crate::campaign::editor_coop_indexes(&self.campaign_doc) } else { Vec::new() };
|
let editor_coop = self.coop_editor && !doc.missions.is_empty();
|
||||||
|
let editor_list = if editor_coop { crate::campaign::editor_coop_indexes(&doc) } else { Vec::new() };
|
||||||
if editor_coop && !editor_list.contains(&self.coop_editor_mission) {
|
if editor_coop && !editor_list.contains(&self.coop_editor_mission) {
|
||||||
self.coop_editor_mission = editor_list.first().copied().unwrap_or(0);
|
self.coop_editor_mission = editor_list.first().copied().unwrap_or(0);
|
||||||
}
|
}
|
||||||
if !ui.started && !ui.room_open() && editor_coop {
|
if !ui.started && !ui.room_open() && editor_coop {
|
||||||
let line = match self.campaign_doc.missions.get(self.coop_editor_mission).filter(|_| !editor_list.is_empty()) {
|
let line = match doc.missions.get(self.coop_editor_mission).filter(|_| !editor_list.is_empty()) {
|
||||||
Some(m) => {
|
Some(m) => {
|
||||||
let name = if self.campaign_doc.name.trim().is_empty() { "?".to_string() } else { self.campaign_doc.name.clone() };
|
let name = if doc.name.trim().is_empty() { "?".to_string() } else { doc.name.clone() };
|
||||||
let title = if m.title.trim().is_empty() { m.key.clone() } else { m.title.clone() };
|
let title = if m.title.trim().is_empty() { m.key.clone() } else { m.title.clone() };
|
||||||
trf("net.coop_editor_mission", &[name, (self.coop_editor_mission + 1).to_string(), title])
|
trf("net.coop_editor_mission", &[name, (self.coop_editor_mission + 1).to_string(), title])
|
||||||
}
|
}
|
||||||
@@ -1909,6 +2070,19 @@ impl Menu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// a new versus match: the map size and with it the number of seats
|
||||||
|
if !ui.started && !ui.room_open() {
|
||||||
|
let n = self.match_size;
|
||||||
|
let seats = crate::lobby::seats_for(n).len();
|
||||||
|
let r = Rect::new(ix, yy, 4.0 * CW(), CH() * 2.0);
|
||||||
|
button(gfx, r, "<", r.contains(vec2(mx, my)), FRAME);
|
||||||
|
self.buttons.push((r, Btn::MatchSize(-1)));
|
||||||
|
gfx.text(&trf("net.map_size", &[n.to_string(), n.to_string(), seats.to_string()]), ix + 5.0 * CW(), yy + CH() * 0.5, S(), TXT);
|
||||||
|
let r = Rect::new(ix + 30.0 * CW(), yy, 4.0 * CW(), CH() * 2.0);
|
||||||
|
button(gfx, r, ">", r.contains(vec2(mx, my)), FRAME);
|
||||||
|
self.buttons.push((r, Btn::MatchSize(1)));
|
||||||
|
yy += CH() * 2.4;
|
||||||
|
}
|
||||||
self.field(gfx, ix, yy, w, &tr("net.join_hint"), 0, false);
|
self.field(gfx, ix, yy, w, &tr("net.join_hint"), 0, false);
|
||||||
self.field(gfx, ix, yy + CH() * 2.4, w, &tr("net.chat"), 1, false);
|
self.field(gfx, ix, yy + CH() * 2.4, w, &tr("net.chat"), 1, false);
|
||||||
yy += CH() * 4.8;
|
yy += CH() * 4.8;
|
||||||
@@ -1922,11 +2096,22 @@ impl Menu {
|
|||||||
items.push((Btn::MatchCoopPrev, "<".to_string()));
|
items.push((Btn::MatchCoopPrev, "<".to_string()));
|
||||||
items.push((Btn::MatchCoopCreate, tr("net.coop_create")));
|
items.push((Btn::MatchCoopCreate, tr("net.coop_create")));
|
||||||
items.push((Btn::MatchCoopNext, ">".to_string()));
|
items.push((Btn::MatchCoopNext, ">".to_string()));
|
||||||
|
items.push((Btn::MatchCoopSolo, tr("net.coop_solo")));
|
||||||
}
|
}
|
||||||
if !self.campaign_doc.missions.is_empty() {
|
// an interrupted run of the shown campaign can go on (online, or offline with the AI)
|
||||||
items.push((Btn::MatchCoopSource, tr(if editor_coop { "net.coop_builtin" } else { "net.coop_editor" })));
|
if editor_coop {
|
||||||
|
if let Some((m, _, _)) = crate::coop_resume_of(&doc) {
|
||||||
|
items.push((Btn::MatchCoopResume, trf("net.coop_resume", &[(m + 1).to_string()])));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// the button names what comes next
|
||||||
|
let next = match (editor_coop, self.coop_league) {
|
||||||
|
(false, _) => "net.coop_league",
|
||||||
|
(true, true) if !self.campaign_doc.missions.is_empty() => "net.coop_editor",
|
||||||
|
_ => "net.coop_builtin",
|
||||||
|
};
|
||||||
|
items.push((Btn::MatchCoopSource, tr(next)));
|
||||||
|
}
|
||||||
if ui.room_open() {
|
if ui.room_open() {
|
||||||
items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" })));
|
items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" })));
|
||||||
if ui.is_host {
|
if ui.is_host {
|
||||||
@@ -1946,7 +2131,8 @@ impl Menu {
|
|||||||
items.push((Btn::MatchLeave, tr("net.leave")));
|
items.push((Btn::MatchLeave, tr("net.leave")));
|
||||||
}
|
}
|
||||||
items.push((Btn::Back, tr("menu.back")));
|
items.push((Btn::Back, tr("menu.back")));
|
||||||
let bw = 14.0 * CW();
|
// three buttons a row over the whole width
|
||||||
|
let bw = ((w - 2.0 * CW()) / 3.0).max(14.0 * CW());
|
||||||
let shown = items.iter().take(9).count();
|
let shown = items.iter().take(9).count();
|
||||||
for (k, (b, label)) in items.iter().take(shown).enumerate() {
|
for (k, (b, label)) in items.iter().take(shown).enumerate() {
|
||||||
let r = Rect::new(ix + (k % 3) as f32 * (bw + CW()), yy + (k / 3) as f32 * CH() * 2.4, bw, CH() * 2.0);
|
let r = Rect::new(ix + (k % 3) as f32 * (bw + CW()), yy + (k / 3) as f32 * CH() * 2.4, bw, CH() * 2.0);
|
||||||
@@ -1970,6 +2156,21 @@ impl Menu {
|
|||||||
let r = Rect::new(ix, yy, 24.0 * CW(), CH() * 2.0);
|
let r = Rect::new(ix, yy, 24.0 * CW(), CH() * 2.0);
|
||||||
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
|
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
|
||||||
self.buttons.push((r, Btn::MatchJoinRoom(k)));
|
self.buttons.push((r, Btn::MatchJoinRoom(k)));
|
||||||
|
// what the room plays: an editor campaign (name and mission), a co-op mission or versus
|
||||||
|
let what = match (&room.campaign, room.mission) {
|
||||||
|
(Some(name), _) => {
|
||||||
|
let name = if name.trim().is_empty() { "?".to_string() } else { name.clone() };
|
||||||
|
match &room.title {
|
||||||
|
Some(t) if !t.trim().is_empty() => format!("{name}: {t}"),
|
||||||
|
_ => name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, Some(m)) => missions().get(m).map_or_else(|| tr("net.coop_create"), |d| tr(&format!("camp.{}.title", d.key))),
|
||||||
|
(None, None) => tr("net.versus"),
|
||||||
|
};
|
||||||
|
let maxc = ((w - 25.0 * CW()) / (GW * SMALL())).max(4.0) as usize;
|
||||||
|
let what: String = what.chars().take(maxc).collect();
|
||||||
|
gfx.text(&what, ix + 25.0 * CW(), yy + (CH() * 2.0 - GH * SMALL()) * 0.5, SMALL(), if room.campaign.is_some() { GOLD } else { DIM });
|
||||||
yy += CH() * 2.2;
|
yy += CH() * 2.2;
|
||||||
}
|
}
|
||||||
} else if ui.rooms.is_empty() && !ui.started {
|
} else if ui.rooms.is_empty() && !ui.started {
|
||||||
@@ -1999,6 +2200,18 @@ impl Menu {
|
|||||||
let ready = if s.ready || s.ai { "✓" } else { "—" };
|
let ready = if s.ready || s.ai { "✓" } else { "—" };
|
||||||
let line = format!("{} {} {} {} {}", side_label, name, status, ready, if s.side == 0 { tr("net.host") } else { String::new() });
|
let line = format!("{} {} {} {} {}", side_label, name, status, ready, if s.side == 0 { tr("net.host") } else { String::new() });
|
||||||
gfx.text(&line, ix, yy, SMALL(), if s.connected || s.ai { TXT } else { DIM });
|
gfx.text(&line, ix, yy, SMALL(), if s.connected || s.ai { TXT } else { DIM });
|
||||||
|
// versus: every seat chooses its team (the host also for free and AI seats)
|
||||||
|
if !ui.coop {
|
||||||
|
let team = trf("net.team", &[((b'A' + s.team.min(3)) as char).to_string()]);
|
||||||
|
let r = Rect::new(ix + 49.0 * CW(), yy, 10.0 * CW(), CH() * 2.0);
|
||||||
|
let may = !ui.started && (is_local || (ui.is_host && !s.connected));
|
||||||
|
if may {
|
||||||
|
button(gfx, r, &team, r.contains(vec2(mx, my)), FRAME);
|
||||||
|
self.buttons.push((r, Btn::MatchTeam(k)));
|
||||||
|
} else {
|
||||||
|
gfx.text(&team, r.x + CW(), yy + CH() * 0.5, SMALL(), DIM);
|
||||||
|
}
|
||||||
|
}
|
||||||
if !ui.started && ui.local_side.is_some() && !s.connected {
|
if !ui.started && ui.local_side.is_some() && !s.connected {
|
||||||
if ui.is_host && s.side != 0 {
|
if ui.is_host && s.side != 0 {
|
||||||
if s.ai {
|
if s.ai {
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// The achievements page: every achievement with its badge (bronze, silver, gold, ... in the
|
||||||
|
// colour of its tier), name, description and the progress towards it. Hidden ones show "???"
|
||||||
|
// until they are unlocked.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::achievements::{all, texts, TIERS};
|
||||||
|
|
||||||
|
const TIER_COL: [(f32, f32, f32); 5] = [(0.72, 0.45, 0.25), (0.75, 0.78, 0.85), (0.95, 0.78, 0.25), (0.55, 0.9, 1.0), (0.85, 0.5, 1.0)];
|
||||||
|
|
||||||
|
impl Menu {
|
||||||
|
pub(super) fn draw_achievements(&mut self, gfx: &Gfx) {
|
||||||
|
let sw = sw();
|
||||||
|
let sh = sh();
|
||||||
|
let cols = 70.min((sw / CW()) as i32 - 2);
|
||||||
|
let rows = 40.min((sh / CH()) as i32 - 2);
|
||||||
|
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);
|
||||||
|
let title = if self.ach_hall { tr("hall.title") } else { tr("menu.achievements") };
|
||||||
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI);
|
||||||
|
if self.ach_hall {
|
||||||
|
self.draw_hall_page(gfx, x, y, cols, rows);
|
||||||
|
self.ach_buttons(gfx, x, y, rows);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let list = all();
|
||||||
|
let done = list.iter().filter(|a| self.profile.unlocked.contains_key(&a.0)).count();
|
||||||
|
let head = trf("ach.count", &[done.to_string(), list.len().to_string(), crate::achievements::fmt_count(self.profile.total("minutes"))]);
|
||||||
|
gfx.text(&head, x + CW() * 3.0, y + CH() * 1.6, S(), GOLD);
|
||||||
|
let top = y + CH() * 3.5;
|
||||||
|
let bottom = y + (rows - 4) as f32 * CH();
|
||||||
|
let row_h = CH() * 3.2;
|
||||||
|
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
|
||||||
|
self.scroll = self.scroll.min(list.len().saturating_sub(self.rows_visible));
|
||||||
|
let ix = x + CW() * 3.0;
|
||||||
|
let w = (cols - 6) as f32 * CW();
|
||||||
|
let maxc = ((w - CW() * 6.0) / (GW * SMALL())) as usize;
|
||||||
|
for (k, (id, hidden, icon, tier)) in list.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
|
||||||
|
let ry = top + (k - self.scroll) as f32 * row_h;
|
||||||
|
let got = self.profile.unlocked.contains_key(id);
|
||||||
|
// the badge: a frame in the tier's colour around the icon (dark while locked)
|
||||||
|
let (r, g, b) = TIER_COL[(*tier).min(4)];
|
||||||
|
let bc = if got { Color::new(r, g, b, 1.0) } else { Color::new(0.25, 0.22, 0.22, 1.0) };
|
||||||
|
let pic = CH() * 2.6;
|
||||||
|
draw_rectangle(ix, ry, pic, pic, Color::new(0.08, 0.05, 0.05, 1.0));
|
||||||
|
draw_rectangle_lines(ix, ry, pic, pic, 3.0, bc);
|
||||||
|
let tint = if got { WHITE } else { Color::new(0.3, 0.3, 0.3, 1.0) };
|
||||||
|
draw_texture_ex(&gfx.badges, ix + 4.0, ry + 4.0, tint, DrawTextureParams { source: Some(crate::badges::badge_rect(*icon)), dest_size: Some(vec2(pic - 8.0, pic - 8.0)), ..Default::default() });
|
||||||
|
let tx = ix + pic + CW();
|
||||||
|
let (name, desc) = if *hidden && !got { ("???".to_string(), tr("ach.hidden")) } else { texts(id) };
|
||||||
|
gfx.text(&name, tx, ry + CH() * 0.1, S(), if got { GOLD } else { TXT });
|
||||||
|
let d: String = desc.chars().take(maxc).collect();
|
||||||
|
gfx.text(&d, tx, ry + CH() * 1.3, SMALL(), DIM);
|
||||||
|
// progress towards a tier
|
||||||
|
if let Some((cat, t)) = id.split_once('.') {
|
||||||
|
if !got {
|
||||||
|
let need = TIERS.iter().find(|x| x.0 == cat).and_then(|x| x.1.get(t.parse::<usize>().unwrap_or(1) - 1)).copied().unwrap_or(1.0);
|
||||||
|
let have = self.profile.total(cat);
|
||||||
|
let bar_x = x + (cols - 20) as f32 * CW();
|
||||||
|
crate::ui::ascii_bar(gfx, bar_x, ry + CH() * 0.1, 10, (have / need).min(1.0) as f32, FRAME_HI, SMALL());
|
||||||
|
let p = format!("{}/{}", crate::achievements::fmt_count(have.floor()), crate::achievements::fmt_count(need));
|
||||||
|
gfx.text(&p, bar_x, ry + CH() * 1.3, SMALL(), DIM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// scrolling and back
|
||||||
|
let _ = ix;
|
||||||
|
self.ach_buttons(gfx, x, y, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrolling, the switch between achievements and the hall, back
|
||||||
|
fn ach_buttons(&mut self, gfx: &Gfx, x: f32, y: f32, rows: i32) {
|
||||||
|
let ix = x + CW() * 3.0;
|
||||||
|
let by = y + (rows - 3) as f32 * CH();
|
||||||
|
let (mx, my) = mouse();
|
||||||
|
let bw = 14.0 * CW();
|
||||||
|
let switch = if self.ach_hall { tr("menu.achievements") } else { tr("hall.title") };
|
||||||
|
let items = [(Btn::Scroll(-1), "▲".to_string()), (Btn::Scroll(1), "▼".to_string()), (Btn::AchHall, switch), (Btn::Back, tr("menu.back"))];
|
||||||
|
let bw = bw.min((sw() - ix - CW() * 4.0) / 4.0 - CW());
|
||||||
|
for (k, (b, label)) in items.iter().enumerate() {
|
||||||
|
let r = Rect::new(ix + k as f32 * (bw + CW()), by, bw, CH() * 2.0);
|
||||||
|
button(gfx, r, label, r.contains(vec2(mx, my)), FRAME);
|
||||||
|
self.buttons.push((r, *b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the hall of the fallen over all games: newest first, two lines each
|
||||||
|
fn draw_hall_page(&mut self, gfx: &Gfx, x: f32, y: f32, cols: i32, rows: i32) {
|
||||||
|
let hall = &self.profile.hall;
|
||||||
|
gfx.text(&trf("hall.count", &[hall.len().to_string()]), x + CW() * 3.0, y + CH() * 1.6, S(), GOLD);
|
||||||
|
let top = y + CH() * 3.5;
|
||||||
|
let bottom = y + (rows - 4) as f32 * CH();
|
||||||
|
let row_h = CH() * 2.4;
|
||||||
|
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
|
||||||
|
self.scroll = self.scroll.min(hall.len().saturating_sub(self.rows_visible));
|
||||||
|
let ix = x + CW() * 3.0;
|
||||||
|
let maxc = (cols - 6) as usize;
|
||||||
|
if hall.is_empty() {
|
||||||
|
gfx.text(&tr("hall.empty"), ix, top, S(), DIM);
|
||||||
|
}
|
||||||
|
for (k, f) in hall.iter().rev().enumerate().skip(self.scroll).take(self.rows_visible) {
|
||||||
|
let ry = top + (k - self.scroll) as f32 * row_h;
|
||||||
|
let name = format!("{} ({})", crate::names::full_name(f.kind, f.name, f.level), trf("info.level", &[tr(crate::game::st(f.kind).name), f.level.to_string()]));
|
||||||
|
gfx.text(&name.chars().take(maxc).collect::<String>(), ix, ry, S(), FRAME_HI);
|
||||||
|
let kills = if f.kills > 0 { format!(" · {}", trf("hall.kills", &[f.kills.to_string()])) } else { String::new() };
|
||||||
|
let line = format!("{}{} · {}", crate::names::cause(f.bane), kills, date(f.when));
|
||||||
|
gfx.text(&line.chars().take(maxc * 2).collect::<String>(), ix + CW() * 2.0, ry + CH(), SMALL(), DIM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a unix time as a date (year-month-day)
|
||||||
|
fn date(unix: u64) -> String {
|
||||||
|
if unix == 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
// days since 1970-01-01 to a civil date (Howard Hinnant's algorithm)
|
||||||
|
let z = (unix / 86400) as i64 + 719468;
|
||||||
|
let era = z.div_euclid(146097);
|
||||||
|
let doe = z - era * 146097;
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
|
||||||
|
format!("{:04}-{:02}-{:02}", y, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn dates_are_civil() {
|
||||||
|
assert_eq!(super::date(0), "");
|
||||||
|
assert_eq!(super::date(1_798_675_200), "2026-12-31");
|
||||||
|
assert_eq!(super::date(1_709_164_800), "2024-02-29");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,10 @@ use crate::ui::{tool_cost_static, BLUE as BLUE_C, GREEN as GREEN_C, RED as RED_C
|
|||||||
|
|
||||||
pub const TABS: [&str; 4] = ["best.tab.creatures", "best.tab.humans", "best.tab.dungeon", "best.tab.ranks"];
|
pub const TABS: [&str; 4] = ["best.tab.creatures", "best.tab.humans", "best.tab.dungeon", "best.tab.ranks"];
|
||||||
|
|
||||||
const CREATURES: [EK; 14] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Jailer, EK::Priestess, EK::Chainbreaker, EK::Cogling, EK::Revenant, EK::Bat, EK::Gargoyle, EK::Owl, EK::Wyrm];
|
const CREATURES: [EK; 16] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Jailer, EK::Priestess, EK::Chainbreaker, EK::Cogling, EK::Revenant, EK::Bat, EK::Gargoyle, EK::Owl, EK::Wyrm, EK::CaveBear, EK::Beetle];
|
||||||
const HUMANS: [EK; 8] = [EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Fairy, EK::BattleMage, EK::Giant, EK::Paladin];
|
const HUMANS: [EK; 14] = [EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Fairy, EK::Thief, EK::BattleMage, EK::Monk, EK::Giant, EK::Ranger, EK::Inquisitor, EK::Paladin, EK::Ram, EK::Catapult];
|
||||||
// buildings: indices into ui::TOOLS
|
// buildings: indices into ui::TOOLS
|
||||||
const BUILDINGS: [usize; 30] = [2, 3, 4, 5, 6, 12, 40, 13, 18, 21, 41, 42, 43, 44, 17, 19, 14, 15, 16, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
|
const BUILDINGS: [usize; 32] = [2, 3, 4, 5, 6, 12, 40, 13, 18, 21, 41, 42, 43, 44, 17, 47, 49, 19, 14, 15, 16, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum Entry {
|
enum Entry {
|
||||||
@@ -266,6 +266,8 @@ impl Menu {
|
|||||||
crate::ui::Tool::Room(r) => crate::game::ROOM_NAMES[r].trim_start_matches("room.").to_string(),
|
crate::ui::Tool::Room(r) => crate::game::ROOM_NAMES[r].trim_start_matches("room.").to_string(),
|
||||||
crate::ui::Tool::Tower(k) => crate::game::tower_name(k).trim_start_matches("tower.").to_string(),
|
crate::ui::Tool::Tower(k) => crate::game::tower_name(k).trim_start_matches("tower.").to_string(),
|
||||||
crate::ui::Tool::Bridge => "bridge".into(),
|
crate::ui::Tool::Bridge => "bridge".into(),
|
||||||
|
crate::ui::Tool::Drawbridge => "drawbridge".into(),
|
||||||
|
crate::ui::Tool::Wall => "wall".into(),
|
||||||
crate::ui::Tool::Trap(k) => k.id().into(),
|
crate::ui::Tool::Trap(k) => k.id().into(),
|
||||||
_ => "steel".into(),
|
_ => "steel".into(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ enum Act {
|
|||||||
Terrain(String, String),
|
Terrain(String, String),
|
||||||
Kill(String, i32),
|
Kill(String, i32),
|
||||||
Trigger(String, bool),
|
Trigger(String, bool),
|
||||||
|
Weather(u8, f64),
|
||||||
|
TalentXp(f64),
|
||||||
}
|
}
|
||||||
|
|
||||||
// the triggering unit (for "where the unit is" and "the unit is a ...")
|
// the triggering unit (for "where the unit is" and "the unit is a ...")
|
||||||
@@ -142,7 +144,15 @@ pub fn kind_by_name(name: &str) -> Option<EK> {
|
|||||||
18 => EK::Paladin,
|
18 => EK::Paladin,
|
||||||
19 => EK::Giant,
|
19 => EK::Giant,
|
||||||
20 => EK::Fairy,
|
20 => EK::Fairy,
|
||||||
_ => EK::BattleMage,
|
21 => EK::BattleMage,
|
||||||
|
22 => EK::Ram,
|
||||||
|
23 => EK::Catapult,
|
||||||
|
24 => EK::CaveBear,
|
||||||
|
25 => EK::Beetle,
|
||||||
|
26 => EK::Thief,
|
||||||
|
27 => EK::Monk,
|
||||||
|
28 => EK::Ranger,
|
||||||
|
_ => EK::Inquisitor,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,6 +541,12 @@ impl Game {
|
|||||||
Val::Or { a, b } => (self.cond(s, a, ev) || self.cond(s, b, ev)) as i32 as f64,
|
Val::Or { a, b } => (self.cond(s, a, ev) || self.cond(s, b, ev)) as i32 as f64,
|
||||||
Val::Not { a } => (!self.cond(s, a, ev)) as i32 as f64,
|
Val::Not { a } => (!self.cond(s, a, ev)) as i32 as f64,
|
||||||
Val::EventKind { kind } => (ev.kind.is_some() && ev.kind == kind_by_name(kind)) as i32 as f64,
|
Val::EventKind { kind } => (ev.kind.is_some() && ev.kind == kind_by_name(kind)) as i32 as f64,
|
||||||
|
Val::WeatherIs { kind } => {
|
||||||
|
// the weather felt now (a change counts once it has come in)
|
||||||
|
let w = &self.weather;
|
||||||
|
let now = if w.level > 0.5 { w.shown } else { crate::weather::FAIR };
|
||||||
|
(crate::blocks::WEATHER.iter().position(|k| k == kind) == Some(now as usize)) as i32 as f64
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if r.is_finite() {
|
if r.is_finite() {
|
||||||
r
|
r
|
||||||
@@ -560,6 +576,8 @@ impl Game {
|
|||||||
Block::Wave { count } => Act::Wave(self.eval(s, count, ev).round() as i64),
|
Block::Wave { count } => Act::Wave(self.eval(s, count, ev).round() as i64),
|
||||||
Block::Gold { amount } => Act::Gold(self.eval(s, amount, ev)),
|
Block::Gold { amount } => Act::Gold(self.eval(s, amount, ev)),
|
||||||
Block::Mana { amount } => Act::Mana(self.eval(s, amount, ev)),
|
Block::Mana { amount } => Act::Mana(self.eval(s, amount, ev)),
|
||||||
|
Block::Weather { kind, secs } => Act::Weather(crate::blocks::WEATHER.iter().position(|k| k == kind).unwrap_or(0) as u8, self.eval(s, secs, ev)),
|
||||||
|
Block::TalentXp { amount } => Act::TalentXp(self.eval(s, amount, ev)),
|
||||||
Block::SetVar { name, value } => {
|
Block::SetVar { name, value } => {
|
||||||
let v = self.eval(s, value, ev);
|
let v = self.eval(s, value, ev);
|
||||||
s.vars.insert(name.clone(), v);
|
s.vars.insert(name.clone(), v);
|
||||||
@@ -816,6 +834,13 @@ impl Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Act::Mana(n) => self.mana = (self.mana + n as f32).clamp(0.0, self.mana_max()),
|
Act::Mana(n) => self.mana = (self.mana + n as f32).clamp(0.0, self.mana_max()),
|
||||||
|
// the weather comes (softly) and stays the given seconds (0: until a block changes it)
|
||||||
|
Act::Weather(k, secs) => {
|
||||||
|
self.weather.kind = k;
|
||||||
|
self.weather.left = if secs > 0.0 { secs as f32 } else { 1.0e9 };
|
||||||
|
self.weather.strike_t = self.weather.strike_t.min(5.0);
|
||||||
|
}
|
||||||
|
Act::TalentXp(n) => self.add_master_xp(n.max(0.0) as f32),
|
||||||
Act::Win => {
|
Act::Win => {
|
||||||
self.state = 1;
|
self.state = 1;
|
||||||
self.script_msg("msg.mission_won", vec![]);
|
self.script_msg("msg.mission_won", vec![]);
|
||||||
@@ -846,6 +871,7 @@ impl Game {
|
|||||||
"water" => Kind::Water,
|
"water" => Kind::Water,
|
||||||
"lava" => Kind::Lava,
|
"lava" => Kind::Lava,
|
||||||
"gold" => Kind::Gold,
|
"gold" => Kind::Gold,
|
||||||
|
"grove" => Kind::Grove,
|
||||||
_ => Kind::Floor,
|
_ => Kind::Floor,
|
||||||
};
|
};
|
||||||
for i in zone_tiles(&z) {
|
for i in zone_tiles(&z) {
|
||||||
@@ -952,6 +978,28 @@ mod tests {
|
|||||||
assert!(g.script.as_ref().unwrap().error.is_none(), "{:?}", g.script.as_ref().unwrap().error);
|
assert!(g.script.as_ref().unwrap().error.is_none(), "{:?}", g.script.as_ref().unwrap().error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weather_and_talent_blocks_work() {
|
||||||
|
let mut g = Game::new(3, 64, 64);
|
||||||
|
g.add_surface(3, 0.5, 2);
|
||||||
|
let scripts = MissionScripts {
|
||||||
|
start: vec![
|
||||||
|
Block::Weather { kind: "storm".into(), secs: Val::num(0.0) },
|
||||||
|
Block::TalentXp { amount: Val::num(400.0) },
|
||||||
|
Block::Wait { secs: Val::num(25.0) },
|
||||||
|
Block::If { cond: Val::WeatherIs { kind: "storm".into() }, then: vec![Block::SetVar { name: "storm".into(), value: Val::num(1.0) }], els: vec![] },
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
g.script = Some(ScriptState::new(scripts, vec![], 7));
|
||||||
|
run(&mut g, 30.0);
|
||||||
|
assert_eq!(g.weather.kind, crate::weather::STORM);
|
||||||
|
assert!(g.weather.left > 1.0e8, "it stays");
|
||||||
|
assert!(g.talents.xp >= 400.0 && g.talents.free() >= 1);
|
||||||
|
assert_eq!(g.script.as_ref().unwrap().vars.get("storm"), Some(&1.0), "the condition sees the storm");
|
||||||
|
assert!(crate::blocks::check(&g.script.as_ref().unwrap().scripts.start, &crate::blocks::Names { zones: &[], triggers: &[], vars: &[] }).iter().all(|p| p.key != "blk.err_weather"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_zone_trigger_spawns_and_the_victory_script_wins() {
|
fn a_zone_trigger_spawns_and_the_victory_script_wins() {
|
||||||
let mut g = Game::new(3, 64, 64);
|
let mut g = Game::new(3, 64, 64);
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// Named creatures: every creature of a master carries a name seed from its birth. The name
|
||||||
|
// comes from the list of its kind in the language files (so it follows a language switch);
|
||||||
|
// from level 5 on it earns a humorous epithet, from level 8 a grand one. A creature of level
|
||||||
|
// 3 or more that falls leaves a memorial stone with its name on its master's ground.
|
||||||
|
|
||||||
|
use crate::game::*;
|
||||||
|
use crate::i18n::{tr, trf};
|
||||||
|
use crate::map::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// the fallen are remembered in the hall from HALL_LEVEL on; only the greatest (MEMORIAL_LEVEL)
|
||||||
|
// get a stone on the map, at most MAX_MEMORIALS of them (the oldest go)
|
||||||
|
pub const HALL_LEVEL: u32 = 3;
|
||||||
|
pub const MEMORIAL_LEVEL: u32 = 10;
|
||||||
|
pub const MAX_MEMORIALS: usize = 30;
|
||||||
|
|
||||||
|
// what killed a creature besides another unit (Ent::bane)
|
||||||
|
pub const BANE_SNARE: u8 = 250;
|
||||||
|
pub const BANE_LIGHTNING: u8 = 251;
|
||||||
|
pub const BANE_LAVA: u8 = 252;
|
||||||
|
pub const BANE_WATER: u8 = 253;
|
||||||
|
|
||||||
|
// an entry of the hall of the fallen (in the game and in the player's profile)
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Fallen {
|
||||||
|
pub kind: EK,
|
||||||
|
pub name: u32,
|
||||||
|
pub level: u32,
|
||||||
|
pub kills: u32,
|
||||||
|
pub bane: u8,
|
||||||
|
pub time: f32, // game seconds when it fell
|
||||||
|
#[serde(default)]
|
||||||
|
pub when: u64, // unix time (set when it goes into the profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// how it fell, in words
|
||||||
|
pub fn cause(bane: u8) -> String {
|
||||||
|
match bane {
|
||||||
|
BANE_SNARE => tr("cause.snare"),
|
||||||
|
BANE_LIGHTNING => tr("cause.lightning"),
|
||||||
|
BANE_LAVA => tr("cause.lava"),
|
||||||
|
BANE_WATER => tr("cause.water"),
|
||||||
|
0 => tr("cause.fell"),
|
||||||
|
k => match crate::gfx::SPRITE_KINDS.get(k as usize - 1) {
|
||||||
|
Some(&kind) => trf("cause.slain", &[tr(st(kind).name)]),
|
||||||
|
None => tr("cause.fell"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Memorial {
|
||||||
|
pub tile: usize,
|
||||||
|
pub kind: EK,
|
||||||
|
pub name: u32,
|
||||||
|
pub level: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// the name list of a kind of creature
|
||||||
|
pub fn group(k: EK) -> &'static str {
|
||||||
|
match k {
|
||||||
|
EK::Worker => "imp",
|
||||||
|
EK::Kobold => "kobold",
|
||||||
|
EK::Spider => "spider",
|
||||||
|
EK::Mage => "mage",
|
||||||
|
EK::Ogre => "ogre",
|
||||||
|
EK::Jailer => "jailer",
|
||||||
|
EK::Priestess => "priestess",
|
||||||
|
EK::Chainbreaker => "chainbreaker",
|
||||||
|
EK::Bat => "bat",
|
||||||
|
EK::Gargoyle => "gargoyle",
|
||||||
|
EK::Owl => "owl",
|
||||||
|
EK::Wyrm => "wyrm",
|
||||||
|
EK::Cogling => "cogling",
|
||||||
|
EK::Revenant => "revenant",
|
||||||
|
EK::CaveBear | EK::Beetle => "beast",
|
||||||
|
_ => "human",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the seed of a new creature's name (from its id: the same on every computer of a match)
|
||||||
|
pub fn seed_for(id: u32) -> u32 {
|
||||||
|
(id.wrapping_mul(2654435761) ^ 0x5bd1_e995) | 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick(key: &str, seed: u32) -> Option<String> {
|
||||||
|
let list = tr(key);
|
||||||
|
if list == key {
|
||||||
|
return None; // no list in this language
|
||||||
|
}
|
||||||
|
let items: Vec<&str> = list.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||||
|
(!items.is_empty()).then(|| items[seed as usize % items.len()].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// the name with its epithet (if the level has earned one); "" if the creature has no name
|
||||||
|
pub fn full_name(kind: EK, seed: u32, level: u32) -> String {
|
||||||
|
if seed == 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let Some(base) = pick(&format!("names.{}", group(kind)), seed) else { return String::new() };
|
||||||
|
let list = if level >= 8 { "epithets.high" } else { "epithets.mid" };
|
||||||
|
match pick(list, seed >> 7).filter(|_| level >= 5) {
|
||||||
|
Some(ep) => trf("name.epithet", &[base, ep]),
|
||||||
|
None => base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the name of a unit, or its kind's name if it has none
|
||||||
|
pub fn display_name(e: &Ent) -> String {
|
||||||
|
let n = full_name(e.kind, e.name, e.level);
|
||||||
|
if n.is_empty() { tr(st(e.kind).name) } else { n }
|
||||||
|
}
|
||||||
|
|
||||||
|
// a message argument that is turned into the name when the message is shown (i18n::trf)
|
||||||
|
pub fn arg(e: &Ent) -> String {
|
||||||
|
if e.name == 0 {
|
||||||
|
return format!("@{}", st(e.kind).name);
|
||||||
|
}
|
||||||
|
format!("%{}.{}.{}", crate::gfx::sprite_index(e.kind), e.name, e.level)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the counterpart of `arg`
|
||||||
|
pub fn from_arg(a: &str) -> String {
|
||||||
|
let p: Vec<u32> = a.split('.').filter_map(|s| s.parse().ok()).collect();
|
||||||
|
match p.as_slice() {
|
||||||
|
[k, seed, level] => {
|
||||||
|
let kind = crate::gfx::SPRITE_KINDS.get(*k as usize).copied().unwrap_or(EK::Kobold);
|
||||||
|
let n = full_name(kind, *seed, *level);
|
||||||
|
if n.is_empty() { tr(st(kind).name) } else { n }
|
||||||
|
}
|
||||||
|
_ => a.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
// a named creature of some standing fell: a stone with its name on its master's ground, as
|
||||||
|
// near to where it fell as there is plain floor
|
||||||
|
pub(crate) fn raise_memorial(&mut self, e: &Ent) -> bool {
|
||||||
|
if e.hero || e.name == 0 || e.level < HALL_LEVEL || !Owner::MASTER_SIDES.contains(&e.side) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// the hall of the fallen keeps the local master's (also without room for a stone)
|
||||||
|
if e.side == self.local_side {
|
||||||
|
self.hall.push(Fallen { kind: e.kind, name: e.name, level: e.level, kills: e.kills, bane: e.bane, time: self.time, when: 0 });
|
||||||
|
}
|
||||||
|
// a stone only for the greatest
|
||||||
|
if e.level < MEMORIAL_LEVEL {
|
||||||
|
return false; // the usual message tells of it
|
||||||
|
}
|
||||||
|
let owner = Owner::of_side(e.side);
|
||||||
|
let (x, y) = (e.x as i32, e.y as i32);
|
||||||
|
let taken: Vec<usize> = self.memorials.iter().map(|m| m.tile).collect();
|
||||||
|
let mut best: Option<(usize, i32)> = None;
|
||||||
|
for dy in -6..=6 {
|
||||||
|
for dx in -6..=6 {
|
||||||
|
let (tx, ty) = (x + dx, y + dy);
|
||||||
|
if !inb(tx, ty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let i = idx(tx, ty);
|
||||||
|
let t = &self.map.t[i];
|
||||||
|
if t.kind == Kind::Floor && t.owner == owner && !taken.contains(&i) {
|
||||||
|
let d = dx * dx + dy * dy;
|
||||||
|
if best.map_or(true, |b| d < b.1) {
|
||||||
|
best = Some((i, d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some((tile, _)) = best else { return false };
|
||||||
|
if self.memorials.len() >= MAX_MEMORIALS {
|
||||||
|
self.memorials.remove(0);
|
||||||
|
}
|
||||||
|
self.memorials.push(Memorial { tile, kind: e.kind, name: e.name, level: e.level });
|
||||||
|
if e.side == self.local_side {
|
||||||
|
self.tell_local("msg.creature_fallen", vec![arg(e)], None);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// stones on ground that was lost or built over are taken away
|
||||||
|
pub(crate) fn tidy_memorials(&mut self) {
|
||||||
|
let map = &self.map;
|
||||||
|
self.memorials.retain(|m| map.t[m.tile].kind == Kind::Floor && Owner::MASTER_SIDES.contains(&map.t[m.tile].owner.side()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn memorial_at(&self, i: usize) -> Option<&Memorial> {
|
||||||
|
self.memorials.iter().find(|m| m.tile == i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creatures_have_names_and_earn_epithets() {
|
||||||
|
let mut g = Game::new(9, 64, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let id = g.spawn(EK::Ogre, false, cx as f32 + 0.5, cy as f32 + 4.5, 1);
|
||||||
|
let e = g.ents.iter().find(|e| e.id == id).unwrap().clone();
|
||||||
|
assert!(e.name != 0);
|
||||||
|
let n1 = full_name(e.kind, e.name, 1);
|
||||||
|
assert!(!n1.is_empty() && !n1.contains("names."), "{}", n1);
|
||||||
|
let n5 = full_name(e.kind, e.name, 5);
|
||||||
|
let n9 = full_name(e.kind, e.name, 9);
|
||||||
|
assert!(n5.starts_with(&n1) || n5.ends_with(&n1), "{} / {}", n1, n5);
|
||||||
|
assert!(n5.len() > n1.len() && n9 != n5, "{} {} {}", n1, n5, n9);
|
||||||
|
assert_eq!(from_arg(arg(&e).trim_start_matches('%')), display_name(&e));
|
||||||
|
// heroes have none
|
||||||
|
let h = g.spawn(EK::Knight, true, cx as f32 + 0.5, cy as f32 + 5.5, 1);
|
||||||
|
assert_eq!(g.ents.iter().find(|e| e.id == h).unwrap().name, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_hall_remembers_kills_and_how_they_fell() {
|
||||||
|
let mut g = Game::new(9, 64, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
for y in cy + 4..cy + 8 {
|
||||||
|
for x in cx..cx + 6 {
|
||||||
|
let t = &mut g.map.t[idx(x, y)];
|
||||||
|
t.kind = Kind::Floor;
|
||||||
|
t.owner = Owner::Player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut t = crate::achievements::Tracker::new(Default::default());
|
||||||
|
let ctx = crate::achievements::Context { coop: false, editor_test: false };
|
||||||
|
g.time = 1.0;
|
||||||
|
t.update(&g, &ctx, 6.0, 1_800_000_000);
|
||||||
|
let ogre = g.spawn(EK::Ogre, false, cx as f32 + 1.5, cy as f32 + 5.5, 5);
|
||||||
|
let knight = g.spawn(EK::Knight, true, cx as f32 + 2.5, cy as f32 + 5.5, 1);
|
||||||
|
// the ogre slays the knight
|
||||||
|
let (oj, kj) = (g.ents.iter().position(|e| e.id == ogre).unwrap(), g.ents.iter().position(|e| e.id == knight).unwrap());
|
||||||
|
g.ents[kj].hp = 1.0;
|
||||||
|
let mut o = g.ents[oj].clone();
|
||||||
|
g.hit_for_test(&mut o, kj);
|
||||||
|
assert_eq!(o.kills, 1, "a hero slain");
|
||||||
|
g.ents[oj] = o;
|
||||||
|
// a second knight brings the ogre down
|
||||||
|
let k2 = g.spawn(EK::Knight, true, cx as f32 + 2.5, cy as f32 + 6.5, 1);
|
||||||
|
let k2j = g.ents.iter().position(|e| e.id == k2).unwrap();
|
||||||
|
let oj = g.ents.iter().position(|e| e.id == ogre).unwrap();
|
||||||
|
g.ents[oj].hp = 1.0;
|
||||||
|
let mut k = g.ents[k2j].clone();
|
||||||
|
g.hit_for_test(&mut k, oj);
|
||||||
|
assert_eq!(g.hall.len(), 1);
|
||||||
|
let f = &g.hall[0];
|
||||||
|
assert_eq!((f.kind, f.kills), (EK::Ogre, 1));
|
||||||
|
assert_eq!(f.bane as u32, crate::gfx::sprite_index(EK::Knight) + 1);
|
||||||
|
assert!(cause(f.bane).contains(&crate::i18n::tr("unit.knight")));
|
||||||
|
// into the profile, once
|
||||||
|
g.time = 100.0;
|
||||||
|
t.update(&g, &ctx, 6.0, 1_800_000_100);
|
||||||
|
t.update(&g, &ctx, 6.0, 1_800_000_200);
|
||||||
|
assert_eq!(t.profile.hall.len(), 1);
|
||||||
|
assert_eq!(t.profile.hall[0].when, 1_800_000_100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_fallen_get_a_memorial_stone() {
|
||||||
|
let mut g = Game::new(9, 64, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
for y in cy + 4..cy + 8 {
|
||||||
|
for x in cx..cx + 4 {
|
||||||
|
let t = &mut g.map.t[idx(x, y)];
|
||||||
|
t.kind = Kind::Floor;
|
||||||
|
t.owner = Owner::Player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let id = g.spawn(EK::Ogre, false, cx as f32 + 1.5, cy as f32 + 5.5, 10);
|
||||||
|
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
||||||
|
g.kill(j);
|
||||||
|
assert_eq!(g.memorials.len(), 1);
|
||||||
|
let m = g.memorials[0].clone();
|
||||||
|
assert_eq!((m.kind, m.level), (EK::Ogre, 10));
|
||||||
|
// level 4: in the hall, but no stone on the map
|
||||||
|
let o4 = g.spawn(EK::Ogre, false, cx as f32 + 3.5, cy as f32 + 5.5, 4);
|
||||||
|
let j = g.ents.iter().position(|e| e.id == o4).unwrap();
|
||||||
|
g.kill(j);
|
||||||
|
assert_eq!(g.memorials.len(), 1);
|
||||||
|
assert_eq!(g.hall.len(), 2);
|
||||||
|
assert!(g.memorial_at(m.tile).is_some());
|
||||||
|
// a level 1 kobold is not remembered
|
||||||
|
let k = g.spawn(EK::Kobold, false, cx as f32 + 2.5, cy as f32 + 5.5, 1);
|
||||||
|
let j = g.ents.iter().position(|e| e.id == k).unwrap();
|
||||||
|
g.kill(j);
|
||||||
|
assert_eq!(g.memorials.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use crate::commands::Command;
|
use crate::commands::Command;
|
||||||
use crate::game::Game;
|
use crate::game::Game;
|
||||||
|
use crate::map::{Kind, Owner};
|
||||||
|
|
||||||
// one server turn of 100 ms: five fixed simulation steps
|
// one server turn of 100 ms: five fixed simulation steps
|
||||||
pub const STEPS_PER_TURN: usize = 5;
|
pub const STEPS_PER_TURN: usize = 5;
|
||||||
@@ -46,7 +47,281 @@ fn mixdbg(h: &mut u64, v: impl std::fmt::Debug) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the side whose ground a tile of `owner` is (unowned ground counts as the first player's)
|
||||||
|
pub fn side_of_owner(owner: Owner) -> u8 {
|
||||||
|
match owner {
|
||||||
|
Owner::Hero => 1,
|
||||||
|
Owner::Rival1 => 2,
|
||||||
|
Owner::Rival2 => 3,
|
||||||
|
Owner::Rival3 => 4,
|
||||||
|
Owner::Player | Owner::None => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gold amounts a master can send to an ally; `GOLD_ALL` sends everything he has
|
||||||
|
pub const GOLD_ALL: u32 = u32::MAX;
|
||||||
|
pub const GOLD_AMOUNTS: [u32; 4] = [100, 500, 1000, GOLD_ALL];
|
||||||
|
|
||||||
impl Game {
|
impl Game {
|
||||||
|
// ------------------------------------------------------------ teams and allies
|
||||||
|
|
||||||
|
// the team of a master seat in a versus match (sides without an entry are a team of their own)
|
||||||
|
pub fn team_of(&self, side: u8) -> u8 {
|
||||||
|
self.teams.iter().find(|t| t.0 == side).map_or(10 + side, |t| t.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a master seat of the match: a human master or an AI seat
|
||||||
|
pub fn is_seat_side(&self, side: u8) -> bool {
|
||||||
|
Owner::MASTER_SIDES.contains(&side) && (self.is_master(side) || self.ai_sides.contains(&side))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sides that never fight each other: the same side, co-op partners and team mates. Only shared
|
||||||
|
// state goes in, so every client of a match decides the same.
|
||||||
|
pub fn allied(&self, a: u8, b: u8) -> bool {
|
||||||
|
if a == b {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if !self.is_seat_side(a) || !self.is_seat_side(b) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.coop || self.team_of(a) == self.team_of(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the teams of a versus match: (side, team) for the master seats
|
||||||
|
pub fn set_teams(&mut self, teams: &[(u8, u8)]) {
|
||||||
|
self.teams = teams.iter().copied().filter(|(s, _)| Owner::MASTER_SIDES.contains(s)).collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ fallen hearts
|
||||||
|
|
||||||
|
pub fn is_out(&self, side: u8) -> bool {
|
||||||
|
self.out_sides.contains(&side)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the master at this computer lost his heart: he watches the rest of the match
|
||||||
|
pub fn spectating(&self) -> bool {
|
||||||
|
self.netplay.is_some() && self.is_out(self.local_side)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn master_state(&self, side: u8) -> Option<&crate::game::MasterState> {
|
||||||
|
self.masters.iter().find(|m| m.side == side)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn core_hp_of(&self, side: u8) -> f32 {
|
||||||
|
if side == self.me_side {
|
||||||
|
self.core_hp
|
||||||
|
} else {
|
||||||
|
self.master_state(side).map_or(0.0, |m| m.core_hp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn core_of_side(&self, side: u8) -> (i32, i32) {
|
||||||
|
if side == self.me_side {
|
||||||
|
self.map.core
|
||||||
|
} else {
|
||||||
|
self.master_state(side).map_or(self.map.core, |m| m.core)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gold_of(&self, side: u8) -> f32 {
|
||||||
|
if side == self.me_side {
|
||||||
|
self.gold
|
||||||
|
} else {
|
||||||
|
self.master_state(side).map_or(0.0, |m| m.gold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// damage to the soul core of a master (whichever master is being processed)
|
||||||
|
pub fn damage_master_core(&mut self, side: u8, dmg: f32) {
|
||||||
|
if side == self.me_side {
|
||||||
|
self.core_hp -= dmg;
|
||||||
|
} else if let Some(m) = self.masters.iter_mut().find(|m| m.side == side) {
|
||||||
|
m.core_hp -= dmg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the heart of a master of the match that is no ally of `side`, near (x, y)
|
||||||
|
pub(crate) fn enemy_master_near(&self, side: u8, x: f32, y: f32) -> Option<u8> {
|
||||||
|
if self.masters.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.master_sides().into_iter().filter(|&s| s != side && !self.is_out(s) && !self.allied(side, s)).find(|&s| {
|
||||||
|
let (cx, cy) = self.core_of_side(s);
|
||||||
|
(x - cx as f32 - 1.5).powi(2) + (y - cy as f32 - 1.5).powi(2) <= 8.0 * 8.0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// a message and a sound for the player at this computer, whichever master is being processed
|
||||||
|
pub(crate) fn tell_local(&mut self, key: &'static str, args: Vec<String>, sfx: Option<crate::game::Sfx>) {
|
||||||
|
let me = self.me_side;
|
||||||
|
let local = self.local_side;
|
||||||
|
self.switch_to(local);
|
||||||
|
self.msg(key, args);
|
||||||
|
if let Some(sfx) = sfx {
|
||||||
|
self.sfx_ui(sfx);
|
||||||
|
}
|
||||||
|
self.switch_to(me);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a master of a match whose core has no strength left is out. His creatures fight on without a
|
||||||
|
// heart until they fall; he keeps watching. Runs in the shared part of the step.
|
||||||
|
pub(crate) fn update_fallen_masters(&mut self) {
|
||||||
|
if self.netplay.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for side in self.master_sides() {
|
||||||
|
if self.is_out(side) || self.core_hp_of(side) > 0.0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.out_sides.push(side);
|
||||||
|
self.master_fell(side);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn master_fell(&mut self, side: u8) {
|
||||||
|
let me = self.me_side;
|
||||||
|
self.switch_to(side);
|
||||||
|
self.core_hp = 0.0;
|
||||||
|
self.flag = None;
|
||||||
|
// what he held in his hand is put down beside the broken heart
|
||||||
|
let (cx, cy) = self.map.core;
|
||||||
|
let hand = std::mem::take(&mut self.hand);
|
||||||
|
for (k, id) in hand.iter().enumerate() {
|
||||||
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == *id && e.held) {
|
||||||
|
e.held = false;
|
||||||
|
e.x = cx as f32 + 1.5 + (k % 3) as f32 * 0.6 - 0.6;
|
||||||
|
e.y = cy as f32 + 3.5;
|
||||||
|
e.disp_x = e.x;
|
||||||
|
e.disp_y = e.y;
|
||||||
|
e.job = crate::game::Job::None;
|
||||||
|
e.path.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the heart is gone: its tiles become plain ground of that master
|
||||||
|
let owner = Owner::of_side(side);
|
||||||
|
for t in self.map.t.iter_mut() {
|
||||||
|
if t.kind == Kind::Core && t.owner == owner {
|
||||||
|
t.kind = Kind::Floor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.switch_to(me);
|
||||||
|
if side == self.local_side {
|
||||||
|
self.tell_local("msg.master_out_you", vec![], Some(crate::game::Sfx::Defeat));
|
||||||
|
} else {
|
||||||
|
self.tell_local("msg.master_out", vec![format!("@net.side{side}")], Some(crate::game::Sfx::CoreHit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// goals of a mission played in a match: what one master holds (mana springs) counts for any of
|
||||||
|
// the masters still in the game, everything else is shared
|
||||||
|
pub fn match_goal_done(&self, goal: crate::game::Goal) -> bool {
|
||||||
|
match goal {
|
||||||
|
crate::game::Goal::Springs(n) => self.master_sides().into_iter().filter(|s| !self.is_out(*s)).any(|s| {
|
||||||
|
let springs = if s == self.me_side { self.springs } else { self.master_state(s).map_or(0, |m| m.springs) };
|
||||||
|
springs >= n as usize
|
||||||
|
}),
|
||||||
|
g => self.goal_done(g),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The end of a match, decided from shared state only so that all clients stop in the same step:
|
||||||
|
// a mission (co-op) is lost when every master's heart fell; a versus match is over when only one
|
||||||
|
// team is left - the masters of that team win, the others lose.
|
||||||
|
pub(crate) fn check_match_end(&mut self) {
|
||||||
|
if self.state != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive: Vec<u8> = self.master_sides().into_iter().filter(|s| !self.is_out(*s)).collect();
|
||||||
|
let lost = |g: &mut Game, key: &'static str| {
|
||||||
|
g.state = 2;
|
||||||
|
g.msg(key, vec![]);
|
||||||
|
g.sfx_ui(crate::game::Sfx::Defeat);
|
||||||
|
};
|
||||||
|
if let Some(m) = &self.mission {
|
||||||
|
let (limit, goals) = (m.time_limit, m.goals.clone());
|
||||||
|
if alive.is_empty() {
|
||||||
|
return lost(self, "msg.defeat");
|
||||||
|
}
|
||||||
|
if limit > 0.0 && self.time > limit {
|
||||||
|
return lost(self, "msg.time_up");
|
||||||
|
}
|
||||||
|
if !goals.is_empty() && goals.iter().all(|&g| self.match_goal_done(g)) {
|
||||||
|
self.state = 1;
|
||||||
|
self.msg("msg.mission_won", vec![]);
|
||||||
|
self.sfx_ui(crate::game::Sfx::Victory);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// the teams that started (masters and AI seats) and the ones still standing
|
||||||
|
let mut sides: Vec<u8> = self.master_sides();
|
||||||
|
sides.extend(self.rivals.iter().map(|r| r.side));
|
||||||
|
let mut teams: Vec<u8> = sides.iter().map(|&s| self.team_of(s)).collect();
|
||||||
|
teams.sort_unstable();
|
||||||
|
teams.dedup();
|
||||||
|
let mut standing: Vec<u8> = alive.iter().copied().chain(self.rivals.iter().filter(|r| r.alive).map(|r| r.side)).map(|s| self.team_of(s)).collect();
|
||||||
|
standing.sort_unstable();
|
||||||
|
standing.dedup();
|
||||||
|
if alive.is_empty() {
|
||||||
|
return lost(self, "msg.defeat");
|
||||||
|
}
|
||||||
|
let over = if teams.len() >= 2 {
|
||||||
|
standing.len() <= 1
|
||||||
|
} else {
|
||||||
|
// everybody on one team: the humans' gate is the enemy
|
||||||
|
self.gate_on && self.gate_hp <= 0.0
|
||||||
|
};
|
||||||
|
if !over {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if standing.contains(&self.team_of(self.local_side)) {
|
||||||
|
self.state = 1;
|
||||||
|
self.msg("msg.match_won", vec![]);
|
||||||
|
self.sfx_ui(crate::game::Sfx::Victory);
|
||||||
|
} else {
|
||||||
|
lost(self, "msg.match_lost");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ gold for allies
|
||||||
|
|
||||||
|
// the masters the local master can send gold to: human allies whose heart still beats
|
||||||
|
pub fn gold_allies(&self) -> Vec<u8> {
|
||||||
|
if self.netplay.is_none() || self.is_out(self.local_side) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.master_sides().into_iter().filter(|&s| s != self.local_side && !self.is_out(s) && self.allied(self.local_side, s)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// the master being processed sends gold to an ally (never more than he has, never more than
|
||||||
|
// the ally's treasury takes)
|
||||||
|
pub(crate) fn send_gold(&mut self, to: u8, amount: u32) {
|
||||||
|
let from = self.me_side;
|
||||||
|
if to == from || !self.is_master(to) || !self.allied(from, to) || self.is_out(to) || self.is_out(from) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let want = (amount as f32).min(self.gold.floor());
|
||||||
|
if want < 1.0 {
|
||||||
|
self.msg("msg.gold_none", vec![]);
|
||||||
|
self.sfx_ui(crate::game::Sfx::Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.switch_to(to);
|
||||||
|
let got = self.add_gold(want).floor();
|
||||||
|
if got >= 1.0 {
|
||||||
|
self.msg("msg.gold_received", vec![(got as i32).to_string(), format!("@net.side{from}")]);
|
||||||
|
self.sfx_ui(crate::game::Sfx::Payday);
|
||||||
|
}
|
||||||
|
self.switch_to(from);
|
||||||
|
if got < 1.0 {
|
||||||
|
self.msg("msg.gold_full", vec![format!("@net.side{to}")]);
|
||||||
|
self.sfx_ui(crate::game::Sfx::Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.gold -= got;
|
||||||
|
self.msg("msg.gold_sent", vec![(got as i32).to_string(), format!("@net.side{to}")]);
|
||||||
|
self.sfx_ui(crate::game::Sfx::Deposit);
|
||||||
|
}
|
||||||
|
|
||||||
// a match with masters on `sides` (of 0, 2, 3, 4), `local` is the master who plays at this computer
|
// a match with masters on `sides` (of 0, 2, 3, 4), `local` is the master who plays at this computer
|
||||||
pub fn start_match(&mut self, sides: &[u8], local: u8) -> bool {
|
pub fn start_match(&mut self, sides: &[u8], local: u8) -> bool {
|
||||||
if !self.start_masters(sides, local) {
|
if !self.start_masters(sides, local) {
|
||||||
@@ -81,20 +356,16 @@ impl Game {
|
|||||||
.filter(|side| *side != self.local_side)
|
.filter(|side| *side != self.local_side)
|
||||||
.collect();
|
.collect();
|
||||||
for side in self.ai_sides.clone() {
|
for side in self.ai_sides.clone() {
|
||||||
let core = if side == self.me_side {
|
if self.is_out(side) || self.rivals.iter().any(|r| r.side == side) {
|
||||||
|
continue; // a fallen master has nothing left to play; a brain is there already
|
||||||
|
}
|
||||||
|
if !self.is_master(side) {
|
||||||
continue;
|
continue;
|
||||||
} else if let Some(m) = self.masters.iter().find(|m| m.side == side) {
|
|
||||||
Some(m.core)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let Some(core) = core else { continue };
|
|
||||||
if let Some(k) = self.masters.iter().position(|m| m.side == side) {
|
|
||||||
self.masters.remove(k);
|
|
||||||
}
|
|
||||||
if !self.is_rival_side(side) {
|
|
||||||
self.add_rival(side, core.0 + 1, core.1 + 1, self.ai_difficulty);
|
|
||||||
}
|
}
|
||||||
|
// the seat keeps its master (dungeon, gold, creatures): the brain (ai.rs) plays it
|
||||||
|
let core = self.core_of_side(side);
|
||||||
|
let difficulty = self.ai_difficulty;
|
||||||
|
self.rivals.push(crate::rival::Rival { side, core, hp: crate::rival::RIVAL_CORE_HP, alive: true, radius: 6, grow_t: 0.0, spawn_t: 0.0, attack_t: 0.0, difficulty, power: 0.0, gift_t: crate::rival::PARTNER_GIFT_EVERY, think_t: 1.0, attacking: false, army0: 0.0, focus: None, defending: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +440,8 @@ impl Game {
|
|||||||
pub fn checksum(&self) -> u64 {
|
pub fn checksum(&self) -> u64 {
|
||||||
let mut h = FNV_OFFSET;
|
let mut h = FNV_OFFSET;
|
||||||
mix(&mut h, self.sim_steps);
|
mix(&mut h, self.sim_steps);
|
||||||
mix(&mut h, self.state as u64);
|
// whether the world is still running: the end itself is told per team (won or lost)
|
||||||
|
mix(&mut h, (self.state != 0) as u64);
|
||||||
mix(&mut h, self.next_id as u64);
|
mix(&mut h, self.next_id as u64);
|
||||||
mix(&mut h, self.rng.0 as u64);
|
mix(&mut h, self.rng.0 as u64);
|
||||||
mix(&mut h, self.wave as u64);
|
mix(&mut h, self.wave as u64);
|
||||||
@@ -269,6 +541,9 @@ impl Game {
|
|||||||
for side in self.ai_sides.iter() {
|
for side in self.ai_sides.iter() {
|
||||||
mix(&mut h, *side as u64);
|
mix(&mut h, *side as u64);
|
||||||
}
|
}
|
||||||
|
for side in self.out_sides.iter() {
|
||||||
|
mix(&mut h, 100 + *side as u64);
|
||||||
|
}
|
||||||
h
|
h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,7 +622,7 @@ mod tests {
|
|||||||
b.sync_ai_sides(&[3], 1);
|
b.sync_ai_sides(&[3], 1);
|
||||||
assert_eq!(a.checksum(), b.checksum(), "both clients convert side 3 the same way");
|
assert_eq!(a.checksum(), b.checksum(), "both clients convert side 3 the same way");
|
||||||
assert!(a.is_rival_side(3) && b.is_rival_side(3));
|
assert!(a.is_rival_side(3) && b.is_rival_side(3));
|
||||||
assert!(!a.is_master(3) && !b.is_master(3));
|
assert!(a.is_master(3) && b.is_master(3), "the seat keeps its master; a brain plays it");
|
||||||
a.receive_turn(&[]);
|
a.receive_turn(&[]);
|
||||||
b.receive_turn(&[]);
|
b.receive_turn(&[]);
|
||||||
assert_eq!(a.checksum(), b.checksum(), "and keep playing it identically");
|
assert_eq!(a.checksum(), b.checksum(), "and keep playing it identically");
|
||||||
@@ -371,6 +646,171 @@ mod tests {
|
|||||||
assert_eq!(g.netplay.as_ref().unwrap().turn, 1);
|
assert_eq!(g.netplay.as_ref().unwrap().turn, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// every client of one match: the same world, seen from another seat
|
||||||
|
fn clients(seed: u32, locals: &[u8], teams: &[(u8, u8)]) -> Vec<Game> {
|
||||||
|
locals
|
||||||
|
.iter()
|
||||||
|
.map(|&local| {
|
||||||
|
let mut g = client(seed, local);
|
||||||
|
g.set_teams(teams);
|
||||||
|
g
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn turn_all(games: &mut [Game], cmds: &[Command]) {
|
||||||
|
for g in games.iter_mut() {
|
||||||
|
g.receive_turn(cmds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_world(games: &[Game], why: &str) {
|
||||||
|
let sum = games[0].checksum();
|
||||||
|
for g in games.iter().skip(1) {
|
||||||
|
assert_eq!(g.checksum(), sum, "{why}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fallen_master_watches_while_the_match_goes_on() {
|
||||||
|
let mut games = clients(41, &[0, 2, 3], &[]);
|
||||||
|
for g in games.iter_mut() {
|
||||||
|
g.damage_master_core(2, crate::game::CORE_HP * 2.0);
|
||||||
|
}
|
||||||
|
turn_all(&mut games, &[]);
|
||||||
|
same_world(&games, "the heart fell in the same step everywhere");
|
||||||
|
for g in games.iter() {
|
||||||
|
assert!(g.is_out(2) && !g.is_out(0) && !g.is_out(3));
|
||||||
|
assert_eq!(g.state, 0, "the match goes on for the others");
|
||||||
|
}
|
||||||
|
let green = &games[1];
|
||||||
|
assert!(green.spectating() && !games[0].spectating());
|
||||||
|
assert!(green.msgs.iter().any(|m| m.key == "msg.master_out_you"));
|
||||||
|
assert!(games[0].msgs.iter().any(|m| m.key == "msg.master_out"));
|
||||||
|
assert!(!green.map.t.iter().any(|t| t.kind == Kind::Core && t.owner == Owner::Rival1), "the heart is broken");
|
||||||
|
let workers = green.ents.iter().filter(|e| e.alive && e.side == 2).count();
|
||||||
|
assert!(workers > 0, "his creatures go on without a heart");
|
||||||
|
// the spectator sees everything and cannot order anything
|
||||||
|
let mut games = games;
|
||||||
|
for _ in 0..30 {
|
||||||
|
turn_all(&mut games, &[]);
|
||||||
|
}
|
||||||
|
assert!(games[1].map.visible.iter().all(|v| *v), "no fog for the spectator");
|
||||||
|
assert!(!games[0].map.visible.iter().all(|v| *v), "the others still have fog");
|
||||||
|
let (gx, gy) = games[1].core_of_side(2);
|
||||||
|
games[1].queue(Act::Dig(Rect::new(gx + 7, gy, gx + 8, gy + 1)));
|
||||||
|
assert!(games[1].take_orders().is_empty(), "a spectator gives no orders");
|
||||||
|
// an order that still comes in for the fallen side changes nothing
|
||||||
|
let stray = vec![Command { side: 2, seq: 0, act: Act::Dig(Rect::new(gx + 7, gy, gx + 8, gy + 1)) }];
|
||||||
|
turn_all(&mut games, &stray);
|
||||||
|
assert!(!games[0].map.t[crate::map::idx(gx + 7, gy)].marked);
|
||||||
|
for _ in 0..50 {
|
||||||
|
turn_all(&mut games, &[]);
|
||||||
|
}
|
||||||
|
same_world(&games, "the spectator stays in step");
|
||||||
|
assert!(games.iter().all(|g| g.state == 0));
|
||||||
|
// the last enemy falls: red wins, blue (and the spectator) lose, in the same step
|
||||||
|
for g in games.iter_mut() {
|
||||||
|
g.damage_master_core(3, crate::game::CORE_HP * 2.0);
|
||||||
|
}
|
||||||
|
turn_all(&mut games, &[]);
|
||||||
|
same_world(&games, "the end came in the same step everywhere");
|
||||||
|
assert_eq!(games[0].state, 1, "red is the last master standing");
|
||||||
|
assert_eq!(games[1].state, 2);
|
||||||
|
assert_eq!(games[2].state, 2);
|
||||||
|
assert!(games[0].msgs.iter().any(|m| m.key == "msg.match_won"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn team_mates_do_not_fight_and_win_together() {
|
||||||
|
// red and green are team A, blue is team B
|
||||||
|
let teams = [(0u8, 0u8), (2, 0), (3, 1)];
|
||||||
|
let mut games = clients(43, &[0, 2, 3], &teams);
|
||||||
|
let g = &games[0];
|
||||||
|
assert!(g.allied(0, 2) && g.allied(2, 0) && !g.allied(0, 3) && !g.allied(2, 3));
|
||||||
|
assert_eq!(games[1].gold_allies(), vec![0u8], "green can send gold to red");
|
||||||
|
assert!(games[2].gold_allies().is_empty(), "blue has no team mate");
|
||||||
|
// a creature of red next to one of green: no target; one of blue is
|
||||||
|
for g in games.iter_mut() {
|
||||||
|
let (cx, cy) = g.core_of_side(2);
|
||||||
|
let (x, y) = (cx as f32 + 4.5, cy as f32 + 4.5);
|
||||||
|
let red = g.spawn(crate::game::EK::Kobold, false, x, y, 3);
|
||||||
|
let green = g.spawn(crate::game::EK::Kobold, false, x + 1.0, y, 3);
|
||||||
|
for (id, side) in [(red, 0u8), (green, 2u8)] {
|
||||||
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
|
||||||
|
e.side = side;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let red_e = g.ents.iter().find(|e| e.id == red).unwrap().clone();
|
||||||
|
assert_eq!(g.nearest_enemy(&red_e, 3.0), None, "team mates are no targets");
|
||||||
|
let blue = g.spawn(crate::game::EK::Kobold, false, x, y + 1.0, 3);
|
||||||
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == blue) {
|
||||||
|
e.side = 3;
|
||||||
|
}
|
||||||
|
assert_eq!(g.nearest_enemy(&red_e, 3.0), Some(blue), "the other team is");
|
||||||
|
// the blue test unit next to green's heart has done its job (it would see the heart)
|
||||||
|
g.ents.retain(|e| e.id != blue);
|
||||||
|
}
|
||||||
|
for _ in 0..40 {
|
||||||
|
turn_all(&mut games, &[]);
|
||||||
|
}
|
||||||
|
same_world(&games, "teams play the same on every client");
|
||||||
|
// the fog shows the team mate's ground
|
||||||
|
let (gx, gy) = games[0].core_of_side(2);
|
||||||
|
assert!(games[0].map.visible[crate::map::idx(gx + 1, gy + 1)], "red sees green's heart");
|
||||||
|
assert!(!games[2].map.visible[crate::map::idx(gx + 1, gy + 1)], "blue does not");
|
||||||
|
// one of team A falls: the match goes on; team B falls: team A wins together
|
||||||
|
for g in games.iter_mut() {
|
||||||
|
g.damage_master_core(0, crate::game::CORE_HP * 2.0);
|
||||||
|
}
|
||||||
|
turn_all(&mut games, &[]);
|
||||||
|
assert!(games.iter().all(|g| g.state == 0), "green still stands for team A");
|
||||||
|
for g in games.iter_mut() {
|
||||||
|
g.damage_master_core(3, crate::game::CORE_HP * 2.0);
|
||||||
|
}
|
||||||
|
turn_all(&mut games, &[]);
|
||||||
|
same_world(&games, "the end came in the same step everywhere");
|
||||||
|
assert_eq!(games[0].state, 1, "red was out, but his team won");
|
||||||
|
assert_eq!(games[1].state, 1);
|
||||||
|
assert_eq!(games[2].state, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gold_goes_to_an_ally_on_every_client() {
|
||||||
|
let mut games = clients(45, &[0, 2, 3], &[(0, 0), (2, 0), (3, 1)]);
|
||||||
|
let red0 = games[0].gold_of(0);
|
||||||
|
let green0 = games[0].gold_of(2);
|
||||||
|
games[0].queue(Act::SendGold { to: 2, amount: 500 });
|
||||||
|
let orders = games[0].take_orders();
|
||||||
|
assert_eq!(orders.len(), 1);
|
||||||
|
turn_all(&mut games, &orders);
|
||||||
|
for g in games.iter() {
|
||||||
|
assert_eq!(g.gold_of(0), red0 - 500.0, "red paid");
|
||||||
|
assert_eq!(g.gold_of(2), green0 + 500.0, "green got it");
|
||||||
|
}
|
||||||
|
assert!(games[1].msgs.iter().any(|m| m.key == "msg.gold_received"), "the receiver hears of it");
|
||||||
|
assert!(games[1].events.iter().any(|e| e.sfx == crate::game::Sfx::Payday));
|
||||||
|
assert!(games[0].msgs.iter().any(|m| m.key == "msg.gold_sent"));
|
||||||
|
same_world(&games, "both clients have the same gold");
|
||||||
|
// not more than he has (and not more than the ally's treasury takes); never to an enemy
|
||||||
|
let all = vec![
|
||||||
|
Command { side: 2, seq: 0, act: Act::SendGold { to: 0, amount: crate::netplay::GOLD_ALL } },
|
||||||
|
Command { side: 0, seq: 1, act: Act::SendGold { to: 3, amount: 100 } },
|
||||||
|
Command { side: 3, seq: 0, act: Act::SendGold { to: 0, amount: 100 } },
|
||||||
|
];
|
||||||
|
let blue = games[0].gold_of(3);
|
||||||
|
let (red1, green1) = (games[0].gold_of(0), games[0].gold_of(2));
|
||||||
|
turn_all(&mut games, &all);
|
||||||
|
for g in games.iter() {
|
||||||
|
let sent = green1 - g.gold_of(2);
|
||||||
|
assert!(sent > 0.0 && sent <= green1, "green sends what he has: {sent}");
|
||||||
|
assert_eq!(g.gold_of(0), red1 + sent, "red gets all of it");
|
||||||
|
assert!(g.gold_of(0) <= g.gold_cap() + 0.5 || g.local_side != 0);
|
||||||
|
assert!((g.gold_of(3) - blue).abs() < 1.0, "blue got nothing from red and gave nothing");
|
||||||
|
}
|
||||||
|
same_world(&games, "still the same gold everywhere");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_world_that_develops_differently_is_caught_by_the_checksum() {
|
fn a_world_that_develops_differently_is_caught_by_the_checksum() {
|
||||||
let (mut a, mut b) = (client(23, 0), client(23, 0));
|
let (mut a, mut b) = (client(23, 0), client(23, 0));
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
// Orders from the hand: "attack" sends the fighting creatures nearby at an enemy unit or tower,
|
// Orders: "attack" on an enemy unit or tower sends the fighting creatures nearby at it; on open
|
||||||
// "guard" posts a few creatures in a room. Right click with an order tool cancels the orders.
|
// ground it sets an attack marker (a sword) - the ready creatures share out over all swords and
|
||||||
|
// fight where they stand. "Guard" sets a guard marker (a shield) that three creatures hold. Up to
|
||||||
|
// eight of each; a right click on a marker removes it (elsewhere: the guards stand down).
|
||||||
|
|
||||||
use crate::game::*;
|
use crate::game::*;
|
||||||
use crate::map::*;
|
use crate::map::*;
|
||||||
|
|
||||||
const ORDER_RADIUS: f32 = 18.0;
|
const ORDER_RADIUS: f32 = 18.0;
|
||||||
const GUARDS: usize = 3;
|
const GUARDS: usize = 3;
|
||||||
|
pub const MAX_MARKS: usize = 8;
|
||||||
|
|
||||||
fn ready(e: &Ent) -> bool {
|
fn ready(e: &Ent) -> bool {
|
||||||
e.alive && !e.held && e.creature() && !matches!(e.job, Job::Leave | Job::Register | Job::MakeBed(_)) && e.hp > e.max_hp * 0.25
|
e.alive && !e.held && e.creature() && !matches!(e.job, Job::Leave | Job::Register | Job::MakeBed(_)) && e.hp > e.max_hp * 0.25
|
||||||
@@ -26,8 +29,8 @@ impl Game {
|
|||||||
(Some(j), _) => (self.ents[j].x, self.ents[j].y),
|
(Some(j), _) => (self.ents[j].x, self.ents[j].y),
|
||||||
(None, Some((_, p))) => p,
|
(None, Some((_, p))) => p,
|
||||||
_ => {
|
_ => {
|
||||||
self.hint(31, "hint.order_attack");
|
// open ground: an attack marker
|
||||||
self.sfx_ui(Sfx::Error);
|
self.place_mark(wx.floor() as i32, wy.floor() as i32, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -59,8 +62,177 @@ impl Game {
|
|||||||
self.msg("msg.order_attack", vec![n.to_string()]);
|
self.msg("msg.order_attack", vec![n.to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// guard: a few creatures take positions in the room at (x, y)
|
// guard: a guard marker at (x, y) (three creatures hold it, see update_marks)
|
||||||
pub fn order_guard(&mut self, x: i32, y: i32) {
|
pub fn order_guard(&mut self, x: i32, y: i32) {
|
||||||
|
self.place_mark(x, y, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a sword (attack) or shield (guard) marker on tile (x, y)
|
||||||
|
pub fn place_mark(&mut self, x: i32, y: i32, attack: bool) {
|
||||||
|
if !inb(x, y) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let i = idx(x, y);
|
||||||
|
let t = self.map.t[i];
|
||||||
|
// a guard marker stands on own ground, an attack marker anywhere one can walk
|
||||||
|
let ok = if attack { t.walkable() || t.bridge() } else { t.owner == self.me && t.walkable() };
|
||||||
|
if !ok {
|
||||||
|
self.hint(31, if attack { "hint.order_attack" } else { "hint.order_guard" });
|
||||||
|
self.sfx_ui(Sfx::Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let marks = if attack { &self.attack_marks } else { &self.guard_marks };
|
||||||
|
if marks.iter().any(|&m| {
|
||||||
|
let (mx, my) = xy(m);
|
||||||
|
(mx - x).abs() <= 1 && (my - y).abs() <= 1
|
||||||
|
}) {
|
||||||
|
// a second click (tap) on a marker takes it away again
|
||||||
|
self.unmark(x as f32 + 0.5, y as f32 + 0.5);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if marks.len() >= MAX_MARKS {
|
||||||
|
self.hint(31, "hint.marks_full");
|
||||||
|
self.sfx_ui(Sfx::Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if attack {
|
||||||
|
self.attack_marks.push(i);
|
||||||
|
} else {
|
||||||
|
self.guard_marks.push(i);
|
||||||
|
}
|
||||||
|
self.marks_t = 0.0; // creatures are sent at once
|
||||||
|
self.fx(7, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.6, if attack { 0xff3040ff } else { 0xff40d0ff });
|
||||||
|
self.sfx_ui(Sfx::FlagSet);
|
||||||
|
self.msg(if attack { "msg.mark_attack" } else { "msg.mark_guard" }, vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a right click with an order tool: the marker there goes (its creatures are free again);
|
||||||
|
// without a marker there the guards stand down
|
||||||
|
pub fn unmark(&mut self, wx: f32, wy: f32) {
|
||||||
|
// the command markers there go with it
|
||||||
|
self.marks.retain(|m| (m.x - wx).powi(2) + (m.y - wy).powi(2) >= 2.0 * 2.0);
|
||||||
|
let near = |m: &usize| {
|
||||||
|
let (mx, my) = xy(*m);
|
||||||
|
(mx as f32 + 0.5 - wx).powi(2) + (my as f32 + 0.5 - wy).powi(2) < 1.6 * 1.6
|
||||||
|
};
|
||||||
|
if let Some(k) = self.attack_marks.iter().position(near) {
|
||||||
|
let m = self.attack_marks.remove(k);
|
||||||
|
for e in self.ents.iter_mut().filter(|e| e.job == Job::March(m)) {
|
||||||
|
e.job = Job::None;
|
||||||
|
e.path.clear();
|
||||||
|
}
|
||||||
|
} else if let Some(k) = self.guard_marks.iter().position(near) {
|
||||||
|
let m = self.guard_marks.remove(k);
|
||||||
|
for j in 0..self.ents.len() {
|
||||||
|
if let Job::Guard(spot) = self.ents[j].job {
|
||||||
|
if guard_of(m, spot) && self.ents[j].creature() {
|
||||||
|
let me = self.ents[j].id + 1;
|
||||||
|
self.unreserve(me);
|
||||||
|
self.ents[j].job = Job::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(f) = self.flag.filter(|f| near(f)) {
|
||||||
|
// the rally flag goes the same way
|
||||||
|
let (fx, fy) = xy(f);
|
||||||
|
self.set_flag(fx, fy);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
self.cancel_orders();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.msg("msg.mark_removed", vec![]);
|
||||||
|
self.sfx_ui(Sfx::Click);
|
||||||
|
}
|
||||||
|
|
||||||
|
// every second: three creatures for every shield, the ready ones shared out over the swords
|
||||||
|
pub(crate) fn update_marks(&mut self, dt: f32) {
|
||||||
|
self.marks_t -= dt;
|
||||||
|
if self.marks_t > 0.0 || (self.guard_marks.is_empty() && self.attack_marks.is_empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.marks_t = 1.0;
|
||||||
|
// markers on ground that is gone (dug in, lost) go
|
||||||
|
let me = self.me;
|
||||||
|
let map = &self.map;
|
||||||
|
self.guard_marks.retain(|&m| map.t[m].owner == me && map.t[m].walkable());
|
||||||
|
self.attack_marks.retain(|&m| map.t[m].walkable() || map.t[m].bridge());
|
||||||
|
// shields
|
||||||
|
for m in self.guard_marks.clone() {
|
||||||
|
let have = self.ents.iter().filter(|e| e.alive && e.creature() && matches!(e.job, Job::Guard(s) if guard_of(m, s))).count();
|
||||||
|
for _ in have..GUARDS {
|
||||||
|
if !self.send_guard(m) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// swords: every ready creature that is free goes to the nearest sword with room
|
||||||
|
if self.attack_marks.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let free: Vec<usize> = (0..self.ents.len())
|
||||||
|
.filter(|&j| {
|
||||||
|
let e = &self.ents[j];
|
||||||
|
ready(e) && e.hp > e.max_hp * 0.5 && e.energy > 0.15 && matches!(e.job, Job::None | Job::Wander | Job::Train(_) | Job::Study(_) | Job::March(_))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let cap = free.len().div_ceil(self.attack_marks.len()).max(1);
|
||||||
|
let mut count: Vec<usize> = self.attack_marks.iter().map(|&m| self.ents.iter().filter(|e| e.alive && e.job == Job::March(m)).count()).collect();
|
||||||
|
for j in free {
|
||||||
|
if matches!(self.ents[j].job, Job::March(_)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (ex, ey) = (self.ents[j].x, self.ents[j].y);
|
||||||
|
let best = (0..self.attack_marks.len()).filter(|&k| count[k] < cap).min_by(|&a, &b| {
|
||||||
|
let d = |k: usize| {
|
||||||
|
let (mx, my) = xy(self.attack_marks[k]);
|
||||||
|
(mx as f32 + 0.5 - ex).powi(2) + (my as f32 + 0.5 - ey).powi(2)
|
||||||
|
};
|
||||||
|
d(a).partial_cmp(&d(b)).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
let Some(k) = best else { break };
|
||||||
|
count[k] += 1;
|
||||||
|
let mark = self.attack_marks[k];
|
||||||
|
let me = self.ents[j].id + 1;
|
||||||
|
self.unreserve(me);
|
||||||
|
let e = &mut self.ents[j];
|
||||||
|
e.job = Job::March(mark);
|
||||||
|
e.path.clear();
|
||||||
|
e.repath = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the nearest free creature takes a free spot next to shield m
|
||||||
|
fn send_guard(&mut self, m: usize) -> bool {
|
||||||
|
let (x, y) = xy(m);
|
||||||
|
let spot = (-2..=2).flat_map(|dy| (-2..=2).map(move |dx| (dx, dy))).filter(|&(dx, dy)| inb(x + dx, y + dy)).map(|(dx, dy)| idx(x + dx, y + dy)).filter(|&j| {
|
||||||
|
let t = &self.map.t[j];
|
||||||
|
t.walkable() && t.owner == self.me && t.reserved == 0 && !self.ents.iter().any(|e| e.job == Job::Guard(j))
|
||||||
|
}).min_by_key(|&j| {
|
||||||
|
let (sx, sy) = xy(j);
|
||||||
|
(sx - x).pow(2) + (sy - y).pow(2)
|
||||||
|
});
|
||||||
|
let Some(spot) = spot else { return false };
|
||||||
|
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||||
|
let cand = (0..self.ents.len()).filter(|&j| ready(&self.ents[j]) && !matches!(self.ents[j].job, Job::Attack(_) | Job::Guard(_) | Job::March(_) | Job::Rally)).min_by(|&a, &b| {
|
||||||
|
let d = |j: usize| (self.ents[j].x - fx).powi(2) + (self.ents[j].y - fy).powi(2);
|
||||||
|
d(a).partial_cmp(&d(b)).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
let Some(j) = cand else { return false };
|
||||||
|
let Some(p) = self.map.creature_path(flies(self.ents[j].kind), self.ents[j].tile(), spot) else { return false };
|
||||||
|
let me = self.ents[j].id + 1;
|
||||||
|
self.unreserve(me);
|
||||||
|
self.map.t[spot].reserved = me;
|
||||||
|
let e = &mut self.ents[j];
|
||||||
|
e.job = Job::Guard(spot);
|
||||||
|
e.path = p;
|
||||||
|
e.timer = 1.0e9; // until the marker goes
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// the old one-off guard order in a room (kept for scripts and tests)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn order_guard_room(&mut self, x: i32, y: i32) {
|
||||||
if !inb(x, y) {
|
if !inb(x, y) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -134,6 +306,13 @@ impl Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// is guard spot s next to shield m?
|
||||||
|
fn guard_of(m: usize, s: usize) -> bool {
|
||||||
|
let (mx, my) = xy(m);
|
||||||
|
let (sx, sy) = xy(s);
|
||||||
|
(mx - sx).abs() <= 2 && (my - sy).abs() <= 2
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -152,10 +331,71 @@ mod tests {
|
|||||||
let j = g.find(id).unwrap();
|
let j = g.find(id).unwrap();
|
||||||
g.ents[j].job = Job::None;
|
g.ents[j].job = Job::None;
|
||||||
}
|
}
|
||||||
g.order_guard(cx - 1, cy - 1);
|
g.order_guard_room(cx - 1, cy - 1);
|
||||||
let guards = g.ents.iter().filter(|e| matches!(e.job, Job::Guard(_))).count();
|
let guards = g.ents.iter().filter(|e| matches!(e.job, Job::Guard(_))).count();
|
||||||
assert_eq!(guards, 3);
|
assert_eq!(guards, 3);
|
||||||
g.cancel_orders();
|
g.cancel_orders();
|
||||||
assert_eq!(g.ents.iter().filter(|e| matches!(e.job, Job::Guard(_))).count(), 0);
|
assert_eq!(g.ents.iter().filter(|e| matches!(e.job, Job::Guard(_))).count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_tap_removes_a_marker_and_right_click_takes_the_flag() {
|
||||||
|
let mut g = Game::new(14, 64, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
for y in cy - 6..=cy + 8 {
|
||||||
|
for x in cx - 6..=cx + 10 {
|
||||||
|
let t = &mut g.map.t[idx(x, y)];
|
||||||
|
if t.kind != Kind::Core {
|
||||||
|
t.kind = Kind::Floor;
|
||||||
|
t.owner = Owner::Player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.place_mark(cx + 5, cy + 5, false);
|
||||||
|
g.place_mark(cx + 7, cy + 2, true);
|
||||||
|
assert_eq!((g.guard_marks.len(), g.attack_marks.len()), (1, 1));
|
||||||
|
g.place_mark(cx + 5, cy + 5, false);
|
||||||
|
g.place_mark(cx + 7, cy + 2, true);
|
||||||
|
assert_eq!((g.guard_marks.len(), g.attack_marks.len()), (0, 0), "tapped again: gone");
|
||||||
|
g.set_flag(cx + 4, cy - 3);
|
||||||
|
assert!(g.flag.is_some());
|
||||||
|
g.unmark(cx as f32 + 4.5, cy as f32 - 2.5);
|
||||||
|
assert!(g.flag.is_none(), "the flag goes too");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shields_are_held_and_swords_share_the_creatures() {
|
||||||
|
let mut g = Game::new(14, 64, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
for y in cy - 6..=cy + 8 {
|
||||||
|
for x in cx - 6..=cx + 10 {
|
||||||
|
let t = &mut g.map.t[idx(x, y)];
|
||||||
|
if t.kind != Kind::Core {
|
||||||
|
t.kind = Kind::Floor;
|
||||||
|
t.owner = Owner::Player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ids: Vec<u32> = (0..6).map(|k| g.spawn(EK::Ogre, false, cx as f32 - 2.5, cy as f32 + 0.5 + k as f32 * 0.3, 2)).collect();
|
||||||
|
for &id in &ids {
|
||||||
|
let j = g.find(id).unwrap();
|
||||||
|
g.ents[j].settled = true;
|
||||||
|
g.ents[j].job = Job::None;
|
||||||
|
}
|
||||||
|
g.order_guard(cx + 5, cy + 5);
|
||||||
|
g.update_marks(1.0);
|
||||||
|
let guards = g.ents.iter().filter(|e| matches!(e.job, Job::Guard(_))).count();
|
||||||
|
assert_eq!(guards, 3, "three hold the shield");
|
||||||
|
g.place_mark(cx + 8, cy - 4, true);
|
||||||
|
g.place_mark(cx - 4, cy - 5, true);
|
||||||
|
g.update_marks(1.0);
|
||||||
|
let a = g.ents.iter().filter(|e| e.job == Job::March(g.attack_marks[0])).count();
|
||||||
|
let b = g.ents.iter().filter(|e| e.job == Job::March(g.attack_marks[1])).count();
|
||||||
|
assert!(a + b == 3 && a.abs_diff(b) <= 1, "{} and {}", a, b);
|
||||||
|
// a right click on a sword frees its creatures
|
||||||
|
let (sx, sy) = xy(g.attack_marks[0]);
|
||||||
|
g.unmark(sx as f32 + 0.5, sy as f32 + 0.5);
|
||||||
|
assert_eq!(g.attack_marks.len(), 1);
|
||||||
|
assert!(!g.ents.iter().any(|e| e.job == Job::March(idx(sx, sy))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,8 @@ pub fn persona(k: EK) -> Persona {
|
|||||||
EK::Wyrm => p(Kind::Treasury, &[EK::Bat], Some((7, "inferno"))),
|
EK::Wyrm => p(Kind::Treasury, &[EK::Bat], Some((7, "inferno"))),
|
||||||
EK::Cogling => p(Kind::Forge, &[EK::Ogre], Some((4, "bomb"))),
|
EK::Cogling => p(Kind::Forge, &[EK::Ogre], Some((4, "bomb"))),
|
||||||
EK::Revenant => p(Kind::Graveyard, &[EK::Priestess], Some((5, "drain"))),
|
EK::Revenant => p(Kind::Graveyard, &[EK::Priestess], Some((5, "drain"))),
|
||||||
|
EK::CaveBear => p(Kind::Farm, &[EK::Spider], None),
|
||||||
|
EK::Beetle => p(Kind::Lair, &[EK::Bat], None),
|
||||||
_ => p(Kind::Lair, &[], None),
|
_ => p(Kind::Lair, &[], None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Browser integration via our own miniquad JS plugin (www/dc_plugin.js):
|
// Browser integration via our own miniquad JS plugin (www/dc_plugin.js):
|
||||||
// persistent key/value storage (localStorage), a cache that is written to storage when the
|
// persistent key/value storage (localStorage), a cache that is written to storage when the
|
||||||
// page is hidden or closed, file download, file upload and HTTP requests.
|
// page is hidden or closed, file download, file upload and HTTP requests.
|
||||||
// Native builds use simple fallbacks.
|
// Native builds keep the storage as files in the data directory (see imp below).
|
||||||
|
|
||||||
// Result of an HTTP request: status 0 means the service could not be reached
|
// Result of an HTTP request: status 0 means the service could not be reached
|
||||||
pub struct HttpResult {
|
pub struct HttpResult {
|
||||||
@@ -113,6 +113,11 @@ mod imp {
|
|||||||
unsafe { dc_cache_set(key.as_ptr(), key.len() as u32, val.as_ptr(), val.len() as u32) }
|
unsafe { dc_cache_set(key.as_ptr(), key.len() as u32, val.as_ptr(), val.len() as u32) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// where download() put a file, for the status line: the browser's download folder
|
||||||
|
pub fn saved_as(name: &str) -> String {
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn download(name: &str, data: &[u8]) {
|
pub fn download(name: &str, data: &[u8]) {
|
||||||
unsafe { dc_download(name.as_ptr(), name.len() as u32, data.as_ptr(), data.len() as u32) }
|
unsafe { dc_download(name.as_ptr(), name.len() as u32, data.as_ptr(), data.len() as u32) }
|
||||||
}
|
}
|
||||||
@@ -188,14 +193,84 @@ mod imp {
|
|||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
mod imp {
|
mod imp {
|
||||||
use std::cell::RefCell;
|
// Desktop builds: the storage keys are files in the data directory, savegames and
|
||||||
|
// campaigns are written to its "saves" folder, the system file dialog opens files.
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
thread_local! {
|
use std::path::PathBuf;
|
||||||
static STORE: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
static STORE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
|
||||||
|
static PICKED: Mutex<Option<Vec<u8>>> = Mutex::new(None);
|
||||||
|
static PICKING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
// DUNGEONCRAFT_DATA, else the usual per-user application data folder
|
||||||
|
pub fn data_dir() -> PathBuf {
|
||||||
|
let env = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||||
|
if let Some(d) = env("DUNGEONCRAFT_DATA") {
|
||||||
|
return d;
|
||||||
}
|
}
|
||||||
|
if cfg!(target_os = "windows") {
|
||||||
|
if let Some(d) = env("APPDATA") {
|
||||||
|
return d.join("DungeonCraft");
|
||||||
|
}
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
if let Some(h) = env("HOME") {
|
||||||
|
return h.join("Library/Application Support/DungeonCraft");
|
||||||
|
}
|
||||||
|
} else if let Some(d) = env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share"))) {
|
||||||
|
return d.join("dungeoncraft");
|
||||||
|
}
|
||||||
|
PathBuf::from("dungeoncraft-data")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn saves_dir() -> PathBuf {
|
||||||
|
data_dir().join("saves")
|
||||||
|
}
|
||||||
|
|
||||||
|
// a storage key as a file name: only harmless characters
|
||||||
|
pub(super) fn key_file(key: &str) -> String {
|
||||||
|
let name: String = key.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' }).collect();
|
||||||
|
if name.is_empty() || name.starts_with('.') {
|
||||||
|
format!("_{}", name)
|
||||||
|
} else {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writes through a temporary file, so a crash never leaves half a file
|
||||||
|
fn write_file(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
let tmp = path.with_extension("tmp");
|
||||||
|
std::fs::write(&tmp, data)?;
|
||||||
|
std::fs::rename(&tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn storage_get(key: &str) -> Option<String> {
|
pub fn storage_get(key: &str) -> Option<String> {
|
||||||
STORE.with(|s| s.borrow().get(key).cloned())
|
let mut store = STORE.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let store = store.get_or_insert_with(HashMap::new);
|
||||||
|
if let Some(v) = store.get(key) {
|
||||||
|
return Some(v.clone());
|
||||||
}
|
}
|
||||||
|
let v = std::fs::read_to_string(data_dir().join("store").join(key_file(key))).ok()?;
|
||||||
|
store.insert(key.to_string(), v.clone());
|
||||||
|
Some(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn storage_set(key: &str, val: &str) {
|
||||||
|
STORE.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new).insert(key.to_string(), val.to_string());
|
||||||
|
let path = data_dir().join("store").join(key_file(key));
|
||||||
|
if let Err(e) = write_file(&path, val.as_bytes()) {
|
||||||
|
eprintln!("DungeonCraft: cannot write {}: {}", path.display(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// there is no "page left" moment on the desktop: write at once
|
||||||
|
pub fn cache_on_leave(key: &str, val: &str) {
|
||||||
|
storage_set(key, val);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn prompt(_title: &str, _default: &str) -> Option<String> {
|
pub fn prompt(_title: &str, _default: &str) -> Option<String> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -208,19 +283,47 @@ mod imp {
|
|||||||
pub fn fetch_download_state() -> i32 {
|
pub fn fetch_download_state() -> i32 {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
pub fn storage_set(key: &str, val: &str) {
|
|
||||||
STORE.with(|s| s.borrow_mut().insert(key.to_string(), val.to_string()));
|
// a "download" lands in the saves folder
|
||||||
}
|
|
||||||
pub fn cache_on_leave(key: &str, val: &str) {
|
|
||||||
storage_set(key, val);
|
|
||||||
}
|
|
||||||
pub fn download(name: &str, data: &[u8]) {
|
pub fn download(name: &str, data: &[u8]) {
|
||||||
let _ = std::fs::write(name, data);
|
let path = saves_dir().join(key_file(name));
|
||||||
|
if let Err(e) = write_file(&path, data) {
|
||||||
|
eprintln!("DungeonCraft: cannot write {}: {}", path.display(), e);
|
||||||
}
|
}
|
||||||
pub fn open_file() {}
|
}
|
||||||
|
|
||||||
|
// where download() put a file, for the status line
|
||||||
|
pub fn saved_as(name: &str) -> String {
|
||||||
|
saves_dir().join(key_file(name)).display().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick() {
|
||||||
|
let dir = saves_dir();
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let file = rfd::FileDialog::new().set_directory(&dir).pick_file();
|
||||||
|
if let Some(bytes) = file.and_then(|f| std::fs::read(f).ok()) {
|
||||||
|
*PICKED.lock().unwrap_or_else(|e| e.into_inner()) = Some(bytes);
|
||||||
|
}
|
||||||
|
PICKING.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
// opens the system file dialog (one at a time); poll with take_file()
|
||||||
|
pub fn open_file() {
|
||||||
|
if PICKING.swap(true, std::sync::atomic::Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// macOS dialogs must run on the main thread; elsewhere the game keeps running
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
pick();
|
||||||
|
} else if std::thread::Builder::new().name("file dialog".into()).spawn(pick).is_err() {
|
||||||
|
PICKING.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn take_file() -> Option<Vec<u8>> {
|
pub fn take_file() -> Option<Vec<u8>> {
|
||||||
None
|
PICKED.lock().unwrap_or_else(|e| e.into_inner()).take()
|
||||||
}
|
}
|
||||||
|
|
||||||
// no savegame service in native builds: every request fails like an unreachable server
|
// no savegame service in native builds: every request fails like an unreachable server
|
||||||
pub fn http(_method: &str, _url: &str, _body: &[u8]) -> u32 {
|
pub fn http(_method: &str, _url: &str, _body: &[u8]) -> u32 {
|
||||||
0
|
0
|
||||||
@@ -270,3 +373,14 @@ pub use imp::*;
|
|||||||
pub extern "C" fn dungeoncraft_crate_version() -> u32 {
|
pub extern "C" fn dungeoncraft_crate_version() -> u32 {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn storage_keys_become_harmless_file_names() {
|
||||||
|
assert_eq!(super::imp::key_file("dc.settings"), "dc.settings");
|
||||||
|
assert_eq!(super::imp::key_file("../etc/passwd"), "_.._etc_passwd");
|
||||||
|
assert_eq!(super::imp::key_file("a b/c"), "a_b_c");
|
||||||
|
assert_eq!(super::imp::key_file(""), "_");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,16 +34,40 @@ pub const RESEARCH: &[(usize, f32)] = &[
|
|||||||
(25, 650.0), // stone roller
|
(25, 650.0), // stone roller
|
||||||
(37, 700.0), // invisibility
|
(37, 700.0), // invisibility
|
||||||
(19, 700.0), // steel walls
|
(19, 700.0), // steel walls
|
||||||
|
(47, 700.0), // drawbridge
|
||||||
(28, 800.0), // frost glow
|
(28, 800.0), // frost glow
|
||||||
|
(48, 800.0), // frost spell
|
||||||
(38, 850.0), // plague
|
(38, 850.0), // plague
|
||||||
(27, 900.0), // rune mine
|
(27, 900.0), // rune mine
|
||||||
(39, 950.0), // quake
|
(39, 950.0), // quake
|
||||||
(44, 900.0), // sacrifice altar
|
(44, 900.0), // sacrifice altar
|
||||||
(31, 1100.0), // magic door
|
(31, 1100.0), // magic door
|
||||||
|
(50, 900.0), // battering ram
|
||||||
|
(51, 1000.0), // catapult
|
||||||
|
];
|
||||||
|
|
||||||
|
// room levels, researched once everything else is known: (index in ROOM_KINDS, level, points)
|
||||||
|
pub const UPGRADES: &[(usize, u8, f32)] = &[
|
||||||
|
(1, 2, 500.0), // lair
|
||||||
|
(2, 2, 500.0), // mushroom garden
|
||||||
|
(0, 2, 550.0), // treasury
|
||||||
|
(4, 2, 600.0), // library
|
||||||
|
(3, 2, 600.0), // training hall
|
||||||
|
(8, 2, 650.0), // forge
|
||||||
|
(6, 2, 650.0), // temple
|
||||||
|
(7, 2, 650.0), // aerie
|
||||||
|
(1, 3, 1000.0), // lair
|
||||||
|
(2, 3, 1000.0), // mushroom garden
|
||||||
|
(0, 3, 1100.0), // treasury
|
||||||
|
(4, 3, 1200.0), // library
|
||||||
|
(3, 3, 1200.0), // training hall
|
||||||
|
(8, 3, 1300.0), // forge
|
||||||
|
(6, 3, 1300.0), // temple
|
||||||
|
(7, 3, 1300.0), // aerie
|
||||||
];
|
];
|
||||||
|
|
||||||
// known from the start in free play
|
// known from the start in free play
|
||||||
pub const BASE_TOOLS: &[usize] = &[0, 1, 2, 3, 4, 6, 7, 8, 9, 14, 17, 20, 45, 46];
|
pub const BASE_TOOLS: &[usize] = &[0, 1, 2, 3, 4, 6, 7, 8, 9, 14, 17, 20, 45, 46, 49];
|
||||||
|
|
||||||
// campaign: what the next missions bring may be researched early
|
// campaign: what the next missions bring may be researched early
|
||||||
const LOOKAHEAD: usize = 4;
|
const LOOKAHEAD: usize = 4;
|
||||||
@@ -88,13 +112,45 @@ impl Game {
|
|||||||
!self.known(tool) && self.researchable.get(tool).copied().unwrap_or(false) && RESEARCH.iter().any(|&(t, _)| t == tool)
|
!self.known(tool) && self.researchable.get(tool).copied().unwrap_or(false) && RESEARCH.iter().any(|&(t, _)| t == tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the room level being researched once no tool is left: index in UPGRADES
|
||||||
|
pub fn upgrade_target(&self) -> Option<usize> {
|
||||||
|
if self.research_target().is_some() || self.known.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
UPGRADES.iter().position(|&(r, lv, _)| self.room_level.get(r).copied().unwrap_or(1).max(1) < lv)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn add_research(&mut self, points: f32) {
|
pub(crate) fn add_research(&mut self, points: f32) {
|
||||||
if self.research_target().is_some() {
|
if self.research_target().is_some() || self.upgrade_target().is_some() {
|
||||||
self.research += points;
|
self.research += points;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a room kind reached level `lv` (the map keeps it for the look of the rooms)
|
||||||
|
pub fn set_room_level(&mut self, r: usize, lv: u8) {
|
||||||
|
if self.room_level.len() < crate::game::N_ROOMS {
|
||||||
|
self.room_level.resize(crate::game::N_ROOMS, 1);
|
||||||
|
}
|
||||||
|
self.room_level[r] = lv;
|
||||||
|
let side = self.me_side;
|
||||||
|
self.map.upgrades.retain(|u| !(u.0 == side && u.1 as usize == r));
|
||||||
|
if lv > 1 {
|
||||||
|
self.map.upgrades.push((side, r as u8, lv));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn update_research(&mut self) {
|
pub(crate) fn update_research(&mut self) {
|
||||||
|
if let Some(u) = self.upgrade_target() {
|
||||||
|
let (r, lv, cost) = UPGRADES[u];
|
||||||
|
if self.research >= cost {
|
||||||
|
self.research -= cost;
|
||||||
|
self.set_room_level(r, lv);
|
||||||
|
let roman = if lv == 2 { "II" } else { "III" };
|
||||||
|
self.msg("msg.room_upgraded", vec![format!("@{}", crate::game::ROOM_NAMES[r]), roman.to_string()]);
|
||||||
|
self.sfx_ui(Sfx::LevelUp);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(k) = self.research_target() else { return };
|
let Some(k) = self.research_target() else { return };
|
||||||
let (tool, cost) = RESEARCH[k];
|
let (tool, cost) = RESEARCH[k];
|
||||||
if self.research >= cost {
|
if self.research >= cost {
|
||||||
@@ -150,4 +206,31 @@ mod tests {
|
|||||||
assert!(g.can_research(10), "heal comes soon and can be researched");
|
assert!(g.can_research(10), "heal comes soon and can be researched");
|
||||||
assert!(!g.can_research(31), "magic doors are far away");
|
assert!(!g.can_research(31), "magic doors are far away");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn siege_engines_are_not_known_in_the_first_missions() {
|
||||||
|
let g = crate::campaign::start_mission(0, None);
|
||||||
|
for t in [19, 47, 48, 50, 51] {
|
||||||
|
assert!(!g.tool_allowed(t), "tool {} known in mission 1", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn once_everything_is_known_the_library_raises_room_levels() {
|
||||||
|
let mut g = Game::new(9, 64, 64);
|
||||||
|
assert!(g.upgrade_target().is_none(), "tools first");
|
||||||
|
for v in g.known.iter_mut() {
|
||||||
|
*v = true;
|
||||||
|
}
|
||||||
|
let u = g.upgrade_target().expect("a room level to research");
|
||||||
|
let (r, lv, cost) = UPGRADES[u];
|
||||||
|
assert_eq!(lv, 2);
|
||||||
|
let before = g.room_bonus(ROOM_KINDS[r]);
|
||||||
|
g.research = cost + 1.0;
|
||||||
|
g.update_research();
|
||||||
|
assert_eq!(g.room_level_of(ROOM_KINDS[r]), 2);
|
||||||
|
assert!(g.room_bonus(ROOM_KINDS[r]) > before);
|
||||||
|
assert!(g.map.upgrades.iter().any(|x| x.1 as usize == r && x.2 == 2), "the map shows it");
|
||||||
|
assert_ne!(g.upgrade_target(), Some(u), "the next one");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
// Rival dungeon masters (simplified): a dungeon around their own soul core that grows over
|
// Rival dungeon masters: each is a real master (gold, imps, rooms, research - see add_rival)
|
||||||
// time, creatures that appear there, guard it and attack in groups - the player, the other
|
// played by the brain in ai.rs; this keeps their record (side, heart, difficulty, alive) for the
|
||||||
// master or the humans. A master whose core falls is out; its ground becomes free.
|
// interface, the goals and the victory. A master whose heart falls is out; his ground becomes
|
||||||
|
// free. Units of a rival without a master (old savegames) still use rival_ai until he gets one.
|
||||||
|
|
||||||
use crate::game::*;
|
use crate::game::*;
|
||||||
use crate::map::*;
|
use crate::map::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub const RIVAL_CORE_HP: f32 = 2000.0;
|
pub const RIVAL_CORE_HP: f32 = 2000.0;
|
||||||
|
// a co-op AI partner helps masters short of gold: every minute it may send some
|
||||||
|
pub const PARTNER_GIFT_EVERY: f32 = 60.0;
|
||||||
|
pub const PARTNER_GIFT_BELOW: f32 = 300.0;
|
||||||
|
pub const PARTNER_GIFT: f32 = 400.0;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Rival {
|
pub struct Rival {
|
||||||
@@ -20,23 +25,18 @@ pub struct Rival {
|
|||||||
pub attack_t: f32,
|
pub attack_t: f32,
|
||||||
pub difficulty: u8, // 0 easy, 1 medium, 2 hard
|
pub difficulty: u8, // 0 easy, 1 medium, 2 hard
|
||||||
pub power: f32, // grows with the time: level of the new creatures
|
pub power: f32, // grows with the time: level of the new creatures
|
||||||
}
|
#[serde(default)]
|
||||||
|
pub gift_t: f32, // co-op AI partner: seconds until it looks whether its allies need gold
|
||||||
struct Pace {
|
#[serde(default)]
|
||||||
spawn: f32, // seconds between new creatures
|
pub think_t: f32, // the brain (ai.rs): seconds until the next thought
|
||||||
grow: f32, // seconds between growth steps
|
#[serde(default)]
|
||||||
attack: f32, // seconds between attacks
|
pub attacking: bool, // the attack flag is set on an enemy heart
|
||||||
cap: usize, // most creatures at a time
|
#[serde(default)]
|
||||||
max_radius: i32, // largest dungeon
|
pub army0: f32, // army strength when the attack began
|
||||||
power: f32, // levels per minute
|
#[serde(default)]
|
||||||
}
|
pub focus: Option<u8>, // balance runs: this master learns only one talent branch
|
||||||
|
#[serde(default)]
|
||||||
fn pace(d: u8) -> Pace {
|
pub defending: bool, // the rally flag is on intruders in the dungeon (ai.rs)
|
||||||
match d {
|
|
||||||
0 => Pace { spawn: 55.0, grow: 45.0, attack: 330.0, cap: 6, max_radius: 8, power: 0.25 },
|
|
||||||
2 => Pace { spawn: 26.0, grow: 22.0, attack: 170.0, cap: 13, max_radius: 13, power: 0.6 },
|
|
||||||
_ => Pace { spawn: 38.0, grow: 30.0, attack: 240.0, cap: 9, max_radius: 11, power: 0.4 },
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Rival {
|
impl Rival {
|
||||||
@@ -96,178 +96,77 @@ impl Game {
|
|||||||
self.rivals.iter().any(|r| r.side == side && r.alive)
|
self.rivals.iter().any(|r| r.side == side && r.alive)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sets up a rival dungeon around (cx, cy)
|
// sets up a rival master around (cx, cy): a real master (ai.rs) with his heart, a little
|
||||||
|
// ground, a portal of his own, four imps and gold by the difficulty
|
||||||
pub fn add_rival(&mut self, side: u8, cx: i32, cy: i32, difficulty: u8) {
|
pub fn add_rival(&mut self, side: u8, cx: i32, cy: i32, difficulty: u8) {
|
||||||
let owner = Owner::of_side(side);
|
|
||||||
let core = (cx - 1, cy - 1);
|
let core = (cx - 1, cy - 1);
|
||||||
let r = 4;
|
self.carve_master_area(side, core);
|
||||||
for y in cy - r..=cy + r {
|
self.add_master_portal(side, core);
|
||||||
for x in cx - r..=cx + r {
|
self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: 5, grow_t: 20.0, spawn_t: 5.0, attack_t: 0.0, difficulty, power: 0.0, gift_t: PARTNER_GIFT_EVERY, think_t: 2.0, attacking: false, army0: 0.0, focus: None, defending: false });
|
||||||
if !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 {
|
self.adopt_master(side, core, difficulty, true);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let t = &mut self.map.t[idx(x, y)];
|
|
||||||
if matches!(t.kind, Kind::Portal | Kind::Gate | Kind::Core) {
|
|
||||||
continue; // portals and other cores stay
|
|
||||||
}
|
|
||||||
t.kind = Kind::Floor;
|
|
||||||
t.owner = owner;
|
|
||||||
t.gold = 0;
|
|
||||||
t.marked = false;
|
|
||||||
t.reinforced = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for y in 0..3 {
|
|
||||||
for x in 0..3 {
|
|
||||||
let t = &mut self.map.t[idx(core.0 + x, core.1 + y)];
|
|
||||||
t.kind = Kind::Core;
|
|
||||||
t.owner = owner;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// a lair and a treasury (the player can take it)
|
|
||||||
for y in cy - 3..=cy + 3 {
|
|
||||||
let t = &mut self.map.t[idx(cx - 3, y)];
|
|
||||||
t.kind = Kind::Lair;
|
|
||||||
}
|
|
||||||
for y in cy - 1..=cy + 1 {
|
|
||||||
let t = &mut self.map.t[idx(cx + 3, y)];
|
|
||||||
t.kind = Kind::Treasury;
|
|
||||||
t.gold = 300;
|
|
||||||
t.res = 0;
|
|
||||||
}
|
|
||||||
self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: r, grow_t: 20.0, spawn_t: 5.0, attack_t: pace(difficulty).attack, difficulty, power: 0.0 });
|
|
||||||
for k in 0..3 {
|
|
||||||
self.spawn_rival_unit(side, EK::Kobold, 1, k as f32);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_rival_unit(&mut self, side: u8, kind: EK, level: u32, k: f32) {
|
// the rival on `side` gets (or has) a master of his own. `fresh`: a new one with imps and gold
|
||||||
let Some(rv) = self.rivals.iter().find(|r| r.side == side) else { return };
|
pub(crate) fn adopt_master(&mut self, side: u8, core: (i32, i32), difficulty: u8, fresh: bool) {
|
||||||
let (cx, cy) = rv.centre();
|
if self.is_master(side) {
|
||||||
let a = k * 1.7 + self.rng.f() * 2.0;
|
return;
|
||||||
let (x, y) = (cx + a.cos() * 2.4, cy + a.sin() * 2.4);
|
}
|
||||||
let id = self.spawn(kind, false, x, y, level);
|
let me = self.me_side;
|
||||||
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
self.add_master(side, core);
|
||||||
e.side = side;
|
self.switch_to(side);
|
||||||
e.home = Some((cx, cy));
|
self.gold = crate::ai::start_gold(difficulty);
|
||||||
|
self.mana = 150.0;
|
||||||
|
self.recount();
|
||||||
|
if fresh {
|
||||||
|
let (cx, cy) = core;
|
||||||
|
for j in 0..4 {
|
||||||
|
self.spawn(EK::Worker, false, cx as f32 - 0.5, cy as f32 + j as f32 * 0.7 + 0.3, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// the creatures of that side are his
|
||||||
|
for e in self.ents.iter_mut().filter(|e| e.alive && !e.hero && e.side == side) {
|
||||||
|
e.home = None;
|
||||||
|
e.target = None;
|
||||||
|
}
|
||||||
|
self.switch_to(me);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn update_rivals(&mut self, dt: f32) {
|
pub fn update_rivals(&mut self, dt: f32) {
|
||||||
|
let back = self.me_side;
|
||||||
for k in 0..self.rivals.len() {
|
for k in 0..self.rivals.len() {
|
||||||
if !self.rivals[k].alive {
|
if !self.rivals[k].alive {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let p = pace(self.rivals[k].difficulty);
|
|
||||||
let side = self.rivals[k].side;
|
let side = self.rivals[k].side;
|
||||||
{
|
// a rival from an older savegame or a painted map: he gets his master now
|
||||||
let r = &mut self.rivals[k];
|
if !self.is_master(side) {
|
||||||
r.power += dt / 60.0 * p.power;
|
let (core, diff) = (self.rivals[k].core, self.rivals[k].difficulty);
|
||||||
r.grow_t -= dt;
|
self.adopt_master(side, core, diff, false);
|
||||||
r.spawn_t -= dt;
|
|
||||||
r.attack_t -= dt;
|
|
||||||
}
|
}
|
||||||
// growth: one more ring of ground (no imps needed)
|
// his heart fell: he is out
|
||||||
if self.rivals[k].grow_t <= 0.0 {
|
if self.core_hp_of(side) <= 0.0 {
|
||||||
self.rivals[k].grow_t = p.grow * (0.8 + self.rng.f() * 0.4);
|
if !self.out_sides.contains(&side) {
|
||||||
if self.rivals[k].radius < p.max_radius {
|
self.out_sides.push(side);
|
||||||
self.rivals[k].radius += 1;
|
|
||||||
self.rival_grow(k);
|
|
||||||
}
|
}
|
||||||
}
|
self.eliminate_rival(k);
|
||||||
// new creatures
|
|
||||||
let units = self.ents.iter().filter(|e| e.alive && e.side == side && !e.hero).count();
|
|
||||||
if self.rivals[k].spawn_t <= 0.0 {
|
|
||||||
self.rivals[k].spawn_t = p.spawn * (0.8 + self.rng.f() * 0.4);
|
|
||||||
if units < p.cap {
|
|
||||||
let power = self.rivals[k].power;
|
|
||||||
let mut pool = vec![EK::Kobold, EK::Spider];
|
|
||||||
if power >= 1.5 {
|
|
||||||
pool.extend([EK::Mage, EK::Ogre]);
|
|
||||||
}
|
|
||||||
if power >= 3.0 {
|
|
||||||
pool.push(EK::Gargoyle);
|
|
||||||
}
|
|
||||||
let kind = pool[(self.rng.next() as usize) % pool.len()];
|
|
||||||
let level = (1.0 + power) as u32;
|
|
||||||
self.spawn_rival_unit(side, kind, level.clamp(1, 10), units as f32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// attack: about two thirds of the creatures march on an enemy
|
|
||||||
if self.rivals[k].attack_t <= 0.0 {
|
|
||||||
self.rivals[k].attack_t = p.attack * (0.8 + self.rng.f() * 0.4);
|
|
||||||
let mut targets: Vec<(u8, u32)> = Vec::new();
|
|
||||||
if self.coop {
|
|
||||||
if self.gate_on || self.ents.iter().any(|e| e.alive && e.hero) {
|
|
||||||
targets.push((1, 3));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
targets.push((0, 3));
|
|
||||||
for o in &self.rivals {
|
|
||||||
if o.alive && o.side != side && !self.is_coop_ally_side(o.side) {
|
|
||||||
targets.push((o.side, 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.has_gate() && self.gate_hp > 0.0 {
|
|
||||||
targets.push((1, 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let total: u32 = targets.iter().map(|t| t.1).sum();
|
|
||||||
if total == 0 {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut r = self.rng.next() % total;
|
self.rivals[k].hp = self.core_hp_of(side) / CORE_HP * RIVAL_CORE_HP;
|
||||||
let mut target = 0;
|
// a co-op partner sends gold to allied masters who run short
|
||||||
for (s, w) in targets {
|
if self.is_partner(side) {
|
||||||
if r < w {
|
self.rivals[k].gift_t -= dt;
|
||||||
target = s;
|
if self.rivals[k].gift_t <= 0.0 {
|
||||||
break;
|
self.rivals[k].gift_t = PARTNER_GIFT_EVERY;
|
||||||
}
|
self.partner_gift(side);
|
||||||
r -= w;
|
|
||||||
}
|
|
||||||
let mut n = 0;
|
|
||||||
for e in self.ents.iter_mut() {
|
|
||||||
if e.alive && !e.hero && e.side == side && e.target.is_none() && n * 3 < units * 2 {
|
|
||||||
e.target = Some(target);
|
|
||||||
e.path.clear();
|
|
||||||
n += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n > 0 && target == 0 {
|
|
||||||
self.msg(if side == 2 { "msg.rival1_attack" } else { "msg.rival2_attack" }, vec![n.to_string()]);
|
|
||||||
self.sfx_ui(Sfx::WaveAlarm);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.switch_to(side);
|
||||||
|
self.ai_think(k, dt);
|
||||||
|
self.switch_to(back);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// a ring of earth around the rival dungeon becomes its ground
|
|
||||||
fn rival_grow(&mut self, k: usize) {
|
|
||||||
let rv = self.rivals[k].clone();
|
|
||||||
let owner = Owner::of_side(rv.side);
|
|
||||||
let (cx, cy) = (rv.core.0 + 1, rv.core.1 + 1);
|
|
||||||
let r = rv.radius;
|
|
||||||
for y in cy - r..=cy + r {
|
|
||||||
for x in cx - r..=cx + r {
|
|
||||||
if (x - cx).abs() != r && (y - cy).abs() != r {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let t = self.map.t[idx(x, y)];
|
|
||||||
let free = matches!(t.kind, Kind::Earth | Kind::Floor) && !t.reinforced && t.owner == Owner::None;
|
|
||||||
let touches = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == owner && n.walkable()));
|
|
||||||
if free && touches {
|
|
||||||
let tl = &mut self.map.t[idx(x, y)];
|
|
||||||
tl.kind = Kind::Floor;
|
|
||||||
tl.owner = owner;
|
|
||||||
tl.gold = 0;
|
|
||||||
tl.marked = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// damage to the core of a rival master
|
// damage to the core of a rival master
|
||||||
pub fn rival_core_hit(&mut self, side: u8, dmg: f32) {
|
pub fn rival_core_hit(&mut self, side: u8, dmg: f32) {
|
||||||
@@ -289,7 +188,16 @@ impl Game {
|
|||||||
let (x, y) = self.rivals[k].centre();
|
let (x, y) = self.rivals[k].centre();
|
||||||
self.fx(1, x, y, 0.0, 0.0, 1.0, 0xffffffff);
|
self.fx(1, x, y, 0.0, 0.0, 1.0, 0xffffffff);
|
||||||
self.sfx(Sfx::Victory, x, y);
|
self.sfx(Sfx::Victory, x, y);
|
||||||
self.msg(if side == 2 { "msg.rival1_out" } else { "msg.rival2_out" }, vec![]);
|
let key = if self.is_partner(side) {
|
||||||
|
"msg.partner_out"
|
||||||
|
} else if side == 2 {
|
||||||
|
"msg.rival1_out"
|
||||||
|
} else if side == 3 {
|
||||||
|
"msg.rival2_out"
|
||||||
|
} else {
|
||||||
|
"msg.rival3_out"
|
||||||
|
};
|
||||||
|
self.msg(key, vec![]);
|
||||||
// its creatures scatter and vanish, its ground is free
|
// its creatures scatter and vanish, its ground is free
|
||||||
for e in self.ents.iter_mut() {
|
for e in self.ents.iter_mut() {
|
||||||
if e.alive && !e.hero && e.side == side {
|
if e.alive && !e.hero && e.side == side {
|
||||||
@@ -319,8 +227,8 @@ impl Game {
|
|||||||
|
|
||||||
fn target_alive(&self, target: u8) -> bool {
|
fn target_alive(&self, target: u8) -> bool {
|
||||||
match target {
|
match target {
|
||||||
0 => self.core_hp > 0.0,
|
|
||||||
1 => self.has_gate() && self.gate_hp > 0.0,
|
1 => self.has_gate() && self.gate_hp > 0.0,
|
||||||
|
s if self.is_master(s) => !self.is_out(s) && self.core_hp_of(s) > 0.0,
|
||||||
s => self.rival_alive(s),
|
s => self.rival_alive(s),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,9 +238,9 @@ impl Game {
|
|||||||
let dmg = e.atk();
|
let dmg = e.atk();
|
||||||
e.cd = st(e.kind).interval;
|
e.cd = st(e.kind).interval;
|
||||||
match target {
|
match target {
|
||||||
0 => {
|
s if self.is_master(s) => {
|
||||||
self.core_hp -= dmg;
|
self.damage_master_core(s, dmg);
|
||||||
let (cx, cy) = self.map.core;
|
let (cx, cy) = self.core_of_side(s);
|
||||||
self.fx(2, (e.x + cx as f32 + 1.5) * 0.5, (e.y + cy as f32 + 1.5) * 0.5, 0.0, 0.0, 0.3, 0xff4040ff);
|
self.fx(2, (e.x + cx as f32 + 1.5) * 0.5, (e.y + cy as f32 + 1.5) * 0.5, 0.0, 0.0, 0.3, 0xff4040ff);
|
||||||
self.sfx(Sfx::CoreHit, e.x, e.y);
|
self.sfx(Sfx::CoreHit, e.x, e.y);
|
||||||
self.hint(6, "hint.core_attacked");
|
self.hint(6, "hint.core_attacked");
|
||||||
@@ -366,7 +274,7 @@ impl Game {
|
|||||||
e.repath = 1.5;
|
e.repath = 1.5;
|
||||||
// attackers dig their way like heroes; the player's creatures only walk
|
// attackers dig their way like heroes; the player's creatures only walk
|
||||||
let goal = |i: usize| self.target_core_adjacent(i, target);
|
let goal = |i: usize| self.target_core_adjacent(i, target);
|
||||||
let found = if e.side() == 0 { self.map.search(cur, self.map.creature_cost(flies(e.kind)), goal, true) } else { self.map.search(cur, self.map.hero_cost_as(flies(e.kind)), goal, true) };
|
let found = if !e.hero && self.is_master(e.side()) { self.map.search(cur, self.map.creature_cost(flies(e.kind)), goal, true) } else { self.map.search(cur, self.map.hero_cost_as(flies(e.kind)), goal, true) };
|
||||||
match found {
|
match found {
|
||||||
Some(p) => e.path = p,
|
Some(p) => e.path = p,
|
||||||
None => {
|
None => {
|
||||||
@@ -380,6 +288,41 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// units of a rival master: fight enemies nearby, march when sent, otherwise guard home
|
// units of a rival master: fight enemies nearby, march when sent, otherwise guard home
|
||||||
|
// an AI seat of a co-op game: an allied master who builds his own dungeon
|
||||||
|
pub fn is_partner(&self, side: u8) -> bool {
|
||||||
|
self.coop && self.ai_sides.contains(&side)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the partner gives gold to every allied master below the limit
|
||||||
|
fn partner_gift(&mut self, side: u8) {
|
||||||
|
let me = self.me_side;
|
||||||
|
for s in self.master_sides() {
|
||||||
|
if self.is_out(s) || self.gold_of(s) >= PARTNER_GIFT_BELOW {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.switch_to(s);
|
||||||
|
let got = self.add_gold(PARTNER_GIFT).floor();
|
||||||
|
if got >= 1.0 {
|
||||||
|
self.msg("msg.gold_received", vec![(got as i32).to_string(), format!("@net.side{side}")]);
|
||||||
|
self.sfx_ui(Sfx::Payday);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.switch_to(me);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a co-op partner's creatures follow the attack flag of the first master still standing
|
||||||
|
fn partner_flag(&self, side: u8) -> Option<usize> {
|
||||||
|
if !self.is_partner(side) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lead = self.master_sides().into_iter().find(|s| !self.is_out(*s))?;
|
||||||
|
if lead == self.me_side {
|
||||||
|
self.flag
|
||||||
|
} else {
|
||||||
|
self.masters.iter().find(|m| m.side == lead)?.flag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn rival_ai(&mut self, e: &mut Ent, dt: f32) {
|
pub fn rival_ai(&mut self, e: &mut Ent, dt: f32) {
|
||||||
if e.think <= 0.0 {
|
if e.think <= 0.0 {
|
||||||
e.think = 0.5;
|
e.think = 0.5;
|
||||||
@@ -392,18 +335,41 @@ impl Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// two of three healthy creatures of a co-op partner join the attack at the allies' flag
|
||||||
|
let assist = if e.id % 3 != 0 && e.hp > e.max_hp * 0.4 { self.partner_flag(e.side) } else { None };
|
||||||
if let Job::Attack(id) = e.job {
|
if let Job::Attack(id) = e.job {
|
||||||
let (hx, hy) = e.home.unwrap_or((e.x, e.y));
|
let (hx, hy) = e.home.unwrap_or((e.x, e.y));
|
||||||
let far = e.target.is_none() && (e.x - hx).powi(2) + (e.y - hy).powi(2) > 15.0 * 15.0;
|
let far = e.target.is_none() && assist.is_none() && (e.x - hx).powi(2) + (e.y - hy).powi(2) > 15.0 * 15.0;
|
||||||
if !far && self.attack_job(e, id, dt) {
|
if !far && self.attack_job(e, id, dt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.job = Job::None;
|
e.job = Job::None;
|
||||||
e.path.clear();
|
e.path.clear();
|
||||||
}
|
}
|
||||||
|
if let Some(f) = assist {
|
||||||
|
// the flag at the humans' gate: attack the gate
|
||||||
|
let (fx, fy) = xy(f);
|
||||||
|
let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5);
|
||||||
|
let (gx, gy) = self.gate_center();
|
||||||
|
if e.target.is_none() && self.has_gate() && self.gate_hp > 0.0 && (fx - gx).powi(2) + (fy - gy).powi(2) <= 8.0 * 8.0 {
|
||||||
|
e.target = Some(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
if self.march_on(e, dt) {
|
if self.march_on(e, dt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if let Some(f) = assist {
|
||||||
|
let (fx, fy) = xy(f);
|
||||||
|
let d2 = (e.x - fx as f32 - 0.5).powi(2) + (e.y - fy as f32 - 0.5).powi(2);
|
||||||
|
if d2 > 2.5 * 2.5 && e.path.is_empty() && e.repath <= 0.0 {
|
||||||
|
e.repath = 1.5;
|
||||||
|
if let Some(p) = self.map.creature_path(flies(e.kind), e.tile(), f) {
|
||||||
|
e.path = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.step_path(e, dt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// guard the own dungeon
|
// guard the own dungeon
|
||||||
let (hx, hy) = e.home.unwrap_or((e.x, e.y));
|
let (hx, hy) = e.home.unwrap_or((e.x, e.y));
|
||||||
let d2 = (e.x - hx).powi(2) + (e.y - hy).powi(2);
|
let d2 = (e.x - hx).powi(2) + (e.y - hy).powi(2);
|
||||||
@@ -449,21 +415,113 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rival_grows_spawns_and_attacks() {
|
fn an_ai_master_builds_its_dungeon() {
|
||||||
let mut g = rival_game(2);
|
let mut g = rival_game(2);
|
||||||
let r0 = g.rivals[0].radius;
|
let side = g.rivals[0].side;
|
||||||
let core0 = g.core_hp;
|
let owner = Owner::of_side(side);
|
||||||
let mut attacked = false;
|
assert!(g.is_master(side), "a real master");
|
||||||
for _ in 0..(12 * 60 * 20) {
|
let ground0 = g.map.t.iter().filter(|t| t.owner == owner).count();
|
||||||
|
for _ in 0..(8 * 60 * 20) {
|
||||||
g.update(0.05);
|
g.update(0.05);
|
||||||
attacked |= g.ents.iter().any(|e| e.rival() && e.target == Some(0));
|
}
|
||||||
if g.core_hp < core0 - 50.0 {
|
let ground = g.map.t.iter().filter(|t| t.owner == owner).count();
|
||||||
break;
|
assert!(ground > ground0 + 30, "its imps dig and claim: {} -> {}", ground0, ground);
|
||||||
|
g.switch_to(side);
|
||||||
|
let rooms: usize = g.counts.iter().sum();
|
||||||
|
let imps = g.worker_count();
|
||||||
|
g.switch_to(0);
|
||||||
|
assert!(rooms >= 9, "it builds rooms ({} tiles)", rooms);
|
||||||
|
assert!(imps >= 4, "and keeps its imps");
|
||||||
|
assert!(g.torches.iter().any(|t| g.map.t[t.wall].owner == owner || g.map.t[t.wall].solid()), "torches");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_fourth_master_works_too_and_is_no_dwarf() {
|
||||||
|
let mut g = (1..200u32).map(|s| Game::new_free(s, 96, 96, 1)).find(|g| g.rivals.len() == 1 && !g.gate_on).unwrap();
|
||||||
|
let far = |g: &Game, (x, y): (i32, i32)| {
|
||||||
|
let d = |(a, b): (i32, i32)| (a - x).pow(2) + (b - y).pow(2);
|
||||||
|
d(g.map.core).min(d(g.rivals[0].core))
|
||||||
|
};
|
||||||
|
let spot = [(14, 14), (80, 14), (14, 80), (80, 80)].into_iter().max_by_key(|&p| far(&g, p)).unwrap();
|
||||||
|
g.add_rival(4, spot.0, spot.1, 1);
|
||||||
|
let owner = Owner::of_side(4);
|
||||||
|
let ground0 = g.map.t.iter().filter(|t| t.owner == owner).count();
|
||||||
|
for _ in 0..(6 * 60 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
assert!(g.ents.iter().any(|e| e.alive && e.side == 4 && e.rival() && !e.dwarf()), "its units belong to a master");
|
||||||
|
let ground = g.map.t.iter().filter(|t| t.owner == owner).count();
|
||||||
|
assert!(ground > ground0 + 20, "the yellow master digs and claims: {} -> {}", ground0, ground);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ai_marks_stay_near_its_ground_and_imps_dig_only_their_masters_marks() {
|
||||||
|
let mut g = rival_game(2);
|
||||||
|
let side = g.rivals[0].side;
|
||||||
|
let owner = Owner::of_side(side);
|
||||||
|
for _ in 0..(10 * 60 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
if g.time as i32 % 20 == 0 {
|
||||||
|
for (i, t) in g.map.t.iter().enumerate() {
|
||||||
|
if t.marked && t.mark_by == side {
|
||||||
|
let (x, y) = crate::map::xy(i);
|
||||||
|
let near = (-5..=5).any(|dy| (-5..=5).any(|dx| g.map.get(x + dx, y + dy).map_or(false, |o| o.owner == owner)));
|
||||||
|
assert!(near, "AI mark at {},{} far from its ground", x, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert!(g.rivals[0].radius > r0, "the rival dungeon grows");
|
}
|
||||||
assert!(attacked, "the rival sends creatures");
|
}
|
||||||
assert!(g.core_hp < core0, "they reach the player's core");
|
// a foreign mark next to the player's imps stays untouched
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let spot = (0..g.map.t.len()).find(|&i| {
|
||||||
|
let (x, y) = crate::map::xy(i);
|
||||||
|
(x - cx).abs() < 10 && (y - cy).abs() < 10 && g.map.t[i].kind == Kind::Earth && !g.map.t[i].marked
|
||||||
|
&& DIR4.iter().any(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |o| o.owner == Owner::Player && o.walkable()))
|
||||||
|
}).expect("earth beside the player's floor");
|
||||||
|
g.map.t[spot].reinforced = false;
|
||||||
|
g.map.t[spot].marked = true;
|
||||||
|
g.map.t[spot].mark_by = side;
|
||||||
|
for _ in 0..(60 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
assert_eq!(g.map.t[spot].kind, Kind::Earth, "the player's imps leave the AI's mark alone");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_ai_summons_new_imps_when_its_imps_die() {
|
||||||
|
let mut g = rival_game(1);
|
||||||
|
let side = g.rivals[0].side;
|
||||||
|
for _ in 0..(60 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
for e in g.ents.iter_mut().filter(|e| e.alive && e.side == side && e.kind == EK::Worker) {
|
||||||
|
e.hp = 0.0;
|
||||||
|
}
|
||||||
|
for _ in 0..20 {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
assert_eq!(g.ents.iter().filter(|e| e.alive && e.side == side && e.kind == EK::Worker).count(), 0, "all its imps are dead");
|
||||||
|
for _ in 0..(3 * 60 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
let imps = g.ents.iter().filter(|e| e.alive && e.side == side && e.kind == EK::Worker).count();
|
||||||
|
assert!(imps >= 2, "new imps (one per 80 mana, like the player): {}", imps);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ai_dig_marks_carry_the_masters_side() {
|
||||||
|
let mut g = rival_game(1);
|
||||||
|
let side = g.rivals[0].side;
|
||||||
|
for _ in 0..(40 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
assert!(g.map.t.iter().any(|t| t.marked && t.mark_by == side), "the AI marks in its own colour");
|
||||||
|
let back = crate::save::from_bytes(crate::save::to_json(&g).as_bytes()).ok().expect("loads").game;
|
||||||
|
assert_eq!(
|
||||||
|
back.map.t.iter().filter(|t| t.marked && t.mark_by == side).count(),
|
||||||
|
g.map.t.iter().filter(|t| t.marked && t.mark_by == side).count(),
|
||||||
|
"kept in the save"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -502,4 +560,170 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(fought, "heroes fight the rival master");
|
assert!(fought, "heroes fight the rival master");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hero_waves_march_on_the_strongest_master() {
|
||||||
|
let mut g = (1..200u32).map(|s| Game::new_free(s, 160, 160, 1)).find(|g| g.rivals.len() == 1 && g.gate_on).unwrap();
|
||||||
|
let side = g.rivals[0].side;
|
||||||
|
let (rx, ry) = g.rivals[0].centre();
|
||||||
|
let army = |g: &mut Game, side: u8, x: f32, y: f32, n: usize| {
|
||||||
|
for k in 0..n {
|
||||||
|
let id = g.spawn(EK::Ogre, false, x + k as f32 * 0.2, y, 6);
|
||||||
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
|
||||||
|
e.side = side;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// the rival master is far stronger: the wave marches on him and is measured against him
|
||||||
|
g.gold = 0.0;
|
||||||
|
army(&mut g, side, rx, ry + 2.0, 8);
|
||||||
|
assert_eq!(g.wave_goal(), side);
|
||||||
|
let before = g.ents.len();
|
||||||
|
g.spawn_wave();
|
||||||
|
let heroes: Vec<&Ent> = g.ents[before..].iter().filter(|e| e.hero).collect();
|
||||||
|
assert!(!heroes.is_empty());
|
||||||
|
assert!(heroes.iter().all(|e| e.target == Some(side)), "the wave marches on the rival");
|
||||||
|
// now the player is the strongest: the next wave comes for him
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
army(&mut g, 0, cx as f32 + 1.5, cy as f32 + 4.0, 20);
|
||||||
|
assert_eq!(g.wave_goal(), 0);
|
||||||
|
let before = g.ents.len();
|
||||||
|
g.spawn_wave();
|
||||||
|
assert!(g.ents[before..].iter().filter(|e| e.hero).all(|e| e.target.is_none()), "the wave comes for the player");
|
||||||
|
}
|
||||||
|
|
||||||
|
// the first mission of the co-op league, set up like an offline co-op game: player on side 0,
|
||||||
|
// an AI partner on side 2, no server
|
||||||
|
fn offline_league_game() -> Game {
|
||||||
|
let doc = crate::editor::coop_campaign::coop_campaign();
|
||||||
|
let mut g = crate::campaign::start_editor_coop_mission(&doc.missions[0], 0, 77);
|
||||||
|
g.coop = true;
|
||||||
|
assert!(g.start_match(&[0, 2], 0));
|
||||||
|
g.sync_ai_sides(&[2], 1);
|
||||||
|
g.end_match();
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_ai_partner_builds_only_what_is_researched() {
|
||||||
|
// the first missions of both co-op campaigns: no library, no forge yet
|
||||||
|
let doc = crate::editor::coop_campaign::coop_campaign();
|
||||||
|
let games = [
|
||||||
|
crate::campaign::start_editor_coop_mission(&doc.missions[0], 0, 77),
|
||||||
|
crate::campaign::start_coop_mission(crate::campaign::coop_mission_indexes()[0], None),
|
||||||
|
];
|
||||||
|
let lib = crate::ui::TOOLS.iter().position(|&t| t == crate::ui::Tool::Room(4)).unwrap();
|
||||||
|
let forge = crate::ui::TOOLS.iter().position(|&t| t == crate::ui::Tool::Room(8)).unwrap();
|
||||||
|
for mut g in games {
|
||||||
|
g.coop = true;
|
||||||
|
assert!(g.start_match(&[0, 2], 0));
|
||||||
|
g.sync_ai_sides(&[2], 1);
|
||||||
|
g.end_match();
|
||||||
|
let mine_knows = (g.tool_allowed(lib), g.tool_allowed(forge));
|
||||||
|
for _ in 0..(10 * 60 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
let owner = Owner::of_side(2);
|
||||||
|
let has = |k: Kind| g.map.t.iter().any(|t| t.kind == k && t.owner == owner);
|
||||||
|
if !mine_knows.0 {
|
||||||
|
assert!(!has(Kind::Library), "the partner has no library either");
|
||||||
|
}
|
||||||
|
if !mine_knows.1 {
|
||||||
|
assert!(!has(Kind::Forge), "the partner has no forge either");
|
||||||
|
}
|
||||||
|
assert!(has(Kind::Lair) || has(Kind::Treasury), "but builds what it knows");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imps_take_ground_from_enemies_but_never_from_allies() {
|
||||||
|
let mut g = offline_league_game();
|
||||||
|
assert!(g.allied(0, 2));
|
||||||
|
// a partner floor tile right beside the player's floor
|
||||||
|
let spot = (0..g.map.t.len()).find(|&i| {
|
||||||
|
let (x, y) = crate::map::xy(i);
|
||||||
|
g.map.t[i].kind == Kind::Floor && g.map.t[i].owner == Owner::Player
|
||||||
|
&& DIR4.iter().any(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |o| o.kind == Kind::Floor && o.owner == Owner::Player))
|
||||||
|
&& x > 2 && y > 2
|
||||||
|
}).expect("player floor");
|
||||||
|
let (x, y) = crate::map::xy(spot);
|
||||||
|
let n = DIR4.iter().map(|&(dx, dy)| crate::map::idx(x + dx, y + dy)).find(|&j| g.map.t[j].owner == Owner::Player).unwrap();
|
||||||
|
g.map.t[n].owner = Owner::of_side(2);
|
||||||
|
g.map.t[n].reserved = 0;
|
||||||
|
assert!(!g.claimable(n, 999), "an ally's floor stays his");
|
||||||
|
// the same tile of an enemy master may be taken
|
||||||
|
g.coop = false;
|
||||||
|
g.teams = vec![(0, 0), (2, 1)];
|
||||||
|
assert!(!g.allied(0, 2));
|
||||||
|
assert!(g.claimable(n, 999), "an enemy's floor can be claimed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_offline_coop_partner_is_an_allied_ai_master() {
|
||||||
|
let mut g = offline_league_game();
|
||||||
|
assert!(g.netplay.is_none() && g.is_master(2), "the partner is a real master without the server");
|
||||||
|
assert!(g.rival_alive(2) && g.is_partner(2));
|
||||||
|
assert!(g.allied(0, 2) && g.allied(2, 0));
|
||||||
|
// short of gold: the partner helps out within a minute
|
||||||
|
g.gold = 0.0;
|
||||||
|
for _ in 0..(65 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
assert!(g.msgs.iter().any(|m| m.key == "msg.gold_received"), "the partner sent gold");
|
||||||
|
assert_eq!(g.state, 0);
|
||||||
|
// the partner's creatures never fight the player's
|
||||||
|
assert!(!g.ents.iter().any(|e| e.alive && !e.hero && e.side == 0 && e.hp < e.max_hp && matches!(e.job, Job::Attack(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_partner_follows_the_attack_flag() {
|
||||||
|
let mut g = offline_league_game();
|
||||||
|
for _ in 0..(90 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
let (rx, ry) = g.rivals.iter().find(|r| r.side == 2).unwrap().centre();
|
||||||
|
// a flag on the partner's own ground a little away from its core
|
||||||
|
let f = (0..g.map.t.len())
|
||||||
|
.find(|&i| {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
let d2 = (x as f32 - rx).powi(2) + (y as f32 - ry).powi(2);
|
||||||
|
g.map.t[i].owner == Owner::of_side(2) && g.map.walk(x, y) && d2 > 16.0 && d2 < 36.0
|
||||||
|
})
|
||||||
|
.expect("partner ground");
|
||||||
|
g.flag = Some(f);
|
||||||
|
let (fx, fy) = xy(f);
|
||||||
|
let mut near = false;
|
||||||
|
for _ in 0..(30 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
near |= g.ents.iter().any(|e| e.alive && e.side == 2 && !e.hero && e.id % 3 != 0 && (e.x - fx as f32 - 0.5).powi(2) + (e.y - fy as f32 - 0.5).powi(2) < 3.0 * 3.0);
|
||||||
|
if near {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(near, "partner creatures gather at the flag");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn ai_master_report() {
|
||||||
|
for d in 0..3u8 {
|
||||||
|
let mut g = (1..200u32).map(|s| Game::new_free(s, 96, 96, d)).find(|g| g.rivals.len() == 1 && !g.gate_on).unwrap();
|
||||||
|
let side = g.rivals[0].side;
|
||||||
|
for m in 1..=15 {
|
||||||
|
for _ in 0..(60 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
g.switch_to(side);
|
||||||
|
let (rooms, imps, cr, gold) = (g.counts.iter().sum::<usize>(), g.worker_count(), g.creature_count(), g.gold);
|
||||||
|
let flag = g.flag.is_some();
|
||||||
|
let earned = g.stats.get(crate::stats::GOLD);
|
||||||
|
let me = g.me;
|
||||||
|
let veins = (0..g.map.t.len()).filter(|&i| g.map.t[i].marked && g.map.t[i].vein().is_some()).count();
|
||||||
|
let marked = g.map.t.iter().filter(|t| t.marked).count();
|
||||||
|
let near = (0..g.map.t.len()).filter(|&i| { let (x, y) = xy(i); g.map.t[i].marked && DIR4.iter().any(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |n| n.owner == me && n.walkable())) }).count();
|
||||||
|
g.switch_to(0);
|
||||||
|
println!("diff {} min {:2}: rooms {:3} imps {:2} creatures {:2} gold {:5.0} attacking {} | marked {} veins {} reachable {} earned {:.0} | player core {:.0}", d, m, rooms, imps, cr, gold, flag, marked, veins, near, earned, g.core_hp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
// The rogues of the later waves: the thief sneaks into a treasury and runs off with gold, the
|
||||||
|
// monk shrugs off spells, the ranger lays snares in the dungeon's corridors and the inquisitor
|
||||||
|
// now and then turns one of the weakest creatures (kobolds, spiders) to the heroes' side.
|
||||||
|
|
||||||
|
use crate::game::*;
|
||||||
|
use crate::map::*;
|
||||||
|
|
||||||
|
// (kind, first wave, first campaign mission (index)) - the wave pool takes them from then on
|
||||||
|
pub const ROGUES: [(EK, u32, usize); 4] = [(EK::Thief, 3, 4), (EK::Monk, 5, 8), (EK::Ranger, 7, 11), (EK::Inquisitor, 9, 15)];
|
||||||
|
|
||||||
|
// gold a thief can carry, and how fast he scoops it
|
||||||
|
pub const THIEF_BAG: i32 = 250;
|
||||||
|
const THIEF_GRAB: i32 = 25;
|
||||||
|
// a ranger's snares: one every SNARE_EVERY seconds, at most MAX_SNARES on the map
|
||||||
|
const SNARE_EVERY: f32 = 18.0;
|
||||||
|
const MAX_SNARES: usize = 30;
|
||||||
|
pub const SNARE_DAMAGE: f32 = 25.0;
|
||||||
|
pub const SNARE_STUN: f32 = 3.0;
|
||||||
|
// the inquisitor turns a weak creature every CONVERT_EVERY seconds, within CONVERT_RANGE tiles
|
||||||
|
pub const CONVERT_EVERY: f32 = 20.0;
|
||||||
|
const CONVERT_RANGE: f32 = 5.0;
|
||||||
|
|
||||||
|
// spells do not touch a monk of the order
|
||||||
|
pub fn spell_proof(e: &Ent) -> bool {
|
||||||
|
e.hero && e.kind == EK::Monk
|
||||||
|
}
|
||||||
|
|
||||||
|
// the creatures an inquisitor can turn: only the weakest
|
||||||
|
pub fn convertible(k: EK) -> bool {
|
||||||
|
matches!(k, EK::Kobold | EK::Spider)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn master_ground(t: &Tile) -> bool {
|
||||||
|
Owner::MASTER_SIDES.contains(&t.owner.side())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
// true: the rogue did all he wanted this step (the usual hero AI is skipped)
|
||||||
|
pub(crate) fn rogue_ai(&mut self, e: &mut Ent, dt: f32) -> bool {
|
||||||
|
if !e.active_hero() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match e.kind {
|
||||||
|
EK::Thief => self.thief_ai(e, dt),
|
||||||
|
EK::Ranger => {
|
||||||
|
self.lay_snare(e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
EK::Inquisitor => {
|
||||||
|
self.convert_creature(e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ thief
|
||||||
|
|
||||||
|
fn thief_ai(&mut self, e: &mut Ent, dt: f32) -> bool {
|
||||||
|
let cur = e.tile();
|
||||||
|
let full = e.carry >= THIEF_BAG;
|
||||||
|
// a full bag (or nothing left to steal): back to the gate and away
|
||||||
|
if full || (e.carry > 0 && !self.gold_to_steal(cur)) {
|
||||||
|
if !self.has_gate() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let (gx, gy) = self.gate_center();
|
||||||
|
if (e.x - gx).powi(2) + (e.y - gy).powi(2) < 2.0 * 2.0 {
|
||||||
|
e.alive = false;
|
||||||
|
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
|
||||||
|
self.tell_local("msg.thief_escaped", vec![e.carry.to_string()], Some(Sfx::Error));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if e.path.is_empty() && e.repath <= 0.0 {
|
||||||
|
e.repath = 1.5;
|
||||||
|
if let Some(p) = self.map.path_to(cur, idx(gx as i32, gy as i32)) {
|
||||||
|
e.path = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.step_path(e, dt);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// in a treasury with gold: scoop it up
|
||||||
|
let t = self.map.t[cur];
|
||||||
|
if t.kind == Kind::Treasury && t.gold > 0 && master_ground(&t) {
|
||||||
|
e.path.clear();
|
||||||
|
e.working = true;
|
||||||
|
if e.cd <= 0.0 {
|
||||||
|
e.cd = 0.5;
|
||||||
|
let want = THIEF_GRAB.min(THIEF_BAG - e.carry).min(t.gold);
|
||||||
|
let got = self.steal_gold(t.owner.side(), cur, want);
|
||||||
|
if got > 0 && e.carry == 0 && t.owner.side() == self.local_side {
|
||||||
|
self.tell_local("msg.thief_in_treasury", vec![], Some(Sfx::WaveAlarm));
|
||||||
|
}
|
||||||
|
e.carry += got;
|
||||||
|
self.fx(5, e.x, e.y, 0.0, 0.0, 0.5, 0xff30d0ff);
|
||||||
|
self.sfx(Sfx::Steal, e.x, e.y);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// on the way to the nearest treasury with gold
|
||||||
|
if e.path.is_empty() && e.repath <= 0.0 {
|
||||||
|
e.repath = 2.0;
|
||||||
|
let cost = self.map.hero_cost();
|
||||||
|
let found = self.map.search(cur, cost, |i| {
|
||||||
|
let t = &self.map.t[i];
|
||||||
|
t.kind == Kind::Treasury && t.gold > 0 && master_ground(t)
|
||||||
|
}, true);
|
||||||
|
match found {
|
||||||
|
Some(p) => e.path = p,
|
||||||
|
None if e.carry > 0 => {}
|
||||||
|
None => return false, // nothing to steal: he fights like the others
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.path.is_empty() {
|
||||||
|
return e.carry > 0;
|
||||||
|
}
|
||||||
|
self.step_path(e, dt);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// is there a treasury with gold anywhere a thief could reach from tile i?
|
||||||
|
fn gold_to_steal(&self, i: usize) -> bool {
|
||||||
|
let cost = self.map.hero_cost();
|
||||||
|
self.map.search(i, cost, |j| {
|
||||||
|
let t = &self.map.t[j];
|
||||||
|
t.kind == Kind::Treasury && t.gold > 0 && master_ground(t)
|
||||||
|
}, true).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
// takes gold from the treasury tile i of the master on `side`; returns what was taken
|
||||||
|
fn steal_gold(&mut self, side: u8, i: usize, want: i32) -> i32 {
|
||||||
|
let me = self.me_side;
|
||||||
|
if side != me {
|
||||||
|
if !self.is_master(side) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
self.switch_to(side);
|
||||||
|
}
|
||||||
|
let take = (want as f32).min(self.gold).floor().max(0.0) as i32;
|
||||||
|
self.gold -= take as f32;
|
||||||
|
let t = &mut self.map.t[i];
|
||||||
|
t.gold = (t.gold - take).max(0);
|
||||||
|
if side != me {
|
||||||
|
self.switch_to(me);
|
||||||
|
}
|
||||||
|
take
|
||||||
|
}
|
||||||
|
|
||||||
|
// a slain thief drops his loot: it goes to whoever slew him (with the bounty)
|
||||||
|
pub(crate) fn thief_loot(&self, id: u32) -> f32 {
|
||||||
|
self.ents.iter().find(|e| e.id == id).map_or(0.0, |e| e.carry.max(0) as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ ranger
|
||||||
|
|
||||||
|
// a snare on the dungeon's ground under the ranger's feet, now and then
|
||||||
|
fn lay_snare(&mut self, e: &mut Ent) {
|
||||||
|
if e.skill_cd > 0.0 || self.snares.len() >= MAX_SNARES {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let i = e.tile();
|
||||||
|
let t = &self.map.t[i];
|
||||||
|
if !t.walkable() || !master_ground(t) || t.bridge() || self.snares.contains(&i) || self.traps.iter().any(|tr| tr.tile == i) {
|
||||||
|
e.skill_cd = 2.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.snares.push(i);
|
||||||
|
e.skill_cd = SNARE_EVERY;
|
||||||
|
self.fx(6, e.x, e.y, 0.0, 0.0, 0.4, 0xff3a5a7a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the shared step: a creature that steps into a snare is caught (hurt and stuck for a while)
|
||||||
|
pub(crate) fn update_snares(&mut self) {
|
||||||
|
if self.snares.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut k = 0;
|
||||||
|
while k < self.snares.len() {
|
||||||
|
let i = self.snares[k];
|
||||||
|
if !self.map.t[i].walkable() {
|
||||||
|
self.snares.swap_remove(k);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let caught = self.ents.iter().position(|e| {
|
||||||
|
e.alive && !e.held && !e.hero && !e.chained && e.captive == 0 && !flies(e.kind) && Owner::MASTER_SIDES.contains(&e.side) && e.tile() == i
|
||||||
|
});
|
||||||
|
let Some(j) = caught else {
|
||||||
|
k += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.snares.swap_remove(k);
|
||||||
|
let e = &mut self.ents[j];
|
||||||
|
e.stun = e.stun.max(SNARE_STUN);
|
||||||
|
e.hp -= SNARE_DAMAGE;
|
||||||
|
e.bane = crate::names::BANE_SNARE;
|
||||||
|
e.flash = 0.2;
|
||||||
|
let (x, y, side, kind, dead) = (e.x, e.y, e.side, e.kind, e.hp <= 0.0);
|
||||||
|
self.fx(2, x, y, 0.0, 0.0, 0.4, 0xff4040ff);
|
||||||
|
self.sfx(Sfx::Snare, x, y);
|
||||||
|
if side == self.local_side {
|
||||||
|
self.tell_local("msg.snare_caught", vec![format!("@{}", st(kind).name)], None);
|
||||||
|
}
|
||||||
|
if dead {
|
||||||
|
self.kill(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ inquisitor
|
||||||
|
|
||||||
|
// turns the nearest weak creature in sight to the heroes' side
|
||||||
|
fn convert_creature(&mut self, e: &mut Ent) {
|
||||||
|
if e.skill_cd > 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.skill_cd = 1.0;
|
||||||
|
let target = self
|
||||||
|
.ents
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, o)| o.alive && !o.held && !o.hero && !o.chained && o.captive == 0 && convertible(o.kind) && Owner::MASTER_SIDES.contains(&o.side))
|
||||||
|
.map(|(j, o)| (j, (o.x - e.x).powi(2) + (o.y - e.y).powi(2)))
|
||||||
|
.filter(|&(j, d2)| d2 < CONVERT_RANGE * CONVERT_RANGE && self.los(e.x, e.y, self.ents[j].x, self.ents[j].y))
|
||||||
|
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||||
|
.map(|(j, _)| j);
|
||||||
|
let Some(j) = target else { return };
|
||||||
|
e.skill_cd = CONVERT_EVERY;
|
||||||
|
let goal = e.target;
|
||||||
|
let (side, kind, id, x, y) = {
|
||||||
|
let o = &self.ents[j];
|
||||||
|
(o.side, o.kind, o.id, o.x, o.y)
|
||||||
|
};
|
||||||
|
self.release_all(id);
|
||||||
|
let o = &mut self.ents[j];
|
||||||
|
o.hero = true;
|
||||||
|
o.side = 1;
|
||||||
|
o.job = Job::None;
|
||||||
|
o.path.clear();
|
||||||
|
o.home = None;
|
||||||
|
o.bed = None;
|
||||||
|
o.guard = false;
|
||||||
|
o.target = goal;
|
||||||
|
o.waypoint = None;
|
||||||
|
o.flash = 0.4;
|
||||||
|
self.shot(e.x, e.y, 0.6, x, y, 0.45, 0.35, 0xff40d0ff);
|
||||||
|
self.style_last_fx(crate::particles::ST_HOLY);
|
||||||
|
self.fx(4, x, y, 0.0, 0.0, 0.9, 0xffffeecc);
|
||||||
|
self.sfx(Sfx::Sermon, x, y);
|
||||||
|
if side == self.local_side {
|
||||||
|
self.tell_local("msg.converted", vec![format!("@{}", st(kind).name)], Some(Sfx::WaveAlarm));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// a free game with an open dungeon around the core and no waves
|
||||||
|
fn open_game() -> Game {
|
||||||
|
let mut g = Game::new(5, 96, 96);
|
||||||
|
g.gate_on = false;
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
for y in cy - 8..cy + 10 {
|
||||||
|
for x in cx - 8..cx + 10 {
|
||||||
|
let t = &mut g.map.t[idx(x, y)];
|
||||||
|
if t.kind != Kind::Core {
|
||||||
|
t.kind = Kind::Floor;
|
||||||
|
t.owner = Owner::Player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.ents.clear();
|
||||||
|
g.recount();
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hero(g: &mut Game, k: EK, x: f32, y: f32) -> u32 {
|
||||||
|
let id = g.spawn(k, true, x, y, 1);
|
||||||
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
|
||||||
|
e.job = Job::None;
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_thief_steals_from_the_treasury() {
|
||||||
|
let mut g = open_game();
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
g.build_rect(0, cx + 4, cy + 4, cx + 6, cy + 6);
|
||||||
|
g.gold = 3000.0;
|
||||||
|
g.sync_treasury();
|
||||||
|
let gold0 = g.gold;
|
||||||
|
let id = hero(&mut g, EK::Thief, cx as f32 - 5.5, cy as f32 + 5.5);
|
||||||
|
let mut carried = 0;
|
||||||
|
for _ in 0..(60 * 20) {
|
||||||
|
g.state = 0; // no creatures in the test: it would count as over
|
||||||
|
g.update(0.05);
|
||||||
|
carried = g.ents.iter().find(|e| e.id == id).map_or(carried, |e| e.carry);
|
||||||
|
if carried >= THIEF_BAG {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(carried > 0, "the thief took gold");
|
||||||
|
assert!(g.gold < gold0, "the master lost it: {} -> {}", gold0, g.gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monks_shrug_off_spells() {
|
||||||
|
let mut g = open_game();
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let monk = hero(&mut g, EK::Monk, cx as f32 + 6.5, cy as f32 + 6.5);
|
||||||
|
let knight = hero(&mut g, EK::Knight, cx as f32 + 6.5, cy as f32 + 7.0);
|
||||||
|
g.mana = 400.0;
|
||||||
|
g.lightning(cx as f32 + 6.5, cy as f32 + 6.7);
|
||||||
|
let hp = |g: &Game, id: u32| g.ents.iter().find(|e| e.id == id).map_or(0.0, |e| e.hp);
|
||||||
|
assert_eq!(hp(&g, monk), st(EK::Monk).hp, "the monk is untouched");
|
||||||
|
assert!(hp(&g, knight) < st(EK::Knight).hp, "the knight is not");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rangers_snare_catches_a_creature() {
|
||||||
|
let mut g = open_game();
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let r = hero(&mut g, EK::Ranger, cx as f32 + 6.5, cy as f32 + 6.5);
|
||||||
|
let mut e = g.ents.iter().find(|e| e.id == r).unwrap().clone();
|
||||||
|
e.skill_cd = 0.0;
|
||||||
|
g.lay_snare(&mut e);
|
||||||
|
let i = idx(cx + 6, cy + 6);
|
||||||
|
assert!(g.snares.contains(&i));
|
||||||
|
g.ents.retain(|e| e.id != r);
|
||||||
|
let k = g.spawn(EK::Ogre, false, cx as f32 + 6.5, cy as f32 + 6.5, 1);
|
||||||
|
g.update_snares();
|
||||||
|
let o = g.ents.iter().find(|e| e.id == k).unwrap();
|
||||||
|
assert!(o.stun > 0.0 && o.hp < st(EK::Ogre).hp, "caught");
|
||||||
|
assert!(g.snares.is_empty(), "a snare works once");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_inquisitor_turns_only_weak_creatures() {
|
||||||
|
let mut g = open_game();
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
let ogre = g.spawn(EK::Ogre, false, cx as f32 + 5.5, cy as f32 + 6.0, 1);
|
||||||
|
let kobold = g.spawn(EK::Kobold, false, cx as f32 + 6.5, cy as f32 + 7.5, 1);
|
||||||
|
let q = hero(&mut g, EK::Inquisitor, cx as f32 + 6.5, cy as f32 + 5.5);
|
||||||
|
let mut e = g.ents.iter().find(|e| e.id == q).unwrap().clone();
|
||||||
|
e.skill_cd = 0.0;
|
||||||
|
g.convert_creature(&mut e);
|
||||||
|
let get = |g: &Game, id: u32| g.ents.iter().find(|e| e.id == id).unwrap().clone();
|
||||||
|
assert!(get(&g, kobold).hero, "the kobold now fights for the heroes");
|
||||||
|
assert!(!get(&g, ogre).hero, "an ogre is too strong to be turned");
|
||||||
|
assert_eq!(e.skill_cd, CONVERT_EVERY);
|
||||||
|
// nothing else to turn: the ogre stays
|
||||||
|
e.skill_cd = 0.0;
|
||||||
|
g.convert_creature(&mut e);
|
||||||
|
assert!(!get(&g, ogre).hero);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rogues_join_the_later_waves() {
|
||||||
|
for (k, wave, mission) in ROGUES {
|
||||||
|
assert!(wave >= 3 && mission >= 4, "{:?} comes late", k);
|
||||||
|
}
|
||||||
|
assert!(ROGUES.iter().any(|r| r.0 == EK::Inquisitor && r.1 == 9));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// Savegames: JSON with format tag and version, containing the game state, the camera
|
// Savegames: JSON with format tag and version, containing the game state - nothing else. The
|
||||||
// and the player's settings (settings are always applied when a savegame is loaded).
|
// player's settings live in the browser's local storage; settings in older savegames are
|
||||||
|
// ignored. Only the screenshot scenes (scenes.rs) also carry a camera.
|
||||||
|
|
||||||
use crate::game::Game;
|
use crate::game::Game;
|
||||||
use crate::render3d::Cam;
|
use crate::render3d::Cam;
|
||||||
use crate::settings::Settings;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub const FORMAT: &str = "dungeoncraft-save";
|
pub const FORMAT: &str = "dungeoncraft-save";
|
||||||
@@ -15,8 +15,8 @@ struct SaveOut<'a> {
|
|||||||
format: &'a str,
|
format: &'a str,
|
||||||
version: u32,
|
version: u32,
|
||||||
game: &'a Game,
|
game: &'a Game,
|
||||||
camera: &'a Cam,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
settings: &'a Settings,
|
camera: Option<&'a Cam>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -25,17 +25,21 @@ struct SaveIn {
|
|||||||
version: u32,
|
version: u32,
|
||||||
game: Game,
|
game: Game,
|
||||||
camera: Option<Cam>,
|
camera: Option<Cam>,
|
||||||
settings: Option<Settings>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Loaded {
|
pub struct Loaded {
|
||||||
pub game: Game,
|
pub game: Game,
|
||||||
pub camera: Option<Cam>,
|
pub camera: Option<Cam>,
|
||||||
pub settings: Option<Settings>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_json(game: &Game, camera: &Cam, settings: &Settings) -> String {
|
pub fn to_json(game: &Game) -> String {
|
||||||
serde_json::to_string(&SaveOut { format: FORMAT, version: VERSION, game, camera, settings }).unwrap_or_default()
|
serde_json::to_string(&SaveOut { format: FORMAT, version: VERSION, game, camera: None }).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// a screenshot scene: the game and where the camera looks
|
||||||
|
#[allow(dead_code)] // scenes.rs (tests) only
|
||||||
|
pub fn to_json_with_camera(game: &Game, camera: &Cam) -> String {
|
||||||
|
serde_json::to_string(&SaveOut { format: FORMAT, version: VERSION, game, camera: Some(camera) }).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Err contains a language key describing the problem
|
// Err contains a language key describing the problem
|
||||||
@@ -48,9 +52,15 @@ pub fn from_bytes(data: &[u8]) -> Result<Loaded, &'static str> {
|
|||||||
return Err("save.err_version");
|
return Err("save.err_version");
|
||||||
}
|
}
|
||||||
let mut game = s.game;
|
let mut game = s.game;
|
||||||
|
// older saves kept the dwarves on side 4, which is now the fourth master
|
||||||
|
if !game.is_master(4) && !game.rivals.iter().any(|r| r.side == 4) {
|
||||||
|
for e in game.ents.iter_mut().filter(|e| !e.hero && e.side == 4) {
|
||||||
|
e.side = crate::dwarves::DWARF_SIDE;
|
||||||
|
}
|
||||||
|
}
|
||||||
game.map.activate();
|
game.map.activate();
|
||||||
game.recount();
|
game.recount();
|
||||||
Ok(Loaded { game, camera: s.camera, settings: s.settings })
|
Ok(Loaded { game, camera: s.camera })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -65,18 +75,14 @@ mod tests {
|
|||||||
for _ in 0..3000 {
|
for _ in 0..3000 {
|
||||||
g.update(0.05);
|
g.update(0.05);
|
||||||
}
|
}
|
||||||
let cam = Cam::new(10.0, 20.0);
|
let json = to_json(&g);
|
||||||
let mut settings = Settings::default();
|
|
||||||
settings.language = "de".into();
|
|
||||||
let json = to_json(&g, &cam, &settings);
|
|
||||||
println!("savegame size: {} KB", json.len() / 1024);
|
println!("savegame size: {} KB", json.len() / 1024);
|
||||||
let l = from_bytes(json.as_bytes()).expect("load");
|
let l = from_bytes(json.as_bytes()).expect("load");
|
||||||
assert_eq!(l.game.time, g.time);
|
assert_eq!(l.game.time, g.time);
|
||||||
assert_eq!(l.game.ents.len(), g.ents.len());
|
assert_eq!(l.game.ents.len(), g.ents.len());
|
||||||
assert_eq!(l.game.gold, g.gold);
|
assert_eq!(l.game.gold, g.gold);
|
||||||
assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.kind == b.kind && a.owner == b.owner && a.marked == b.marked));
|
assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.kind == b.kind && a.owner == b.owner && a.marked == b.marked));
|
||||||
assert_eq!(l.settings.unwrap().language, "de");
|
assert!(l.camera.is_none() && !json.contains("\"settings\"") && !json.contains("\"camera\""), "only the game state");
|
||||||
assert_eq!(l.camera.unwrap().tx, 10.0);
|
|
||||||
assert_eq!((l.game.map.w, l.game.map.h), (96, 128));
|
assert_eq!((l.game.map.w, l.game.map.h), (96, 128));
|
||||||
assert_eq!(l.game.map.portals.len(), g.map.portals.len());
|
assert_eq!(l.game.map.portals.len(), g.map.portals.len());
|
||||||
assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.var == b.var && a.hp == b.hp && a.food == b.food && a.gold == b.gold));
|
assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.var == b.var && a.hp == b.hp && a.food == b.food && a.gold == b.gold));
|
||||||
@@ -107,7 +113,7 @@ mod tests {
|
|||||||
g.update(0.05);
|
g.update(0.05);
|
||||||
}
|
}
|
||||||
let sim = t1.elapsed();
|
let sim = t1.elapsed();
|
||||||
let json = to_json(&g, &Cam::new(0.0, 0.0), &Settings::default());
|
let json = to_json(&g);
|
||||||
println!("256x256: generate {:?}, 2 min simulated in {:?}, savegame {} KB", gen, sim, json.len() / 1024);
|
println!("256x256: generate {:?}, 2 min simulated in {:?}, savegame {} KB", gen, sim, json.len() / 1024);
|
||||||
let l = from_bytes(json.as_bytes()).expect("load");
|
let l = from_bytes(json.as_bytes()).expect("load");
|
||||||
assert_eq!(l.game.map.t.len(), 256 * 256);
|
assert_eq!(l.game.map.t.len(), 256 * 256);
|
||||||
@@ -132,7 +138,7 @@ mod tests {
|
|||||||
let (ex, ey) = (cx + 1, cy + 1);
|
let (ex, ey) = (cx + 1, cy + 1);
|
||||||
let mut cam = Cam::new(ex as f32 + 0.5, ey as f32 + 0.5);
|
let mut cam = Cam::new(ex as f32 + 0.5, ey as f32 + 0.5);
|
||||||
cam.zoom = 12.0;
|
cam.zoom = 12.0;
|
||||||
std::fs::write(path, to_json(&g, &cam, &Settings { language: "en".into(), ..Settings::default() })).unwrap();
|
std::fs::write(path, to_json_with_camera(&g, &cam)).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ fn save(name: &str, g: &mut Game, x: f32, z: f32, zoom: f32, yaw: f32, fog: bool
|
|||||||
let mut s = Settings::default();
|
let mut s = Settings::default();
|
||||||
s.language = "en".into();
|
s.language = "en".into();
|
||||||
s.hints = false;
|
s.hints = false;
|
||||||
std::fs::write(format!("/tmp/scenes/{}.json", name), crate::save::to_json(g, &cam, &s)).unwrap();
|
std::fs::write(format!("/tmp/scenes/{}.json", name), crate::save::to_json_with_camera(g, &cam)).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(g: &mut Game, secs: f32) {
|
fn run(g: &mut Game, secs: f32) {
|
||||||
@@ -162,4 +162,142 @@ fn export_scenes() {
|
|||||||
g.place_all_torches();
|
g.place_all_torches();
|
||||||
run(&mut g, 4.0);
|
run(&mut g, 4.0);
|
||||||
save("lava-light", &mut g, cx as f32 + 5.0, cy as f32, 22.0, 0.0, false);
|
save("lava-light", &mut g, cx as f32 + 5.0, cy as f32, 22.0, 0.0, false);
|
||||||
|
|
||||||
|
// bridges: a raised and a lowered drawbridge over water, a burning bridge, obsidian on lava
|
||||||
|
let mut g = Game::new(17, 80, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
g.gold = 50000.0;
|
||||||
|
open(&mut g, cx - 6, cy - 9, cx + 18, cy + 9);
|
||||||
|
for y in cy - 9..=cy + 9 {
|
||||||
|
for x in cx + 6..cx + 9 {
|
||||||
|
let t = &mut g.map.t[idx(x, y)];
|
||||||
|
t.kind = Kind::Water;
|
||||||
|
t.owner = Owner::None;
|
||||||
|
}
|
||||||
|
for x in cx + 12..cx + 15 {
|
||||||
|
let t = &mut g.map.t[idx(x, y)];
|
||||||
|
t.kind = Kind::Lava;
|
||||||
|
t.owner = Owner::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.drawbridge_order(cx + 5, cy - 5, cx + 9, cy - 5);
|
||||||
|
g.drawbridge_order(cx + 5, cy - 1, cx + 9, cy - 1);
|
||||||
|
g.drawbridge_order(cx + 6, cy - 1, cx + 6, cy - 1); // raised
|
||||||
|
g.build_bridges(cx + 5, cy + 4, cx + 9, cy + 4);
|
||||||
|
g.ignite_at(cx as f32 + 7.5, cy as f32 + 4.5);
|
||||||
|
g.cool_lava_around(cx as f32 + 13.5, cy as f32 + 1.5, 2.5, 60.0);
|
||||||
|
g.place_all_torches();
|
||||||
|
run(&mut g, 2.5);
|
||||||
|
save("bridges", &mut g, cx as f32 + 9.0, cy as f32, 18.0, 0.0, false);
|
||||||
|
|
||||||
|
// wood: giant mushrooms in a cavern, an imp felling one; planned walls
|
||||||
|
let mut g = Game::new(9, 96, 96);
|
||||||
|
let m = g.map.t.iter().position(|t| t.kind == Kind::Mushroom).unwrap();
|
||||||
|
let (mx, my) = xy(m);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
g.gold = 5000.0;
|
||||||
|
open(&mut g, cx - 2, cy - 2, cx + 6, cy + 4);
|
||||||
|
g.plan_walls(cx + 5, cy - 1, cx + 5, cy + 2);
|
||||||
|
run(&mut g, 2.0);
|
||||||
|
save("wood", &mut g, mx as f32 + 0.5, my as f32 + 0.5, 14.0, 0.0, false);
|
||||||
|
|
||||||
|
// above ground: the cliff with a cave mouth, grass, a forest
|
||||||
|
let mut g = Game::new(23, 128, 96);
|
||||||
|
g.add_surface(23, 0.45, 3);
|
||||||
|
g.init_fog();
|
||||||
|
let mid = (mh() / 2) as i32;
|
||||||
|
let ex = (0..mw() as i32).find(|&x| g.map.sky(idx(x, mid))).unwrap_or(60);
|
||||||
|
run(&mut g, 1.0);
|
||||||
|
save("surface", &mut g, ex as f32 + 6.0, mid as f32, 24.0, 0.0, false);
|
||||||
|
|
||||||
|
// room levels: a lair at level 3, a treasury at level 2 next to plain rooms
|
||||||
|
let mut g = Game::new(11, 80, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
g.gold = 50000.0;
|
||||||
|
open(&mut g, cx - 9, cy - 8, cx + 11, cy + 9);
|
||||||
|
g.build_rect(1, cx - 8, cy - 7, cx - 4, cy - 3);
|
||||||
|
g.build_rect(0, cx + 5, cy - 7, cx + 9, cy - 4);
|
||||||
|
g.build_rect(2, cx - 8, cy + 4, cx - 3, cy + 8);
|
||||||
|
g.set_room_level(1, 3);
|
||||||
|
g.set_room_level(0, 2);
|
||||||
|
g.place_all_torches();
|
||||||
|
run(&mut g, 1.0);
|
||||||
|
save("room-levels", &mut g, cx as f32 + 1.5, cy as f32 - 2.0, 16.0, 0.0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a mushroom grove: moss, grown (glowing) and young mushrooms
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn export_grove_scene() {
|
||||||
|
std::fs::create_dir_all("/tmp/scenes").unwrap();
|
||||||
|
let mut g = Game::new(9, 96, 96);
|
||||||
|
// the grove tile with the most grove around it
|
||||||
|
let best = (0..g.map.t.len())
|
||||||
|
.filter(|&i| g.map.t[i].kind == Kind::Grove)
|
||||||
|
.max_by_key(|&i| {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
(-3..=3).flat_map(|dy| (-3..=3).map(move |dx| (dx, dy))).filter(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |t| matches!(t.kind, Kind::Grove | Kind::Mushroom))).count()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let (x, y) = xy(best);
|
||||||
|
run(&mut g, 1.0);
|
||||||
|
save("mushroom-grove", &mut g, x as f32 + 0.5, y as f32 + 0.5, 12.0, 0.0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the rogues of the later waves in a dungeon: thief, monk, ranger, inquisitor and a snare
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn export_rogues_scene() {
|
||||||
|
std::fs::create_dir_all("/tmp/scenes").unwrap();
|
||||||
|
let mut g = Game::new(11, 80, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
open(&mut g, cx - 9, cy - 8, cx + 11, cy + 9);
|
||||||
|
g.place_all_torches();
|
||||||
|
for (k, kind) in [EK::Thief, EK::Monk, EK::Ranger, EK::Inquisitor].into_iter().enumerate() {
|
||||||
|
let id = g.spawn(kind, true, cx as f32 + 4.5 + k as f32 * 1.2, cy as f32 + 5.5, 3);
|
||||||
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
|
||||||
|
e.guard = true;
|
||||||
|
e.home = Some((e.x, e.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.snares.push(idx(cx + 5, cy + 3));
|
||||||
|
g.snares.push(idx(cx + 7, cy + 3));
|
||||||
|
save("rogues", &mut g, cx as f32 + 6.5, cy as f32 + 4.5, 8.0, 0.0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// weather above ground: rain and fog over the land
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn export_weather_scenes() {
|
||||||
|
std::fs::create_dir_all("/tmp/scenes").unwrap();
|
||||||
|
for (name, kind) in [("weather-rain", crate::weather::RAIN), ("weather-fog", crate::weather::FOG), ("weather-storm", crate::weather::STORM)] {
|
||||||
|
let mut g = Game::new(7, 96, 96);
|
||||||
|
g.add_surface(7, 0.5, 3);
|
||||||
|
g.weather = crate::weather::Weather { kind, shown: kind, level: 1.0, left: 900.0, ..Default::default() };
|
||||||
|
// the camera on the land above ground, near the edge of the dungeon side
|
||||||
|
let i = (0..g.map.t.len()).filter(|&i| g.map.sky(i)).min_by_key(|&i| {
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
(x - 52).abs() + (y - 48).abs()
|
||||||
|
}).unwrap();
|
||||||
|
let (x, y) = xy(i);
|
||||||
|
save(name, &mut g, x as f32 + 4.5, y as f32 + 0.5, 18.0, 0.0, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// named creatures and memorial stones of the fallen
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn export_names_scene() {
|
||||||
|
std::fs::create_dir_all("/tmp/scenes").unwrap();
|
||||||
|
let mut g = Game::new(11, 80, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
open(&mut g, cx - 9, cy - 8, cx + 11, cy + 9);
|
||||||
|
g.place_all_torches();
|
||||||
|
creatures(&mut g, &[(EK::Ogre, 9), (EK::Mage, 5), (EK::Kobold, 2)], cx as f32 + 6.5, cy as f32 + 5.0);
|
||||||
|
for (k, &(kind, lv)) in [(EK::Spider, 4), (EK::Ogre, 8), (EK::Mage, 6)].iter().enumerate() {
|
||||||
|
let id = g.spawn(kind, false, cx as f32 + 2.5 + k as f32 * 2.0, cy as f32 + 6.5, lv);
|
||||||
|
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
||||||
|
g.kill(j);
|
||||||
|
}
|
||||||
|
save("names", &mut g, cx as f32 + 5.0, cy as f32 + 5.5, 9.0, 0.0, false);
|
||||||
}
|
}
|
||||||
@@ -137,6 +137,7 @@ pub struct Settings {
|
|||||||
pub music_volume: f32,
|
pub music_volume: f32,
|
||||||
pub muted: bool,
|
pub muted: bool,
|
||||||
pub voice: bool, // Crookfinger's babbling voice
|
pub voice: bool, // Crookfinger's babbling voice
|
||||||
|
pub music_track: String, // music player: "" = automatic, else the id of a calm track that replaces the calm music
|
||||||
// camera
|
// camera
|
||||||
pub rotate_speed: f32,
|
pub rotate_speed: f32,
|
||||||
pub pan_speed: f32,
|
pub pan_speed: f32,
|
||||||
@@ -147,8 +148,9 @@ pub struct Settings {
|
|||||||
pub outlines: bool,
|
pub outlines: bool,
|
||||||
pub wall_detail: u8, // 1 low, 2 medium, 3 high
|
pub wall_detail: u8, // 1 low, 2 medium, 3 high
|
||||||
pub max_lights: u8, // 8, 16 or 24
|
pub max_lights: u8, // 8, 16 or 24
|
||||||
pub ui_factor: u8, // interface size: 0 = automatic, 1..4 = fixed integer factor
|
pub ui_factor: u8, // interface size: 0 = automatic, 2 large, 3 medium, 4 small (interface pixels per world pixel)
|
||||||
pub render_scale: u8, // 3D world resolution divisor: 1 = full, 2..16 = pixel look
|
pub render_scale: u8, // old pixel-look divisor (no longer used, kept for old settings files)
|
||||||
|
pub pixel_rows: u16, // pixel look: rows of the world on every screen (270, 360, 480)
|
||||||
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_frame_time: bool, // show the frame-time line and graph
|
||||||
@@ -161,7 +163,10 @@ pub struct Settings {
|
|||||||
pub hints: bool,
|
pub hints: bool,
|
||||||
pub difficulty: u8, // rival masters in free games: 0 easy, 1 medium, 2 hard
|
pub difficulty: u8, // rival masters in free games: 0 easy, 1 medium, 2 hard
|
||||||
pub map_w: usize, // size of new maps (64..256)
|
pub map_w: usize, // size of new maps (64..256)
|
||||||
|
pub match_size: usize, // versus matches: the map is match_size x match_size (see lobby::seats_for)
|
||||||
pub map_h: usize,
|
pub map_h: usize,
|
||||||
|
pub surface: bool, // new free games: the eastern half lies above ground (forest)
|
||||||
|
pub events: bool, // random events in free games and versus matches
|
||||||
// key bindings: action id -> key name
|
// key bindings: action id -> key name
|
||||||
pub keys: BTreeMap<String, String>,
|
pub keys: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
@@ -174,6 +179,7 @@ impl Default for Settings {
|
|||||||
music_volume: 0.6,
|
music_volume: 0.6,
|
||||||
muted: false,
|
muted: false,
|
||||||
voice: true,
|
voice: true,
|
||||||
|
music_track: String::new(),
|
||||||
rotate_speed: 1.0,
|
rotate_speed: 1.0,
|
||||||
pan_speed: 1.25,
|
pan_speed: 1.25,
|
||||||
zoom_speed: 1.0,
|
zoom_speed: 1.0,
|
||||||
@@ -184,6 +190,7 @@ impl Default for Settings {
|
|||||||
max_lights: 24,
|
max_lights: 24,
|
||||||
ui_factor: 0,
|
ui_factor: 0,
|
||||||
render_scale: 1,
|
render_scale: 1,
|
||||||
|
pixel_rows: 360,
|
||||||
touch_ui: 0,
|
touch_ui: 0,
|
||||||
debug_fps: false,
|
debug_fps: false,
|
||||||
debug_frame_time: false,
|
debug_frame_time: false,
|
||||||
@@ -195,7 +202,10 @@ impl Default for Settings {
|
|||||||
hints: true,
|
hints: true,
|
||||||
difficulty: 1,
|
difficulty: 1,
|
||||||
map_w: 64,
|
map_w: 64,
|
||||||
|
match_size: 128,
|
||||||
map_h: 64,
|
map_h: 64,
|
||||||
|
surface: false,
|
||||||
|
events: true,
|
||||||
keys: BTreeMap::new(),
|
keys: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,31 @@ fn def(s: Sfx, r: f32) -> Vec<f32> {
|
|||||||
Sfx::Voice4 => l(&[seg(Square(0.15), 900.0, 980.0, 0.05, 0.13, 10.0), vib(seg(Square(0.2), 700.0, 560.0, 0.06, 0.14, 8.0), 0.04)], 65),
|
Sfx::Voice4 => l(&[seg(Square(0.15), 900.0, 980.0, 0.05, 0.13, 10.0), vib(seg(Square(0.2), 700.0, 560.0, 0.06, 0.14, 8.0), 0.04)], 65),
|
||||||
Sfx::Voice5 => mix(vec![l(&[vib(seg(Square(0.25), 470.0, 560.0, 0.1, 0.15, 5.0), 0.06)], 66), l(&[seg(Noise, 3000.0, 2000.0, 0.025, 0.06, 30.0)], 67)]),
|
Sfx::Voice5 => mix(vec![l(&[vib(seg(Square(0.25), 470.0, 560.0, 0.1, 0.15, 5.0), 0.06)], 66), l(&[seg(Noise, 3000.0, 2000.0, 0.025, 0.06, 30.0)], 67)]),
|
||||||
Sfx::Voice6 => l(&[vib(seg(Square(0.2), 680.0, 820.0, 0.06, 0.15, 9.0), 0.05), seg(Square(0.2), 820.0, 700.0, 0.04, 0.12, 12.0)], 68),
|
Sfx::Voice6 => l(&[vib(seg(Square(0.2), 680.0, 820.0, 0.06, 0.15, 9.0), 0.05), seg(Square(0.2), 820.0, 700.0, 0.04, 0.12, 12.0)], 68),
|
||||||
|
// a long rumble rolling away after the crack
|
||||||
|
Sfx::Thunder => mix(vec![
|
||||||
|
l(&[seg(Noise, 420.0, 70.0, 2.4, 0.55, 1.1)], 70),
|
||||||
|
l(&[vib(seg(Tri, 55.0, 32.0, 2.2, 0.5, 1.2), 0.08)], 71),
|
||||||
|
l(&[seg(Noise, 160.0, 160.0, 0.35, 0.0, 0.0), seg(Noise, 160.0, 90.0, 1.4, 0.3, 1.6)], 72),
|
||||||
|
]),
|
||||||
|
// the thief's fingers in the coins: a quick, soft jingle
|
||||||
|
Sfx::Steal => mix(vec![l(&arp(Square(0.25), &[2093.0, 1760.0, 2349.0], 0.04, 0.1), 73), l(&[seg(Noise, 7000.0, 7000.0, 0.03, 0.08, 60.0)], 74)]),
|
||||||
|
// iron jaws snap shut
|
||||||
|
Sfx::Snare => mix(vec![l(&[seg(Noise, 8000.0, 3000.0, 0.04, 0.5, 50.0)], 75), l(&[seg(Square(0.5), 1400.0, 300.0, 0.09, 0.22, 25.0)], 76)]),
|
||||||
|
// the inquisitor's sermon: a short, hollow chant
|
||||||
|
Sfx::Sermon => mix(vec![
|
||||||
|
l(&[vib(seg(Sine, 330.0, 330.0, 0.7, 0.28, 2.0), 0.02)], 77),
|
||||||
|
l(&[vib(seg(Sine, 392.0, 415.0, 0.7, 0.2, 2.0), 0.02)], 78),
|
||||||
|
l(&[vib(seg(Sine, 494.0, 494.0, 0.7, 0.16, 2.0), 0.03)], 79),
|
||||||
|
]),
|
||||||
|
// rain on the land: fades in and out, played over and over while it rains
|
||||||
|
Sfx::Rain => mix(vec![
|
||||||
|
l(&[seg(Noise, 7000.0, 7000.0, 0.3, 0.04, -6.0), seg(Noise, 7000.0, 7000.0, 0.9, 0.24, 0.0), seg(Noise, 7000.0, 7000.0, 0.3, 0.24, 6.0)], 80),
|
||||||
|
l(&[seg(Noise, 2500.0, 2500.0, 0.3, 0.02, -6.0), seg(Noise, 2500.0, 2500.0, 0.9, 0.12, 0.0), seg(Noise, 2500.0, 2500.0, 0.3, 0.12, 6.0)], 81),
|
||||||
|
]),
|
||||||
|
// wind over the land: a slow, howling sweep
|
||||||
|
Sfx::Wind => l(&[seg(Noise, 300.0, 700.0, 0.6, 0.03, -3.0), seg(Noise, 700.0, 500.0, 1.4, 0.18, 0.2), seg(Noise, 500.0, 250.0, 0.6, 0.15, 3.0)], 82),
|
||||||
|
// the spores of a grove: a faint chime
|
||||||
|
Sfx::Spores => l(&arp(Sine, &[2093.0, 2637.0, 3136.0, 2637.0], 0.09, 0.07), 83),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,3 +196,21 @@ fn def(s: Sfx, r: f32) -> Vec<f32> {
|
|||||||
pub fn generate_all(rate: f32) -> Vec<Vec<f32>> {
|
pub fn generate_all(rate: f32) -> Vec<Vec<f32>> {
|
||||||
SFX_ALL.iter().map(|&s| def(s, rate)).collect()
|
SFX_ALL.iter().map(|&s| def(s, rate)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_effect_is_audible_and_does_not_clip() {
|
||||||
|
let all = generate_all(22050.0);
|
||||||
|
for (s, buf) in SFX_ALL.iter().zip(all.iter()) {
|
||||||
|
let peak = buf.iter().fold(0.0f32, |m, v| m.max(v.abs()));
|
||||||
|
assert!(!buf.is_empty() && peak > 0.02, "{:?} is silent", s);
|
||||||
|
assert!(peak < 1.5, "{:?} is too loud: {}", s, peak);
|
||||||
|
}
|
||||||
|
// the rain loops: it is long enough to overlap when played every 1.25 s
|
||||||
|
let rain = &all[Sfx::Rain.index()];
|
||||||
|
assert!(rain.len() as f32 / 22050.0 > 1.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Siege engines: battering rams smash doors, walls and towers; catapults hurl stones at towers
|
||||||
|
// from afar. The knights bring them along in later waves; a master with a forge builds his own
|
||||||
|
// (gold and wood) to break a fortress: they follow the attack flag and go for the gate and the
|
||||||
|
// knights' towers.
|
||||||
|
|
||||||
|
use crate::game::*;
|
||||||
|
use crate::map::*;
|
||||||
|
|
||||||
|
// cost of a ram and a catapult: gold, wood
|
||||||
|
pub const RAM_COST: (f32, f32) = (250.0, 15.0);
|
||||||
|
pub const CATAPULT_COST: (f32, f32) = (350.0, 25.0);
|
||||||
|
|
||||||
|
pub fn siege(k: EK) -> bool {
|
||||||
|
matches!(k, EK::Ram | EK::Catapult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// how much harder a unit hits towers
|
||||||
|
pub fn tower_breaker(k: EK) -> f32 {
|
||||||
|
match k {
|
||||||
|
EK::Catapult => 2.5,
|
||||||
|
EK::Ram => 2.0,
|
||||||
|
_ => 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
// the siege tool: a ram (0) or a catapult (1) rolls out on own ground (a forge is needed)
|
||||||
|
pub fn build_siege(&mut self, which: u8, x: i32, y: i32) {
|
||||||
|
if !inb(x, y) || !self.map.walk(x, y) || self.map.t[idx(x, y)].owner != self.me {
|
||||||
|
self.hint(34, "hint.siege_where");
|
||||||
|
self.sfx_ui(Sfx::Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.forge_tiles() == 0 {
|
||||||
|
self.hint(26, "hint.need_forge");
|
||||||
|
self.sfx_ui(Sfx::Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (kind, (gold, wood)) = if which == 0 { (EK::Ram, RAM_COST) } else { (EK::Catapult, CATAPULT_COST) };
|
||||||
|
if self.gold < gold {
|
||||||
|
self.msg("msg.no_gold", vec![]);
|
||||||
|
self.sfx_ui(Sfx::Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !self.spend_wood(wood) {
|
||||||
|
self.msg("msg.no_wood", vec![]);
|
||||||
|
self.sfx_ui(Sfx::Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.gold -= gold;
|
||||||
|
self.count(crate::stats::SIEGE, 1.0);
|
||||||
|
let id = self.spawn(kind, false, x as f32 + 0.5, y as f32 + 0.5, 1);
|
||||||
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
||||||
|
e.side = self.me_side;
|
||||||
|
e.home = Some((x as f32 + 0.5, y as f32 + 0.5));
|
||||||
|
e.settled = true;
|
||||||
|
e.happy = 1.0;
|
||||||
|
}
|
||||||
|
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff40d0ff);
|
||||||
|
self.sfx(Sfx::Build, x as f32 + 0.5, y as f32 + 0.5);
|
||||||
|
self.msg("msg.siege_built", vec![format!("@{}", st(kind).name)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a master's own siege engine: no needs; it goes where the attack flag is and breaks the
|
||||||
|
// gate and the knights' towers there (a catapult shoots any knight tower in reach)
|
||||||
|
pub(crate) fn siege_ai(&mut self, e: &mut Ent, dt: f32) {
|
||||||
|
let s = st(e.kind);
|
||||||
|
// a knight tower in reach (the catapult from afar, the ram next to it)
|
||||||
|
if e.think <= 0.0 {
|
||||||
|
e.think = 0.5;
|
||||||
|
let reach = if e.kind == EK::Catapult { s.range + 0.5 } else { 2.5 };
|
||||||
|
let near = self
|
||||||
|
.towers
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.hero)
|
||||||
|
.map(|t| (t.id, (t.pos().0 - e.x).powi(2) + (t.pos().1 - e.y).powi(2)))
|
||||||
|
.filter(|&(_, d2)| d2 < reach * reach)
|
||||||
|
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||||
|
.map(|t| t.0);
|
||||||
|
if let Some(id) = near {
|
||||||
|
e.job = Job::AttackTower(id);
|
||||||
|
e.path.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Job::AttackTower(id) = e.job {
|
||||||
|
if self.attack_tower(e, id, dt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.job = Job::None;
|
||||||
|
}
|
||||||
|
let Some(f) = self.flag else {
|
||||||
|
// no flag: wait at home
|
||||||
|
self.step_path(e, dt);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (fx, fy) = xy(f);
|
||||||
|
let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5);
|
||||||
|
// the gate of the order near the flag
|
||||||
|
let (gx, gy) = self.gate_center();
|
||||||
|
if self.gate_hp > 0.0 && self.has_gate() && (fx - gx).powi(2) + (fy - gy).powi(2) <= 64.0 {
|
||||||
|
let dg = ((e.x - gx).powi(2) + (e.y - gy).powi(2)).sqrt();
|
||||||
|
if dg <= s.range + 1.6 && (s.range < 1.5 || self.los(e.x, e.y, gx, gy)) {
|
||||||
|
e.path.clear();
|
||||||
|
e.face = if gx > e.x { 1.0 } else { -1.0 };
|
||||||
|
e.working = true;
|
||||||
|
if e.cd <= 0.0 {
|
||||||
|
self.hit_gate(e);
|
||||||
|
e.cd = s.interval;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let d2 = (e.x - fx).powi(2) + (e.y - fy).powi(2);
|
||||||
|
if d2 > 2.0 * 2.0 && (e.path.is_empty() || e.repath <= 0.0) {
|
||||||
|
e.repath = 1.5;
|
||||||
|
if let Some(p) = self.map.path_to(e.tile(), f) {
|
||||||
|
e.path = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.step_path(e, dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_master_builds_a_catapult_that_shoots_knight_towers() {
|
||||||
|
let mut g = Game::new(6, 80, 64);
|
||||||
|
let (cx, cy) = g.map.core;
|
||||||
|
g.gold = 5000.0;
|
||||||
|
g.wood = 100.0;
|
||||||
|
// own ground with a forge
|
||||||
|
for y in cy - 6..=cy + 8 {
|
||||||
|
for x in cx - 2..=cx + 14 {
|
||||||
|
let t = &mut g.map.t[idx(x, y)];
|
||||||
|
if !matches!(t.kind, Kind::Core) {
|
||||||
|
t.kind = Kind::Floor;
|
||||||
|
t.owner = Owner::Player;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.build_rect(8, cx + 1, cy + 5, cx + 4, cy + 7);
|
||||||
|
g.build_siege(1, cx + 6, cy + 2);
|
||||||
|
let cat = g.ents.iter().position(|e| e.kind == EK::Catapult).expect("a catapult");
|
||||||
|
assert_eq!(g.ents[cat].side, 0);
|
||||||
|
assert!(g.gold < 5000.0 && g.wood < 100.0);
|
||||||
|
// a knight tower in reach
|
||||||
|
let i = idx(cx + 12, cy + 2);
|
||||||
|
g.map.t[i].kind = Kind::Tower;
|
||||||
|
g.map.t[i].owner = Owner::Hero;
|
||||||
|
let id = g.next_id;
|
||||||
|
g.next_id += 1;
|
||||||
|
let mut tw = Tower { id, kind: TowerKind::Arrow, tile: i, level: 1, hp: 1.0, cd: 9.0, aim: 0.0, flash: 0.0, hero: true, rune_final: false };
|
||||||
|
tw.hp = tw.max_hp();
|
||||||
|
let hp0 = tw.hp;
|
||||||
|
g.towers.push(tw);
|
||||||
|
for _ in 0..(15 * 20) {
|
||||||
|
g.update(0.05);
|
||||||
|
}
|
||||||
|
let hp = g.towers.iter().find(|t| t.id == id).map_or(0.0, |t| t.hp);
|
||||||
|
assert!(hp < hp0, "the catapult hit the tower");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,52 @@ use macroquad::prelude::*;
|
|||||||
|
|
||||||
pub struct SoundBank {
|
pub struct SoundBank {
|
||||||
last: Vec<f64>,
|
last: Vec<f64>,
|
||||||
|
next_rain: f64,
|
||||||
|
next_wind: f64,
|
||||||
|
next_spores: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SoundBank {
|
impl SoundBank {
|
||||||
pub fn new() -> SoundBank {
|
pub fn new() -> SoundBank {
|
||||||
SoundBank { last: vec![-1.0; SFX_ALL.len()] }
|
SoundBank { last: vec![-1.0; SFX_ALL.len()], next_rain: 0.0, next_wind: 0.0, next_spores: 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// background sounds where the camera looks: rain and wind over the land above ground (the
|
||||||
|
// samples fade in and out and overlap a little), the chime of a mushroom grove now and then
|
||||||
|
pub fn ambience(&mut self, g: &crate::game::Game, (cx, cz): (f32, f32), now: f64) {
|
||||||
|
use crate::map::{idx, inb, Kind};
|
||||||
|
let near = |r: i32, f: &dyn Fn(usize) -> bool| {
|
||||||
|
let (x, z) = (cx as i32, cz as i32);
|
||||||
|
let mut n = 0;
|
||||||
|
for dz in (-r..=r).step_by(2) {
|
||||||
|
for dx in (-r..=r).step_by(2) {
|
||||||
|
if inb(x + dx, z + dz) && f(idx(x + dx, z + dz)) {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n
|
||||||
|
};
|
||||||
|
if g.state != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let outside = !g.map.sky.is_empty() && near(6, &|i| g.map.sky(i)) >= 6;
|
||||||
|
let w = &g.weather;
|
||||||
|
if outside && w.rain() > 0.05 && now >= self.next_rain {
|
||||||
|
self.next_rain = now + 1.25;
|
||||||
|
audio::play_sfx(Sfx::Rain.index(), 0.55 * w.rain(), 0.0);
|
||||||
|
}
|
||||||
|
let windy = if w.shown == crate::weather::STORM { w.level } else { w.fog() * 0.5 };
|
||||||
|
if outside && windy > 0.05 && now >= self.next_wind {
|
||||||
|
self.next_wind = now + 2.2;
|
||||||
|
audio::play_sfx(Sfx::Wind.index(), 0.45 * windy, (now.sin() * 0.4) as f32);
|
||||||
|
}
|
||||||
|
if now >= self.next_spores {
|
||||||
|
self.next_spores = now + 3.0 + (now * 7.3).sin().abs() * 3.0;
|
||||||
|
if near(5, &|i| matches!(g.map.t[i].kind, Kind::Grove)) >= 3 {
|
||||||
|
audio::play_sfx(Sfx::Spores.index(), 0.35, ((now * 1.7).sin() * 0.5) as f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// minimum gap between two identical sounds (avoids a wall of noise)
|
// minimum gap between two identical sounds (avoids a wall of noise)
|
||||||
|
|||||||
@@ -16,9 +16,18 @@ pub enum Spell {
|
|||||||
Invisible,
|
Invisible,
|
||||||
Plague,
|
Plague,
|
||||||
Quake,
|
Quake,
|
||||||
|
Freeze, // frost: cools lava into obsidian, chills enemies
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const SPELLS: [Spell; 8] = [Spell::Eye, Spell::CaveIn, Spell::Haste, Spell::Stoneskin, Spell::GoldRain, Spell::Invisible, Spell::Plague, Spell::Quake];
|
pub const SPELLS: [Spell; 9] = [Spell::Eye, Spell::CaveIn, Spell::Haste, Spell::Stoneskin, Spell::GoldRain, Spell::Invisible, Spell::Plague, Spell::Quake, Spell::Freeze];
|
||||||
|
|
||||||
|
// the icon of a spell (the first eight share one row of the atlas)
|
||||||
|
pub fn spell_icon(sp: Spell) -> u32 {
|
||||||
|
match SPELLS.iter().position(|&t| t == sp).unwrap_or(0) {
|
||||||
|
k if k < 8 => crate::gfx::I_SPELLS + k as u32,
|
||||||
|
_ => crate::gfx::I_FREEZE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub const EYE_TIME: f32 = 20.0;
|
pub const EYE_TIME: f32 = 20.0;
|
||||||
pub const EYE_RADIUS: f32 = 8.0;
|
pub const EYE_RADIUS: f32 = 8.0;
|
||||||
@@ -34,6 +43,7 @@ impl Spell {
|
|||||||
Spell::Invisible => 50.0,
|
Spell::Invisible => 50.0,
|
||||||
Spell::Plague => 80.0,
|
Spell::Plague => 80.0,
|
||||||
Spell::Quake => 90.0,
|
Spell::Quake => 90.0,
|
||||||
|
Spell::Freeze => 80.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// radius shown under the pointer
|
// radius shown under the pointer
|
||||||
@@ -43,6 +53,7 @@ impl Spell {
|
|||||||
Spell::CaveIn => 1.5,
|
Spell::CaveIn => 1.5,
|
||||||
Spell::Plague => 2.0,
|
Spell::Plague => 2.0,
|
||||||
Spell::Quake => 3.0,
|
Spell::Quake => 3.0,
|
||||||
|
Spell::Freeze => 2.5,
|
||||||
Spell::GoldRain => 0.0,
|
Spell::GoldRain => 0.0,
|
||||||
_ => 1.6,
|
_ => 1.6,
|
||||||
}
|
}
|
||||||
@@ -57,12 +68,13 @@ impl Spell {
|
|||||||
Spell::Invisible => "invisible",
|
Spell::Invisible => "invisible",
|
||||||
Spell::Plague => "plague",
|
Spell::Plague => "plague",
|
||||||
Spell::Quake => "quake",
|
Spell::Quake => "quake",
|
||||||
|
Spell::Freeze => "freeze",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn foe(e: &Ent) -> bool {
|
fn foe(e: &Ent) -> bool {
|
||||||
e.alive && !e.held && e.captive == 0 && !e.chained && e.side() != 0
|
e.alive && !e.held && e.captive == 0 && !e.chained && e.side() != 0 && !crate::rogues::spell_proof(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn own(e: &Ent) -> bool {
|
fn own(e: &Ent) -> bool {
|
||||||
@@ -80,7 +92,7 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn cast(&mut self, spell: Spell, wx: f32, wy: f32) {
|
pub fn cast(&mut self, spell: Spell, wx: f32, wy: f32) {
|
||||||
let cost = spell.cost();
|
let cost = spell.cost() * self.talents.spell_cost();
|
||||||
if self.mana < cost {
|
if self.mana < cost {
|
||||||
self.msg("msg.no_mana_cost", vec![(cost as i32).to_string()]);
|
self.msg("msg.no_mana_cost", vec![(cost as i32).to_string()]);
|
||||||
self.sfx_ui(Sfx::Error);
|
self.sfx_ui(Sfx::Error);
|
||||||
@@ -232,8 +244,26 @@ impl Game {
|
|||||||
}
|
}
|
||||||
self.sfx(Sfx::Cannon, wx, wy);
|
self.sfx(Sfx::Cannon, wx, wy);
|
||||||
}
|
}
|
||||||
|
Spell::Freeze => {
|
||||||
|
// lava turns to obsidian for a while; enemies are chilled and slowed
|
||||||
|
self.cool_lava_around(wx, wy, 2.5, crate::bridges::FREEZE_TIME);
|
||||||
|
let hit: Vec<usize> = (0..self.ents.len()).filter(|&j| foe(&self.ents[j]) && near(&self.ents[j])).collect();
|
||||||
|
for j in hit {
|
||||||
|
let e = &mut self.ents[j];
|
||||||
|
e.hp -= 20.0;
|
||||||
|
e.slow = e.slow.max(4.0);
|
||||||
|
e.flash = 0.15;
|
||||||
|
if e.hp <= 0.0 {
|
||||||
|
self.kill(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.part(RING, (wx, wy, 0.05), (0.0, 0.0, 0.0), 0.6, abgr(170, 230, 255), 2.5);
|
||||||
|
self.sparkle(wx, wy, 24, abgr(200, 240, 255));
|
||||||
|
self.sfx(Sfx::Magic, wx, wy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.mana -= cost;
|
self.mana -= cost;
|
||||||
|
self.count(crate::stats::SPELLS, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// running spell effects on a unit (called every frame)
|
// running spell effects on a unit (called every frame)
|
||||||
|
|||||||