Compare commits

...
10 Commits
Author SHA1 Message Date
Richard ZinkandClaude Opus 5.5 d8aa0e5fc1 Bump version to v202611-00076
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:33:43 +01:00
Richard ZinkandClaude Opus 5.5 98b78eb518 Desktop release builds: Linux AppImage, Windows exe and macOS app via tools/build_desktop.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:22:11 +01:00
Richard ZinkandClaude Opus 5.5 2475a8ca3c Desktop build plays music and sound through the system sound output; desktop build documented
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:49:45 +01:00
Richard ZinkandClaude Opus 5.5 db521ed2ee Desktop build: settings and progress kept as files, savegames in a saves folder, system file dialog for loading
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:46:11 +01:00
Richard ZinkandClaude Opus 5.5 1df97b4af2 Bump version to v202611-00075
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:08:28 +01:00
Richard ZinkandClaude Opus 5.5 bd3f42b8b8 Hero waves break in where the warning shows: they round the realm to the marked entry; the entry tile glows red
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:08:28 +01:00
Richard ZinkandClaude Opus 5.5 dab31579df Bump version to v202611-00074
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:34:04 +01:00
Richard ZinkandClaude Opus 5.5 e85e602491 Balance: easy AI stays smaller; balance runs documented
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:34:03 +01:00
Richard ZinkandClaude Opus 5.5 28eac52953 Memorial stones only for level 10 creatures, at most 30 per map
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:34:03 +01:00
Richard ZinkandClaude Opus 5.5 66eaedf016 Automated balance runs; AI masters keep growing, mine more, defend with the rally flag and spells; Builder talent stronger
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:07:02 +01:00
20 changed files with 1831 additions and 57 deletions

No files matched your search

+1
View File
@@ -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/
Generated
+829 -4
View File
@@ -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"
+5
View File
@@ -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"
+27
View File
@@ -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:
+1 -1
View File
@@ -1 +1 @@
v202611-00073 v202611-00076
+51
View File
@@ -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.
+2 -2
View File
@@ -140,8 +140,8 @@ grid), guard and attack markers (shields and swords), savegames with the game st
or trees with lightning every 6-20 s. Dimmed daylight, rain streaks, fog veils, flashes; a HUD label. 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 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; 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 3+ that falls leaves a 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 24). 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 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 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 (creature overview "Fallen", or a click with the hand on a memorial stone) and over all games on the
+91 -13
View File
@@ -14,8 +14,9 @@ const THINK: [f32; 3] = [5.2, 3.25, 1.95]; // seconds between two thoughts (a ca
const MAX_RADIUS: [i32; 3] = [11, 15, 19]; // how far the dungeon grows around the heart 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 MAX_IMPS: [usize; 3] = [5, 7, 9];
const FIRST_ATTACK: [f32; 3] = [940.0, 625.0, 430.0]; // no attack before (seconds) const FIRST_ATTACK: [f32; 3] = [940.0, 625.0, 430.0]; // no attack before (seconds)
const BOLDNESS: [f32; 3] = [1.5, 1.15, 0.9]; // own army / enemy army needed to attack 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 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 MAX_TOWERS: [usize; 3] = [1, 2, 4];
const START_GOLD: [f32; 3] = [1500.0, 2500.0, 3500.0]; const START_GOLD: [f32; 3] = [1500.0, 2500.0, 3500.0];
@@ -51,10 +52,11 @@ impl Game {
self.ai_imps(d, cx, cy); self.ai_imps(d, cx, cy);
self.ai_dig(k, d, cx, cy); self.ai_dig(k, d, cx, cy);
self.ai_gold(cx, cy); self.ai_gold(cx, cy);
self.ai_rooms(cx, cy); self.ai_rooms(d, cx, cy);
self.ai_towers(d, cx, cy); self.ai_towers(d, cx, cy);
self.ai_fight(k, d, side, cx, cy); self.ai_fight(k, d, side, cx, cy);
self.ai_talents(); 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 // the tiles an AI master may mark: within MARK_REACH tiles of its own ground
@@ -161,18 +163,23 @@ impl Game {
// gold running low: a tunnel to the nearest vein that nobody works on yet // gold running low: a tunnel to the nearest vein that nobody works on yet
fn ai_gold(&mut self, cx: i32, cy: i32) { fn ai_gold(&mut self, cx: i32, cy: i32) {
if self.gold > 1500.0 { // gold for one and a half paydays and a little more: enough
if self.gold > self.next_payday_cost() * 1.5 + 1000.0 {
return; return;
} }
let me = self.me; let me = self.me;
// a marked vein the imps can reach (next to own ground) is being worked already // marked veins the imps can reach (next to own ground) are being worked already; one vein
let busy = (0..self.map.t.len()).any(|i| { // for every three imps at a time
let t = &self.map.t[i]; let side = me.side().min(7);
let (x, y) = xy(i); let busy = (0..self.map.t.len())
t.marked && 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())) .filter(|&i| {
}); let t = &self.map.t[i];
if busy { let (x, y) = xy(i);
return; // a vein is being worked already 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| { self.ai_mark_tunnel(cx, cy, |g: &Game, i: usize| {
let t = &g.map.t[i]; let t = &g.map.t[i];
@@ -220,7 +227,7 @@ impl Game {
} }
// the next room of the build order, where there is free own floor for it // the next room of the build order, where there is free own floor for it
fn ai_rooms(&mut self, cx: i32, cy: i32) { fn ai_rooms(&mut self, d: usize, cx: i32, cy: i32) {
for &(room, w, h, n) in BUILD.iter() { for &(room, w, h, n) in BUILD.iter() {
// only what this master has researched (the same rules as the player) // only what this master has researched (the same rules as the player)
if !self.ai_knows(crate::ui::Tool::Room(room)) { if !self.ai_knows(crate::ui::Tool::Room(room)) {
@@ -239,6 +246,29 @@ impl Game {
} }
return; 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)? // has this master researched the tool (rooms, towers)?
@@ -295,6 +325,11 @@ impl Game {
self.place_mark(cx + 1, cy + 3, false); self.place_mark(cx + 1, cy + 3, false);
} }
let army = self.side_army(side); 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 // co-op: go where the allies go
if self.is_partner(side) { 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 lead = self.master_sides().into_iter().find(|&s| s != side && !self.is_out(s) && self.allied(side, s));
@@ -363,6 +398,49 @@ impl Game {
} }
} }
// 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? // is an enemy heart near tile f?
fn ai_enemy_heart_near(&self, side: u8, f: usize) -> bool { fn ai_enemy_heart_near(&self, side: u8, f: usize) -> bool {
let (fx, fy) = xy(f); let (fx, fy) = xy(f);
+105 -1
View File
@@ -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;
@@ -244,6 +245,109 @@ 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::*;
+249
View File
@@ -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);
}
}
}
}
}
+27 -3
View File
@@ -161,6 +161,8 @@ pub struct St {
pub const RUNE_TOOL: usize = 20; // index of the rune forge tower in ui::TOOLS pub const RUNE_TOOL: usize = 20; // index of the rune forge tower in ui::TOOLS
pub const SUMMON_TOOL: usize = 11; // index of summoning in ui::TOOLS pub const SUMMON_TOOL: usize = 11; // index of summoning in ui::TOOLS
pub const LIGHTNING_TOOL: usize = 9; // lightning in ui::TOOLS
pub const HEAL_TOOL: usize = 10; // healing in ui::TOOLS
pub fn st(k: EK) -> St { pub fn st(k: EK) -> St {
let s = |name, hp, atk, range, speed, interval, wage, bounty| St { name, hp, atk, range, speed, interval, wage, bounty }; let s = |name, hp, atk, range, speed, interval, wage, bounty| St { name, hp, atk, range, speed, interval, wage, bounty };
@@ -4901,6 +4903,13 @@ impl Game {
if (e.x - wx as f32 - 0.5).abs() < 2.5 && (e.y - wy as f32 - 0.5).abs() < 2.5 { if (e.x - wx as f32 - 0.5).abs() < 2.5 && (e.y - wy as f32 - 0.5).abs() < 2.5 {
e.waypoint = None; // broke into the realm: now for the core e.waypoint = None; // broke into the realm: now for the core
e.path.clear(); e.path.clear();
} else if let Some(&next) = e.path.first() {
// the realm grew into the way (imps claim fast): round it again
let realm = self.map.t[idx(wx, wy)].owner;
if self.map.t[next].owner == realm && next != idx(wx, wy) && self.map.t[e.tile()].owner != realm {
e.path.clear();
e.repath = 0.0;
}
} }
} }
if e.path.is_empty() && e.repath <= 0.0 { if e.path.is_empty() && e.repath <= 0.0 {
@@ -4908,7 +4917,8 @@ impl Game {
let path = match e.waypoint { let path = match e.waypoint {
Some((wx, wy)) => { Some((wx, wy)) => {
let goal = idx(wx, wy); let goal = idx(wx, wy);
let p = self.map.search(cur, self.map.hero_cost(), |i| i == goal, true); // round the realm from outside: in through the marked entry, not before
let p = self.entry_path(cur, goal).or_else(|| self.map.search(cur, self.map.hero_cost(), |i| i == goal, true));
if p.is_none() { if p.is_none() {
e.waypoint = None; e.waypoint = None;
} }
@@ -4973,14 +4983,28 @@ impl Game {
scored.push(((x, y), score)); scored.push(((x, y), score));
} }
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let first = scored[0].0; // only places the heroes can reach from the gate without crossing the realm first
let gate = idx(gx, gy);
let mut tried = 0;
let mut ok = |g: &Game, p: (i32, i32)| {
tried += 1;
tried > 12 || g.entry_path(gate, idx(p.0, p.1)).is_some()
};
let Some(first) = scored.iter().map(|s| s.0).find(|&p| ok(self, p)) else { return };
self.planned_entries.push(first); self.planned_entries.push(first);
// second entry point at least 10 tiles away (used by waves of 6+ heroes) // second entry point at least 10 tiles away (used by waves of 6+ heroes)
if let Some(&(second, _)) = scored.iter().find(|((x, y), _)| (x - first.0).pow(2) + (y - first.1).pow(2) >= 100) { if let Some(second) = scored.iter().map(|s| s.0).filter(|&(x, y)| (x - first.0).pow(2) + (y - first.1).pow(2) >= 100).find(|&p| ok(self, p)) {
self.planned_entries.push(second); self.planned_entries.push(second);
} }
} }
// a hero's way to an entry tile that stays off the realm (the master's ground) until the entry
pub(crate) fn entry_path(&self, from: usize, goal: usize) -> Option<Vec<usize>> {
let owner = self.map.t[goal].owner;
let cost = self.map.hero_cost();
self.map.search(from, |i| if i != goal && i != from && self.map.t[i].owner == owner { None } else { cost(i) }, |i| i == goal, true)
}
// compass direction of a tile as seen from the core (language key) // compass direction of a tile as seen from the core (language key)
pub fn direction_key(&self, (x, y): (i32, i32)) -> &'static str { pub fn direction_key(&self, (x, y): (i32, i32)) -> &'static str {
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1); let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
+14 -3
View File
@@ -1,4 +1,6 @@
// 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 achievements;
mod ai; mod ai;
@@ -30,6 +32,7 @@ mod props;
mod research; mod research;
mod rival; mod rival;
mod rogues; mod rogues;
mod balance;
mod talents; mod talents;
mod weather; mod weather;
mod names; mod names;
@@ -928,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();
@@ -1496,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()) {
@@ -1543,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) {
@@ -2348,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() {
+19 -7
View File
@@ -8,9 +8,11 @@ use crate::i18n::{tr, trf};
use crate::map::*; use crate::map::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// the fallen get a stone from this level on; at most MAX_MEMORIALS stones (the oldest go) // the fallen are remembered in the hall from HALL_LEVEL on; only the greatest (MEMORIAL_LEVEL)
pub const MEMORIAL_LEVEL: u32 = 3; // get a stone on the map, at most MAX_MEMORIALS of them (the oldest go)
pub const MAX_MEMORIALS: usize = 24; 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) // what killed a creature besides another unit (Ent::bane)
pub const BANE_SNARE: u8 = 250; pub const BANE_SNARE: u8 = 250;
@@ -134,13 +136,17 @@ impl Game {
// a named creature of some standing fell: a stone with its name on its master's ground, as // 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 // near to where it fell as there is plain floor
pub(crate) fn raise_memorial(&mut self, e: &Ent) -> bool { pub(crate) fn raise_memorial(&mut self, e: &Ent) -> bool {
if e.hero || e.name == 0 || e.level < MEMORIAL_LEVEL || !Owner::MASTER_SIDES.contains(&e.side) { if e.hero || e.name == 0 || e.level < HALL_LEVEL || !Owner::MASTER_SIDES.contains(&e.side) {
return false; return false;
} }
// the hall of the fallen keeps the local master's (also without room for a stone) // the hall of the fallen keeps the local master's (also without room for a stone)
if e.side == self.local_side { 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 }); 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 owner = Owner::of_side(e.side);
let (x, y) = (e.x as i32, e.y as i32); let (x, y) = (e.x as i32, e.y as i32);
let taken: Vec<usize> = self.memorials.iter().map(|m| m.tile).collect(); let taken: Vec<usize> = self.memorials.iter().map(|m| m.tile).collect();
@@ -261,12 +267,18 @@ mod tests {
t.owner = Owner::Player; t.owner = Owner::Player;
} }
} }
let id = g.spawn(EK::Ogre, false, cx as f32 + 1.5, cy as f32 + 5.5, 4); 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(); let j = g.ents.iter().position(|e| e.id == id).unwrap();
g.kill(j); g.kill(j);
assert_eq!(g.memorials.len(), 1); assert_eq!(g.memorials.len(), 1);
let m = &g.memorials[0]; let m = g.memorials[0].clone();
assert_eq!((m.kind, m.level), (EK::Ogre, 4)); 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()); assert!(g.memorial_at(m.tile).is_some());
// a level 1 kobold is not remembered // 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 k = g.spawn(EK::Kobold, false, cx as f32 + 2.5, cy as f32 + 5.5, 1);
+1 -1
View File
@@ -365,7 +365,7 @@ impl Game {
// the seat keeps its master (dungeon, gold, creatures): the brain (ai.rs) plays it // the seat keeps its master (dungeon, gold, creatures): the brain (ai.rs) plays it
let core = self.core_of_side(side); let core = self.core_of_side(side);
let difficulty = self.ai_difficulty; 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 }); 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 });
} }
} }
+128 -14
View File
@@ -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(""), "_");
}
}
+10 -1
View File
@@ -3377,12 +3377,21 @@ fn draw_entry_warnings(g: &Game, t: f32, n: usize) {
let line = [255, 90, 60, (120.0 + 120.0 * p) as u8]; let line = [255, 90, 60, (120.0 + 120.0 * p) as u8];
let mut mb = MB::new(); let mut mb = MB::new();
for &(ex, ey) in &g.planned_entries { for &(ex, ey) in &g.planned_entries {
let realm = g.map.get(ex, ey).map_or(Owner::Player, |t| t.owner);
// the entry tile itself: a pulsing red plate (also where an open corridor leads in)
let c = vec3(ex as f32 + 0.5, 0.03, ey as f32 + 0.5);
mb.quad(
[c + vec3(-0.5, 0.0, -0.5), c + vec3(0.5, 0.0, -0.5), c + vec3(0.5, 0.0, 0.5), c + vec3(-0.5, 0.0, 0.5)],
[Vec2::ZERO; 4],
[col(vec3(1.0, 0.16, 0.12) * (0.6 + 0.6 * p), 0.45 + 0.35 * p); 4],
[Vec3::Y; 4],
);
for dy in -2..=2 { for dy in -2..=2 {
for dx in -2..=2 { for dx in -2..=2 {
let (x, y) = (ex + dx, ey + dy); let (x, y) = (ex + dx, ey + dy);
let Some(tile) = g.map.get(x, y) else { continue }; let Some(tile) = g.map.get(x, y) else { continue };
// walls of the realm's border near the entry point (not rock) // walls of the realm's border near the entry point (not rock)
let border = DIR4.iter().any(|&(ax, ay)| g.map.get(x + ax, y + ay).map_or(false, |o| o.owner == Owner::Player && !o.solid())); let border = DIR4.iter().any(|&(ax, ay)| g.map.get(x + ax, y + ay).map_or(false, |o| o.owner == realm && !o.solid()));
if tile.diggable() && border && dx.abs() + dy.abs() <= 3 && mb.v.len() < 30000 { if tile.diggable() && border && dx.abs() + dy.abs() <= 3 && mb.v.len() < 30000 {
block_shell(&mut mb, &g.map, x, y, n, fill, line, 1.0, 0.04); block_shell(&mut mb, &g.map, x, y, n, fill, line, 1.0, 0.04);
} }
+5 -1
View File
@@ -33,6 +33,10 @@ pub struct Rival {
pub attacking: bool, // the attack flag is set on an enemy heart pub attacking: bool, // the attack flag is set on an enemy heart
#[serde(default)] #[serde(default)]
pub army0: f32, // army strength when the attack began pub army0: f32, // army strength when the attack began
#[serde(default)]
pub focus: Option<u8>, // balance runs: this master learns only one talent branch
#[serde(default)]
pub defending: bool, // the rally flag is on intruders in the dungeon (ai.rs)
} }
impl Rival { impl Rival {
@@ -98,7 +102,7 @@ impl Game {
let core = (cx - 1, cy - 1); let core = (cx - 1, cy - 1);
self.carve_master_area(side, core); self.carve_master_area(side, core);
self.add_master_portal(side, core); self.add_master_portal(side, core);
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 }); 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 });
self.adopt_master(side, core, difficulty, true); self.adopt_master(side, core, difficulty, true);
} }
+13 -5
View File
@@ -73,10 +73,10 @@ impl Talents {
} }
// Builder: imps dig and claim faster, rooms cost less // Builder: imps dig and claim faster, rooms cost less
pub fn dig_speed(&self) -> f32 { pub fn dig_speed(&self) -> f32 {
1.0 + 0.1 * self.level(BUILDER) 1.0 + 0.15 * self.level(BUILDER)
} }
pub fn room_cost(&self) -> f32 { pub fn room_cost(&self) -> f32 {
1.0 - 0.06 * self.level(BUILDER) 1.0 - 0.1 * self.level(BUILDER)
} }
// Warlord: creatures hit harder and take less damage // Warlord: creatures hit harder and take less damage
pub fn damage(&self) -> f32 { pub fn damage(&self) -> f32 {
@@ -136,8 +136,16 @@ impl Game {
self.sfx_ui(Sfx::LevelUp); self.sfx_ui(Sfx::LevelUp);
} }
// an AI master spends his points by himself: the branches in turn, the lowest first // an AI master spends his points by himself: the branches in turn, the lowest first (a balance
pub(crate) fn ai_talents(&mut self) { // run may hold him to one branch)
pub(crate) fn ai_talents(&mut self, focus: Option<u8>) {
if let Some(b) = focus {
let b = b as usize;
while self.talents.free() > 0 && self.talents.lv[b] < MAX_LEVEL {
self.talents.lv[b] += 1;
}
return;
}
while self.talents.free() > 0 { while self.talents.free() > 0 {
let order = [WARLORD, BUILDER, MERCHANT, WARLOCK]; let order = [WARLORD, BUILDER, MERCHANT, WARLOCK];
let Some(&b) = order.iter().filter(|&&b| self.talents.lv[b] < MAX_LEVEL).min_by_key(|&&b| self.talents.lv[b]) else { break }; let Some(&b) = order.iter().filter(|&&b| self.talents.lv[b] < MAX_LEVEL).min_by_key(|&&b| self.talents.lv[b]) else { break };
@@ -213,7 +221,7 @@ mod tests {
fn ai_masters_spend_their_points() { fn ai_masters_spend_their_points() {
let mut g = Game::new(9, 64, 64); let mut g = Game::new(9, 64, 64);
g.talents.xp = point_xp(6); g.talents.xp = point_xp(6);
g.ai_talents(); g.ai_talents(None);
assert_eq!(g.talents.free(), 0); assert_eq!(g.talents.free(), 0);
assert!(g.talents.lv.iter().all(|&l| l >= 1), "{:?}", g.talents.lv); assert!(g.talents.lv.iter().all(|&l| l >= 1), "{:?}", g.talents.lv);
} }
+1 -1
View File
@@ -559,7 +559,7 @@ fn branch_text(t: &crate::talents::Talents, b: usize) -> String {
let desc = |lv: u8| { let desc = |lv: u8| {
let l = lv as i32; let l = lv as i32;
let (a, c) = match b { let (a, c) = match b {
BUILDER => (10 * l, 6 * l), BUILDER => (15 * l, 10 * l),
WARLORD => (5 * l, 5 * l), WARLORD => (5 * l, 5 * l),
WARLOCK => (15 * l, 6 * l), WARLOCK => (15 * l, 6 * l),
_ => (6 * l, 6 * l), _ => (6 * l, 6 * l),
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Build the desktop versions of DungeonCraft into dist/.
python3 tools/build_desktop.py # everything this machine can build
python3 tools/build_desktop.py linux windows # only these
- linux: dist/DungeonCraft-<version>-x86_64.AppImage
built with cargo-zigbuild against glibc 2.28, so it runs on distributions from about 2019 on;
needs on the build machine: zig, cargo-zigbuild, the ALSA headers (alsa-lib / libasound2-dev);
appimagetool is downloaded once into ~/.cache/dungeoncraft/
- windows: dist/DungeonCraft-<version>-windows-x64.exe (one file, Windows 10 and newer)
cross-compiled with cargo-zigbuild for x86_64-pc-windows-gnu
- macos: dist/DungeonCraft-<version>-macos.zip (DungeonCraft.app, Apple silicon + Intel)
only on a Mac (Apple's SDK is needed); the app is signed ad hoc, not notarised
Everything the game needs (graphics, music, languages) is inside the program; nothing else is packed.
The tools: `cargo install --locked cargo-zigbuild`, zig from the package manager or `mise install zig@0.15`
(found on PATH, in $CARGO_ZIGBUILD_ZIG_PATH or in mise's install folder), and the Rust targets
(`rustup target add ...`, the script tells which).
"""
import os
import pathlib
import platform
import shutil
import stat
import subprocess
import sys
import urllib.request
ROOT = pathlib.Path(__file__).resolve().parent.parent
DIST = ROOT / "dist"
CACHE = pathlib.Path(os.environ.get("XDG_CACHE_HOME") or pathlib.Path.home() / ".cache") / "dungeoncraft"
ICON = ROOT / "www" / "icons" / "icon-512.png"
GLIBC = "2.28"
APPIMAGETOOL_URL = "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
DESKTOP_ENTRY = """[Desktop Entry]
Type=Application
Name=DungeonCraft
Comment=Dig. Build. Defend. Conquer.
Exec=dungeoncraft
Icon=dungeoncraft
Categories=Game;StrategyGame;
Terminal=false
"""
INFO_PLIST = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>DungeonCraft</string>
<key>CFBundleDisplayName</key><string>DungeonCraft</string>
<key>CFBundleIdentifier</key><string>org.familie-zink.dungeoncraft</string>
<key>CFBundleExecutable</key><string>DungeonCraft</string>
<key>CFBundleIconFile</key><string>DungeonCraft</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>{short}</string>
<key>CFBundleVersion</key><string>{version}</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>LSApplicationCategoryType</key><string>public.app-category.strategy-games</string>
<key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
"""
class BuildError(Exception):
pass
def log(msg):
print(f"==> {msg}", flush=True)
def run(cmd, env=None, cwd=ROOT):
print(" $", " ".join(str(c) for c in cmd), flush=True)
r = subprocess.run([str(c) for c in cmd], cwd=cwd, env=env)
if r.returncode != 0:
raise BuildError(f"{pathlib.Path(str(cmd[0])).name} failed (exit {r.returncode})")
def version():
return (ROOT / "VERSION").read_text(encoding="utf-8").strip()
def cargo():
c = shutil.which("cargo") or str(pathlib.Path.home() / ".cargo" / "bin" / "cargo")
if not pathlib.Path(c).exists():
raise BuildError("cargo not found: install Rust with rustup")
return c
def tool_env():
env = dict(os.environ)
env["PATH"] = str(pathlib.Path(cargo()).parent) + os.pathsep + env.get("PATH", "")
return env
def need_target(triple):
rustup = shutil.which("rustup", path=tool_env()["PATH"])
if not rustup:
return
have = subprocess.run([rustup, "target", "list", "--installed"], capture_output=True, text=True).stdout.split()
if triple not in have:
raise BuildError(f"Rust target missing: rustup target add {triple}")
def zig_env():
env = tool_env()
if not shutil.which("cargo-zigbuild", path=env["PATH"]):
raise BuildError("cargo-zigbuild missing: cargo install --locked cargo-zigbuild")
installs = pathlib.Path.home() / ".local" / "share" / "mise" / "installs" / "zig"
from_mise = sorted(installs.glob("*/zig"), key=lambda p: [int(x) if x.isdigit() else 0 for x in p.parent.name.split(".")])
candidates = [env.get("CARGO_ZIGBUILD_ZIG_PATH"), shutil.which("zig", path=env["PATH"])] + [str(p) for p in reversed(from_mise)]
def works(z): # a mise shim without an active version is on PATH but does not run
try:
return subprocess.run([z, "version"], capture_output=True, timeout=30).returncode == 0
except OSError:
return False
zig = next((z for z in candidates if z and works(z)), None)
if not zig:
raise BuildError("zig not found: install it (package manager, or `mise install zig@0.15`)")
env["CARGO_ZIGBUILD_ZIG_PATH"] = zig
return env
# ------------------------------------------------------------------ Linux: AppImage
def appimagetool():
tool = CACHE / "appimagetool-x86_64.AppImage"
if not tool.exists():
log(f"downloading appimagetool to {tool}")
CACHE.mkdir(parents=True, exist_ok=True)
tmp = tool.with_suffix(".part")
with urllib.request.urlopen(APPIMAGETOOL_URL, timeout=120) as r, open(tmp, "wb") as f:
shutil.copyfileobj(r, f)
tmp.rename(tool)
tool.chmod(tool.stat().st_mode | stat.S_IXUSR)
return tool
def build_linux(ver):
if platform.system() != "Linux" or platform.machine() not in ("x86_64", "AMD64"):
raise BuildError("the AppImage is built on x86_64 Linux")
need_target("x86_64-unknown-linux-gnu")
env = zig_env()
# libasound is only linked by name: its own glibc references are the user's system's business
env["RUSTFLAGS"] = (env.get("RUSTFLAGS", "") + " -C link-arg=-Wl,--allow-shlib-undefined").strip()
log(f"Linux build (glibc {GLIBC})")
run([cargo(), "zigbuild", "--release", "--target", f"x86_64-unknown-linux-gnu.{GLIBC}"], env=env)
binary = ROOT / "target" / "x86_64-unknown-linux-gnu" / "release" / "dungeoncraft"
appdir = ROOT / "target" / "AppDir"
shutil.rmtree(appdir, ignore_errors=True)
(appdir / "usr" / "bin").mkdir(parents=True)
shutil.copy2(binary, appdir / "usr" / "bin" / "dungeoncraft")
(appdir / "dungeoncraft.desktop").write_text(DESKTOP_ENTRY, encoding="utf-8")
shutil.copy2(ICON, appdir / "dungeoncraft.png")
os.symlink("dungeoncraft.png", appdir / ".DirIcon")
os.symlink("usr/bin/dungeoncraft", appdir / "AppRun")
out = DIST / f"DungeonCraft-{ver}-x86_64.AppImage"
out.unlink(missing_ok=True)
tenv = dict(os.environ, ARCH="x86_64", APPIMAGE_EXTRACT_AND_RUN="1") # no FUSE needed for the tool
log("packing the AppImage")
run([appimagetool(), "--no-appstream", appdir, out], env=tenv)
return out
# ------------------------------------------------------------------ Windows: one exe
def build_windows(ver):
need_target("x86_64-pc-windows-gnu")
env = zig_env()
log("Windows build")
run([cargo(), "zigbuild", "--release", "--target", "x86_64-pc-windows-gnu"], env=env)
out = DIST / f"DungeonCraft-{ver}-windows-x64.exe"
shutil.copy2(ROOT / "target" / "x86_64-pc-windows-gnu" / "release" / "dungeoncraft.exe", out)
return out
# ------------------------------------------------------------------ macOS: universal .app in a zip
def build_macos(ver):
if platform.system() != "Darwin":
raise BuildError("the macOS app can only be built on a Mac (Apple's SDK is needed)")
env = tool_env()
arches = ["aarch64-apple-darwin", "x86_64-apple-darwin"]
for t in arches:
need_target(t)
env["MACOSX_DEPLOYMENT_TARGET"] = "11.0"
for t in arches:
log(f"macOS build {t}")
run([cargo(), "build", "--release", "--target", t], env=env)
app = ROOT / "target" / "DungeonCraft.app"
shutil.rmtree(app, ignore_errors=True)
(app / "Contents" / "MacOS").mkdir(parents=True)
(app / "Contents" / "Resources").mkdir(parents=True)
run(["lipo", "-create", "-output", app / "Contents" / "MacOS" / "DungeonCraft"] + [ROOT / "target" / t / "release" / "dungeoncraft" for t in arches])
short = ver.lstrip("v").replace("-", ".")
(app / "Contents" / "Info.plist").write_text(INFO_PLIST.format(short=short, version=ver), encoding="utf-8")
iconset = ROOT / "target" / "DungeonCraft.iconset"
shutil.rmtree(iconset, ignore_errors=True)
iconset.mkdir()
for size in (16, 32, 128, 256, 512):
run(["sips", "-z", size, size, ICON, "--out", iconset / f"icon_{size}x{size}.png"])
if size <= 256:
run(["sips", "-z", size * 2, size * 2, ICON, "--out", iconset / f"icon_{size}x{size}@2x.png"])
run(["iconutil", "-c", "icns", iconset, "-o", app / "Contents" / "Resources" / "DungeonCraft.icns"])
run(["codesign", "--force", "--deep", "--sign", "-", app])
out = DIST / f"DungeonCraft-{ver}-macos.zip"
out.unlink(missing_ok=True)
run(["ditto", "-c", "-k", "--keepParent", app, out])
return out
BUILDERS = {"linux": build_linux, "windows": build_windows, "macos": build_macos}
def main(argv):
if any(a in ("-h", "--help") for a in argv):
print(__doc__)
return 0
wanted = argv or (["macos"] if platform.system() == "Darwin" else ["linux", "windows"])
unknown = [w for w in wanted if w not in BUILDERS]
if unknown:
print(f"unknown target(s): {', '.join(unknown)} (linux, windows, macos)", file=sys.stderr)
return 2
DIST.mkdir(exist_ok=True)
ver = version()
done, failed = [], []
for w in wanted:
try:
done.append(BUILDERS[w](ver))
except BuildError as e:
failed.append(f"{w}: {e}")
print(f"!! {w}: {e}", file=sys.stderr)
print()
for p in done:
print(f"built {p.relative_to(ROOT)} ({p.stat().st_size / 1e6:.1f} MB)")
for f in failed:
print(f"failed {f}")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))