Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed77d18e1e | |||
| 5dd932a6fe | |||
| d820b19988 | |||
| f08bd72038 | |||
| 4864ecca93 | |||
| 7fa567d522 | |||
| 7a4bcd81f9 | |||
| 5d50daf768 | |||
| e7bdb37093 | |||
| c8e968e76e | |||
| 099d163325 | |||
| 4398620ac8 | |||
| e1fa12b6b9 | |||
| df4dcdf3cb | |||
| 62a91f5765 | |||
| 48a39d4430 | |||
| 88c46d679d | |||
| d01e987b89 | |||
| 5e1e4a546a | |||
| 58276ea8f7 | |||
| d2dd57bcff | |||
| 472a238a1c | |||
| 2bd115a714 | |||
| 5a7c92cd96 | |||
| 3abf8e2331 | |||
| 4d37a547ff | |||
| 95b173c504 | |||
| e9f02e362a | |||
| 0f4899319d | |||
| 69eacd42d5 | |||
| 5bc87e65a8 | |||
| 2341ee664e | |||
| 7ff943e829 | |||
| a698495c06 | |||
| 68096ee108 | |||
| ef9bb50fba | |||
| 77407f7a90 | |||
| b64930ed9e | |||
| 22193243a1 | |||
| 919f063934 | |||
| a1ed564bad | |||
| 2a12ab8cbe | |||
| 9d68276086 | |||
| a224a74d05 | |||
| cbf3f7c835 | |||
| 07f0cafcf8 | |||
| 399db7605c | |||
| fea57af6d1 | |||
| 6685e3e2c9 | |||
| 29b18d0ed0 | |||
| 3d158a4e7c | |||
| cfaf565891 | |||
| 74836df618 | |||
| b509b128bb | |||
| 285d35d87e | |||
| 35e6420e68 | |||
| 3709bfa58d | |||
| 101626cf3d | |||
| f117dd5e1c | |||
| 1c01feee27 | |||
| 603d0646bf | |||
| ecd98ea1e2 | |||
| db121bffa5 | |||
| 58501cf536 | |||
| e096216806 | |||
| afd863a9be | |||
| 34ca2cfee7 | |||
| 2c3a1a2fff | |||
| a4e819b4b6 | |||
| e15c055f06 | |||
| 4145abda19 | |||
| f2f333b8cf | |||
| 9198560978 | |||
| c587371544 | |||
| c4dcedd723 | |||
| f68c68f167 |
115
Cargo.lock
generated
@ -1409,7 +1409,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
"rustc-hash",
|
"rustc-hash 1.1.0",
|
||||||
"shlex",
|
"shlex",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
@ -1614,6 +1614,12 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "claims"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bba18ee93d577a8428902687bcc2b6b45a56b1981a1f6d779731c86cc4c5db18"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clang-sys"
|
name = "clang-sys"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@ -1807,7 +1813,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"rangemap",
|
"rangemap",
|
||||||
"rayon",
|
"rayon",
|
||||||
"rustc-hash",
|
"rustc-hash 1.1.0",
|
||||||
"rustybuzz",
|
"rustybuzz",
|
||||||
"self_cell",
|
"self_cell",
|
||||||
"swash",
|
"swash",
|
||||||
@ -1919,6 +1925,18 @@ version = "2.6.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
|
checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deprecate-until"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a3767f826efbbe5a5ae093920b58b43b01734202be697e1354914e862e8e704"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"semver",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@ -2299,21 +2317,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures"
|
|
||||||
version = "0.3.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
|
||||||
dependencies = [
|
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
|
||||||
"futures-executor",
|
|
||||||
"futures-io",
|
|
||||||
"futures-sink",
|
|
||||||
"futures-task",
|
|
||||||
"futures-util",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@ -2321,7 +2324,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2330,17 +2332,6 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-executor"
|
|
||||||
version = "0.3.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
"futures-task",
|
|
||||||
"futures-util",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@ -2371,12 +2362,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-sink"
|
|
||||||
version = "0.3.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@ -2395,13 +2380,9 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
"futures-sink",
|
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
"slab",
|
"slab",
|
||||||
@ -2675,15 +2656,16 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hexlab"
|
name = "hexlab"
|
||||||
version = "0.5.2"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7bd7c21f4e2c11d40473d1ae673905f4deae3b12104fa6d70eeef9ef385aceb6"
|
checksum = "e247b21d6885136e4a910e1e6fac755d8dc56112c40ebf1062582f0644d62c62"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy",
|
"bevy",
|
||||||
"bevy_reflect",
|
"bevy_reflect",
|
||||||
"bevy_utils",
|
"bevy_utils",
|
||||||
"glam",
|
"glam",
|
||||||
"hexx",
|
"hexx",
|
||||||
|
"pathfinding",
|
||||||
"rand",
|
"rand",
|
||||||
"thiserror 2.0.6",
|
"thiserror 2.0.6",
|
||||||
]
|
]
|
||||||
@ -2925,6 +2907,15 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "integer-sqrt"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "io-kit-sys"
|
name = "io-kit-sys"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@ -3166,16 +3157,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maze-ascension"
|
name = "maze-ascension"
|
||||||
version = "0.3.0"
|
version = "1.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bevy",
|
"bevy",
|
||||||
"bevy-inspector-egui",
|
"bevy-inspector-egui",
|
||||||
"bevy_egui",
|
"bevy_egui",
|
||||||
|
"claims",
|
||||||
"hexlab",
|
"hexlab",
|
||||||
"hexx",
|
"hexx",
|
||||||
"log",
|
"log",
|
||||||
"rand",
|
"rand",
|
||||||
|
"rayon",
|
||||||
"rstest",
|
"rstest",
|
||||||
"rstest_reuse",
|
"rstest_reuse",
|
||||||
"strum",
|
"strum",
|
||||||
@ -3257,7 +3250,7 @@ dependencies = [
|
|||||||
"indexmap",
|
"indexmap",
|
||||||
"log",
|
"log",
|
||||||
"pp-rs",
|
"pp-rs",
|
||||||
"rustc-hash",
|
"rustc-hash 1.1.0",
|
||||||
"spirv",
|
"spirv",
|
||||||
"termcolor",
|
"termcolor",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@ -3278,7 +3271,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"regex",
|
"regex",
|
||||||
"regex-syntax 0.8.5",
|
"regex-syntax 0.8.5",
|
||||||
"rustc-hash",
|
"rustc-hash 1.1.0",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tracing",
|
"tracing",
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
@ -3792,6 +3785,20 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathfinding"
|
||||||
|
version = "4.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "301ad6aa19104eeb9af172b3d6a4ab8a5ea26234890baf2fcb1cbbc3f05f674b"
|
||||||
|
dependencies = [
|
||||||
|
"deprecate-until",
|
||||||
|
"indexmap",
|
||||||
|
"integer-sqrt",
|
||||||
|
"num-traits",
|
||||||
|
"rustc-hash 2.1.0",
|
||||||
|
"thiserror 2.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.1"
|
version = "2.3.1"
|
||||||
@ -4168,21 +4175,21 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rstest"
|
name = "rstest"
|
||||||
version = "0.23.0"
|
version = "0.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035"
|
checksum = "03e905296805ab93e13c1ec3a03f4b6c4f35e9498a3d5fa96dc626d22c03cd89"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures",
|
|
||||||
"futures-timer",
|
"futures-timer",
|
||||||
|
"futures-util",
|
||||||
"rstest_macros",
|
"rstest_macros",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rstest_macros"
|
name = "rstest_macros"
|
||||||
version = "0.23.0"
|
version = "0.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a"
|
checksum = "ef0053bbffce09062bee4bcc499b0fbe7a57b879f1efe088d6d8d4c7adcdef9b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"glob",
|
"glob",
|
||||||
@ -4213,6 +4220,12 @@ 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 = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@ -5253,7 +5266,7 @@ dependencies = [
|
|||||||
"parking_lot",
|
"parking_lot",
|
||||||
"profiling",
|
"profiling",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"rustc-hash",
|
"rustc-hash 1.1.0",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"wgpu-hal",
|
"wgpu-hal",
|
||||||
@ -5295,7 +5308,7 @@ dependencies = [
|
|||||||
"range-alloc",
|
"range-alloc",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"renderdoc-sys",
|
"renderdoc-sys",
|
||||||
"rustc-hash",
|
"rustc-hash 1.1.0",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "maze-ascension"
|
name = "maze-ascension"
|
||||||
authors = ["Kristofers Solo <dev@kristofers.xyz>"]
|
authors = ["Kristofers Solo <dev@kristofers.xyz>"]
|
||||||
version = "0.3.0"
|
version = "1.1.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@ -18,7 +18,7 @@ tracing = { version = "0.1", features = [
|
|||||||
"release_max_level_warn",
|
"release_max_level_warn",
|
||||||
] }
|
] }
|
||||||
hexx = { version = "0.19", features = ["bevy_reflect", "grid"] }
|
hexx = { version = "0.19", features = ["bevy_reflect", "grid"] }
|
||||||
hexlab = { version = "0.5", features = ["bevy"] }
|
hexlab = { version = "0.6", features = ["bevy", "pathfinding"] }
|
||||||
bevy-inspector-egui = { version = "0.28", optional = true }
|
bevy-inspector-egui = { version = "0.28", optional = true }
|
||||||
bevy_egui = { version = "0.31", optional = true }
|
bevy_egui = { version = "0.31", optional = true }
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
@ -26,7 +26,9 @@ anyhow = "1"
|
|||||||
strum = { version = "0.26", features = ["derive"] }
|
strum = { version = "0.26", features = ["derive"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rstest = "0.23"
|
claims = "0.8.0"
|
||||||
|
rayon = "1.10.0"
|
||||||
|
rstest = "0.24"
|
||||||
rstest_reuse = "0.7"
|
rstest_reuse = "0.7"
|
||||||
test-log = { version = "0.2.16", default-features = false, features = [
|
test-log = { version = "0.2.16", default-features = false, features = [
|
||||||
"trace",
|
"trace",
|
||||||
|
|||||||
49
README.md
@ -1,12 +1,41 @@
|
|||||||
# Maze Ascension: The Labyrinth of Echoes
|
# Maze Ascension: The Labyrinth of Echoes
|
||||||
|
|
||||||
"Maze Ascension: The Labyrinth of Echoes" is a minimalist maze exploration
|
A procedurally generated 3D maze game built with Rust and Bevy game engine.
|
||||||
game built using the Bevy engine. The game features simple visuals with
|
Navigate through hexagonal maze levels that become progressively more
|
||||||
hexagonal tiles forming the maze structure on a white background with black
|
challenging as you ascend.
|
||||||
borders, and a stickman-style player character. Players navigate through
|
[Play on itch.io](https://kristoferssolo.itch.io/maze-ascension)
|
||||||
multiple levels of increasing difficulty, progressing vertically as they
|
|
||||||
climb up through levels. The game includes power-ups and abilities hidden
|
## Features
|
||||||
throughout the maze, and later introduces the ability to move between levels
|
|
||||||
freely. This unique blend of puzzle-solving, exploration, and vertical
|
- Procedurally generated hexagonal mazes
|
||||||
progression offers a fresh twist on traditional maze gameplay, presented in
|
- Multiple floor levels with increasing difficulty
|
||||||
an accessible and clean visual style.
|
- Smooth floor transitions and animations
|
||||||
|
- Power-up system (WIP)
|
||||||
|
- Custom hexagonal grid library implementation
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/kristoferssolo/maze-ascension.git
|
||||||
|
cd maze-ascension
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just native-release
|
||||||
|
# or
|
||||||
|
cargo run --release --no-default-features
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the GPLv3 License - see the [LICENSE](./LICENSE) file for details.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- [Bevy Game Engine](https://bevyengine.org/)
|
||||||
|
- [Red Blob Games' Hexagonal Grids](https://www.redblobgames.com/grids/hexagons/) article for hexagonal grid mathematics
|
||||||
|
- [hexx](https://github.com/ManevilleF/hexx) for hexagonal grid inspiration
|
||||||
|
|||||||
11
README.norg
@ -1,11 +0,0 @@
|
|||||||
* Maze Ascension: The Labyrinth of Echoes
|
|
||||||
"Maze Ascension: The Labyrinth of Echoes" is a minimalist maze exploration
|
|
||||||
game built using the Bevy engine. The game features simple visuals with
|
|
||||||
hexagonal tiles forming the maze structure on a white background with black
|
|
||||||
borders, and a stickman-style player character. Players navigate through
|
|
||||||
multiple levels of increasing difficulty, progressing vertically as they
|
|
||||||
climb up through levels. The game includes power-ups and abilities hidden
|
|
||||||
throughout the maze, and later introduces the ability to move between levels
|
|
||||||
freely. This unique blend of puzzle-solving, exploration, and vertical
|
|
||||||
progression offers a fresh twist on traditional maze gameplay, presented in
|
|
||||||
an accessible and clean visual style.
|
|
||||||
|
Before Width: | Height: | Size: 956 B |
BIN
assets/images/hints/arrows.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
164
assets/images/hints/arrows.svg
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="212.00006"
|
||||||
|
height="137.99994"
|
||||||
|
viewBox="0 0 56.091679 36.512483"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
||||||
|
sodipodi:docname="arrows.svg"
|
||||||
|
inkscape:export-filename="arrows.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#ffffff"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="1"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:zoom="2.8284271"
|
||||||
|
inkscape:cx="77.60497"
|
||||||
|
inkscape:cy="111.5461"
|
||||||
|
inkscape:window-width="1916"
|
||||||
|
inkscape:window-height="1055"
|
||||||
|
inkscape:window-x="1920"
|
||||||
|
inkscape:window-y="21"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs1">
|
||||||
|
<rect
|
||||||
|
x="-77.781746"
|
||||||
|
y="-59.043419"
|
||||||
|
width="239.00209"
|
||||||
|
height="180.66579"
|
||||||
|
id="rect7" />
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(10.819751,8.4666586)">
|
||||||
|
<g
|
||||||
|
id="g8"
|
||||||
|
transform="matrix(0.99998557,0,0,1.0000004,2.6458217,-3.7369281e-6)"
|
||||||
|
inkscape:label="W">
|
||||||
|
<rect
|
||||||
|
style="fill:none;fill-opacity:1;stroke:#575279;stroke-width:1;stroke-miterlimit:10000;stroke-opacity:1;paint-order:markers stroke fill"
|
||||||
|
id="rect3"
|
||||||
|
width="8.4666662"
|
||||||
|
height="8.4666662"
|
||||||
|
x="22.225"
|
||||||
|
y="2.6527839"
|
||||||
|
rx="1.0583333"
|
||||||
|
ry="1.0583333"
|
||||||
|
transform="matrix(1.7887599,0,0,1.7887302,-32.747127,-12.317401)" />
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.9375px;line-height:0px;font-family:'JetBrainsMono NF';-inkscape-font-specification:'JetBrainsMono NF';text-align:center;writing-mode:lr-tb;direction:ltr;text-anchor:middle;fill:#575279;fill-opacity:1;stroke:none;stroke-width:1;stroke-miterlimit:10000;paint-order:markers stroke fill"
|
||||||
|
x="26.458336"
|
||||||
|
y="9.7436171"
|
||||||
|
id="text3"
|
||||||
|
transform="matrix(1.7887599,0,0,1.7887302,-32.747127,-12.317401)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:7.9375px;line-height:0px;font-family:'JetBrainsMono NF';-inkscape-font-specification:'JetBrainsMono NF Bold';fill:#575279;fill-opacity:1;stroke:none;stroke-width:1"
|
||||||
|
x="26.458336"
|
||||||
|
y="9.7436171">W</tspan></text>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g1"
|
||||||
|
transform="matrix(1.7887341,0,0,1.7887309,-49.68,7.261757)"
|
||||||
|
inkscape:label="A">
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.9375px;line-height:0px;font-family:'JetBrainsMono NF';-inkscape-font-specification:'JetBrainsMono NF';text-align:center;writing-mode:lr-tb;direction:ltr;text-anchor:middle;fill:#575279;fill-opacity:1;stroke:none;stroke-width:1;stroke-miterlimit:10000;paint-order:markers stroke fill"
|
||||||
|
x="26.458336"
|
||||||
|
y="9.7436171"
|
||||||
|
id="text1"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan1"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:7.9375px;line-height:0px;font-family:'JetBrainsMono NF';-inkscape-font-specification:'JetBrainsMono NF Bold';fill:#575279;fill-opacity:1;stroke:none;stroke-width:1"
|
||||||
|
x="26.458336"
|
||||||
|
y="9.7436171">A</tspan></text>
|
||||||
|
<rect
|
||||||
|
style="fill:none;fill-opacity:1;stroke:#575279;stroke-width:1;stroke-miterlimit:10000;stroke-opacity:1;paint-order:markers stroke fill"
|
||||||
|
id="rect2"
|
||||||
|
width="8.4666662"
|
||||||
|
height="8.4666662"
|
||||||
|
x="22.225"
|
||||||
|
y="2.6527839"
|
||||||
|
rx="1.0583333"
|
||||||
|
ry="1.0583333" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g5"
|
||||||
|
transform="matrix(0.99998557,0,0,1.0000004,2.6458214,19.579163)"
|
||||||
|
inkscape:label="S">
|
||||||
|
<rect
|
||||||
|
style="fill:none;fill-opacity:1;stroke:#575279;stroke-width:1;stroke-miterlimit:10000;stroke-opacity:1;paint-order:markers stroke fill"
|
||||||
|
id="rect5"
|
||||||
|
width="8.4666662"
|
||||||
|
height="8.4666662"
|
||||||
|
x="22.225"
|
||||||
|
y="2.6527839"
|
||||||
|
rx="1.0583333"
|
||||||
|
ry="1.0583333"
|
||||||
|
transform="matrix(1.7887599,0,0,1.7887302,-32.747127,-12.317401)" />
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.9375px;line-height:0px;font-family:'JetBrainsMono NF';-inkscape-font-specification:'JetBrainsMono NF';text-align:center;writing-mode:lr-tb;direction:ltr;text-anchor:middle;fill:#575279;fill-opacity:1;stroke:none;stroke-width:1;stroke-miterlimit:10000;paint-order:markers stroke fill"
|
||||||
|
x="26.458336"
|
||||||
|
y="9.7436171"
|
||||||
|
id="text5"
|
||||||
|
transform="matrix(1.7887599,0,0,1.7887302,-32.747127,-12.317401)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan5"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:7.9375px;line-height:0px;font-family:'JetBrainsMono NF';-inkscape-font-specification:'JetBrainsMono NF Bold';fill:#575279;fill-opacity:1;stroke:none;stroke-width:1"
|
||||||
|
x="26.458336"
|
||||||
|
y="9.7436171">S</tspan></text>
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
id="g6"
|
||||||
|
transform="matrix(0.99998557,0,0,1.0000004,22.224988,19.579163)"
|
||||||
|
inkscape:label="D">
|
||||||
|
<rect
|
||||||
|
style="fill:none;fill-opacity:1;stroke:#575279;stroke-width:1;stroke-miterlimit:10000;stroke-opacity:1;paint-order:markers stroke fill"
|
||||||
|
id="rect6"
|
||||||
|
width="8.4666662"
|
||||||
|
height="8.4666662"
|
||||||
|
x="22.225"
|
||||||
|
y="2.6527839"
|
||||||
|
rx="1.0583333"
|
||||||
|
ry="1.0583333"
|
||||||
|
transform="matrix(1.7887599,0,0,1.7887302,-32.747127,-12.317401)" />
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.9375px;line-height:0px;font-family:'JetBrainsMono NF';-inkscape-font-specification:'JetBrainsMono NF';text-align:center;writing-mode:lr-tb;direction:ltr;text-anchor:middle;fill:#575279;fill-opacity:1;stroke:none;stroke-width:1;stroke-miterlimit:10000;paint-order:markers stroke fill"
|
||||||
|
x="26.458336"
|
||||||
|
y="9.7436171"
|
||||||
|
id="text6"
|
||||||
|
transform="matrix(1.7887599,0,0,1.7887302,-32.747127,-12.317401)"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan6"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:7.9375px;line-height:0px;font-family:'JetBrainsMono NF';-inkscape-font-specification:'JetBrainsMono NF Bold';fill:#575279;fill-opacity:1;stroke:none;stroke-width:1"
|
||||||
|
x="26.458336"
|
||||||
|
y="9.7436171">D</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
transform="matrix(0.26458715,0,0,0.26458323,-18.224937,-0.61706746)"
|
||||||
|
id="text7"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:30px;line-height:0px;font-family:'JetBrainsMono NF';-inkscape-font-specification:'JetBrainsMono NF Bold';text-align:center;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect7);display:inline;fill:#575279;stroke:#575279;stroke-width:3.77953;stroke-miterlimit:10000;paint-order:markers stroke fill" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.7 KiB |
BIN
assets/images/hints/interaction.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
79
assets/images/hints/interaction.svg
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="227.09914"
|
||||||
|
height="47.000492"
|
||||||
|
viewBox="0 0 60.086643 12.435547"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
||||||
|
sodipodi:docname="interaction.svg"
|
||||||
|
inkscape:export-filename="interaction.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#ffffff"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="1"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:zoom="2.8284271"
|
||||||
|
inkscape:cx="67.882252"
|
||||||
|
inkscape:cy="68.766135"
|
||||||
|
inkscape:window-width="1916"
|
||||||
|
inkscape:window-height="1055"
|
||||||
|
inkscape:window-x="1920"
|
||||||
|
inkscape:window-y="21"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="g3" />
|
||||||
|
<defs
|
||||||
|
id="defs1">
|
||||||
|
<rect
|
||||||
|
x="-5.4800777"
|
||||||
|
y="38.183765"
|
||||||
|
width="124.98112"
|
||||||
|
height="48.260036"
|
||||||
|
id="rect1" />
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(16.041967,3.3894905)">
|
||||||
|
<g
|
||||||
|
id="g8"
|
||||||
|
transform="matrix(0.99998557,0,0,1.0000004,3.7019837e-6,-3.7020056e-6)">
|
||||||
|
<g
|
||||||
|
id="g3"
|
||||||
|
transform="translate(-11.729167,-2.1527839)">
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:13.2293px;line-height:0px;font-family:'JetBrainsMono NF';-inkscape-font-specification:'JetBrainsMono NF';text-align:center;word-spacing:0px;writing-mode:lr-tb;direction:ltr;text-anchor:middle;fill:#575279;fill-opacity:1;stroke:none;stroke-width:1.00001;stroke-miterlimit:10000;stroke-dasharray:none;paint-order:markers stroke fill"
|
||||||
|
x="26.458336"
|
||||||
|
y="9.7436171"
|
||||||
|
id="text3"><tspan
|
||||||
|
sodipodi:role="line"
|
||||||
|
id="tspan3"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;line-height:0px;font-family:'JetBrainsMono NF';-inkscape-font-specification:'JetBrainsMono NF';word-spacing:0px;writing-mode:lr-tb;direction:rtl;baseline-shift:baseline;fill:#575279;fill-opacity:1;stroke:none;stroke-width:1.00001;stroke-dasharray:none"
|
||||||
|
x="26.458336"
|
||||||
|
y="9.7436171"
|
||||||
|
dy="0"
|
||||||
|
dx="0">Press[E]</tspan></text>
|
||||||
|
<text
|
||||||
|
xml:space="preserve"
|
||||||
|
transform="matrix(0.26458715,0,0,0.26458323,11.729163,2.1527876)"
|
||||||
|
id="text1"
|
||||||
|
style="font-size:30px;line-height:0px;font-family:'JetBrainsMono NF';-inkscape-font-specification:'JetBrainsMono NF';text-align:center;writing-mode:lr-tb;direction:ltr;white-space:pre;shape-inside:url(#rect1);display:inline;fill:#575279;stroke:#575279;stroke-width:3.77953;stroke-miterlimit:10000;paint-order:markers stroke fill" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
354
docs/design.md
@ -1,354 +0,0 @@
|
|||||||
# Design philosophy
|
|
||||||
|
|
||||||
The high-level goal of this template is to feel like the official template that is currently missing from Bevy.
|
|
||||||
The exists an [official CI template](https://github.com/bevyengine/bevy_github_ci_template), but, in our opinion,
|
|
||||||
that one is currently more of an extension to the [Bevy examples](https://bevyengine.org/examples/) than an actual template.
|
|
||||||
We say this because it is extremely bare-bones and as such does not provide things that in practice are necessary for game development.
|
|
||||||
|
|
||||||
## Principles
|
|
||||||
|
|
||||||
So, how would an official template that is built for real-world game development look like?
|
|
||||||
The Bevy Jam working group has agreed on the following guiding design principles:
|
|
||||||
|
|
||||||
- Show how to do things in pure Bevy. This means using no 3rd-party dependencies.
|
|
||||||
- Have some basic game code written out already.
|
|
||||||
- Have everything outside of code already set up.
|
|
||||||
- Nice IDE support.
|
|
||||||
- `cargo-generate` support.
|
|
||||||
- Workflows that provide CI and CD with an auto-publish to itch.io.
|
|
||||||
- Builds configured for performance by default.
|
|
||||||
- Answer questions that will quickly come up when creating an actual game.
|
|
||||||
- How do I structure my code?
|
|
||||||
- How do I preload assets?
|
|
||||||
- What are best practices for creating UI?
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
The last point means that in order to make this template useful for real-life projects,
|
|
||||||
we have to make some decisions that are necessarily opinionated.
|
|
||||||
|
|
||||||
These opinions are based on the experience of the Bevy Jam working group and
|
|
||||||
what we have found to be useful in our own projects.
|
|
||||||
If you disagree with any of these, it should be easy to change them.
|
|
||||||
|
|
||||||
Bevy is still young, and many design patterns are still being discovered and refined.
|
|
||||||
Most do not even have an agreed name yet. For some prior work in this area that inspired us,
|
|
||||||
see [the Unofficial Bevy Cheatbook](https://bevy-cheatbook.github.io/) and [bevy_best_practices](https://github.com/tbillington/bevy_best_practices).
|
|
||||||
|
|
||||||
## Pattern Table of Contents
|
|
||||||
|
|
||||||
- [Plugin Organization](#plugin-organization)
|
|
||||||
- [Widgets](#widgets)
|
|
||||||
- [Asset Preloading](#asset-preloading)
|
|
||||||
- [Spawn Commands](#spawn-commands)
|
|
||||||
- [Interaction Callbacks](#interaction-callbacks)
|
|
||||||
- [Dev Tools](#dev-tools)
|
|
||||||
- [Screen States](#screen-states)
|
|
||||||
|
|
||||||
When talking about these, use their name followed by "pattern",
|
|
||||||
e.g. "the widgets pattern", or "the plugin organization pattern".
|
|
||||||
|
|
||||||
## Plugin Organization
|
|
||||||
|
|
||||||
### Pattern
|
|
||||||
|
|
||||||
Structure your code into plugins like so:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// game.rs
|
|
||||||
mod player;
|
|
||||||
mod enemy;
|
|
||||||
mod powerup;
|
|
||||||
|
|
||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
|
||||||
app.add_plugins((player::plugin, enemy::plugin, powerup::plugin));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// player.rs / enemy.rs / powerup.rs
|
|
||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
|
||||||
app.add_systems(Update, (your, systems, here));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reasoning
|
|
||||||
|
|
||||||
Bevy is great at organizing code into plugins. The most lightweight way to do this is by using simple functions as plugins.
|
|
||||||
By splitting your code like this, you can easily keep all your systems and resources locally grouped. Everything that belongs to the `player` is only in `player.rs`, and so on.
|
|
||||||
|
|
||||||
A good rule of thumb is to have one plugin per file,
|
|
||||||
but feel free to leave out a plugin if your file does not need to do anything with the `App`.
|
|
||||||
|
|
||||||
## Widgets
|
|
||||||
|
|
||||||
### Pattern
|
|
||||||
|
|
||||||
Spawn your UI elements by extending the [`Widgets` trait](../src/theme/widgets.rs):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub trait Widgets {
|
|
||||||
fn button(&mut self, text: impl Into<String>) -> EntityCommands;
|
|
||||||
fn header(&mut self, text: impl Into<String>) -> EntityCommands;
|
|
||||||
fn label(&mut self, text: impl Into<String>) -> EntityCommands;
|
|
||||||
fn text_input(&mut self, text: impl Into<String>) -> EntityCommands;
|
|
||||||
fn image(&mut self, texture: Handle<Texture>) -> EntityCommands;
|
|
||||||
fn progress_bar(&mut self, progress: f32) -> EntityCommands;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reasoning
|
|
||||||
|
|
||||||
This pattern is inspired by [sickle_ui](https://github.com/UmbraLuminosa/sickle_ui).
|
|
||||||
`Widgets` is implemented for `Commands` and similar, so you can easily spawn UI elements in your systems.
|
|
||||||
By encapsulating a widget inside a function, you save on a lot of boilerplate code and can easily change the appearance of all widgets of a certain type.
|
|
||||||
By returning `EntityCommands`, you can easily chain multiple widgets together and insert children into a parent widget.
|
|
||||||
|
|
||||||
## Asset Preloading
|
|
||||||
|
|
||||||
### Pattern
|
|
||||||
|
|
||||||
Define your assets with a resource that maps asset paths to `Handle`s.
|
|
||||||
If you're defining the assets in code, add their paths as constants.
|
|
||||||
Otherwise, load them dynamically from e.g. a file.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(Resource, Debug, Deref, DerefMut, Reflect)]
|
|
||||||
#[reflect(Resource)]
|
|
||||||
pub struct ImageHandles(HashMap<String, Handle<Image>>);
|
|
||||||
|
|
||||||
impl ImageHandles {
|
|
||||||
pub const PATH_PLAYER: &'static str = "images/player.png";
|
|
||||||
pub const PATH_ENEMY: &'static str = "images/enemy.png";
|
|
||||||
pub const PATH_POWERUP: &'static str = "images/powerup.png";
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromWorld for ImageHandles {
|
|
||||||
fn from_world(world: &mut World) -> Self {
|
|
||||||
let asset_server = world.resource::<AssetServer>();
|
|
||||||
|
|
||||||
let paths = [
|
|
||||||
ImageHandles::PATH_PLAYER,
|
|
||||||
ImageHandles::PATH_ENEMY,
|
|
||||||
ImageHandles::PATH_POWERUP,
|
|
||||||
];
|
|
||||||
let map = paths
|
|
||||||
.into_iter()
|
|
||||||
.map(|path| (path.to_string(), asset_server.load(path)))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Self(map)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then start preloading in the `assets::plugin`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
|
||||||
app.register_type::<ImageHandles>();
|
|
||||||
app.init_resource::<ImageHandles>();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And finally add a loading check to the `screens::loading::plugin`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn all_assets_loaded(
|
|
||||||
image_handles: Res<ImageHandles>,
|
|
||||||
) -> bool {
|
|
||||||
image_handles.all_loaded(&asset_server)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reasoning
|
|
||||||
|
|
||||||
This pattern is inspired by [bevy_asset_loader](https://github.com/NiklasEi/bevy_asset_loader).
|
|
||||||
By preloading your assets, you can avoid hitches during gameplay.
|
|
||||||
We start loading as soon as the app starts and wait for all assets to be loaded in the loading screen.
|
|
||||||
|
|
||||||
By using strings as keys, you can dynamically load assets based on input data such as a level file.
|
|
||||||
If you prefer a purely static approach, you can also use an `enum YourAssetHandleKey` and `impl AsRef<str> for YourAssetHandleKey`.
|
|
||||||
You can also mix the dynamic and static approach according to your needs.
|
|
||||||
|
|
||||||
## Spawn Commands
|
|
||||||
|
|
||||||
### Pattern
|
|
||||||
|
|
||||||
Spawn a game object by using a custom command. Inside the command,
|
|
||||||
run the spawning code with `world.run_system_once` or `world.run_system_once_with`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// monster.rs
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct SpawnMonster {
|
|
||||||
pub health: u32,
|
|
||||||
pub transform: Transform,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Command for SpawnMonster {
|
|
||||||
fn apply(self, world: &mut World) {
|
|
||||||
world.run_system_once_with(self, spawn_monster);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_monster(
|
|
||||||
spawn_monster: In<SpawnMonster>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
commands.spawn((
|
|
||||||
Name::new("Monster"),
|
|
||||||
Health::new(spawn_monster.health),
|
|
||||||
SpatialBundle::from_transform(spawn_monster.transform),
|
|
||||||
// other components
|
|
||||||
));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And then to use a spawn command, add it to `Commands`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// dangerous_forest.rs
|
|
||||||
|
|
||||||
fn spawn_forest_goblin(mut commands: Commands) {
|
|
||||||
commands.add(SpawnMonster {
|
|
||||||
health: 100,
|
|
||||||
transform: Transform::from_xyz(10.0, 0.0, 0.0),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reasoning
|
|
||||||
|
|
||||||
By encapsulating the spawning of a game object in a custom command,
|
|
||||||
you save on boilerplate code and can easily change the behavior of spawning.
|
|
||||||
We use `world.run_system_once_with` to run the spawning code with the same syntax as a regular system.
|
|
||||||
That way you can easily add system parameters to access things like assets and resources while spawning the entity.
|
|
||||||
|
|
||||||
A limitation of this approach is that calling code cannot extend the spawn call with additional components or children,
|
|
||||||
as custom commands don't return `Entity` or `EntityCommands`. This kind of usage will be possible in future Bevy versions.
|
|
||||||
|
|
||||||
## Interaction Callbacks
|
|
||||||
|
|
||||||
### Pattern
|
|
||||||
|
|
||||||
When spawning an entity that can be interacted with, such as a button that can be pressed,
|
|
||||||
use an observer to handle the interaction:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn spawn_button(mut commands: Commands) {
|
|
||||||
// See the Widgets pattern for information on the `button` method
|
|
||||||
commands.button("Pay up!").observe(pay_money);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pay_money(_trigger: Trigger<OnPress>, mut money: ResMut<Money>) {
|
|
||||||
money.0 -= 10.0;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The event `OnPress`, which is [defined in this template](../src/theme/interaction.rs),
|
|
||||||
is triggered when the button is [`Interaction::Pressed`](https://docs.rs/bevy/latest/bevy/prelude/enum.Interaction.html#variant.Pressed).
|
|
||||||
|
|
||||||
If you have many interactions that only change a state, consider using the following helper function:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn spawn_button(mut commands: Commands) {
|
|
||||||
commands.button("Play the game").observe(enter_state(Screen::Gameplay));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enter_state<S: FreelyMutableState>(
|
|
||||||
new_state: S,
|
|
||||||
) -> impl Fn(Trigger<OnPress>, ResMut<NextState<S>>) {
|
|
||||||
move |_trigger, mut next_state| next_state.set(new_state.clone())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reasoning
|
|
||||||
|
|
||||||
This pattern is inspired by [bevy_mod_picking](https://github.com/aevyrie/bevy_mod_picking).
|
|
||||||
By pairing the system handling the interaction with the entity as an observer,
|
|
||||||
the code running on interactions can be scoped to the exact context of the interaction.
|
|
||||||
|
|
||||||
For example, the code for what happens when you press a *specific* button is directly attached to that exact button.
|
|
||||||
|
|
||||||
This also keeps the interaction logic close to the entity that is interacted with,
|
|
||||||
allowing for better code organization.
|
|
||||||
|
|
||||||
## Dev Tools
|
|
||||||
|
|
||||||
### Pattern
|
|
||||||
|
|
||||||
Add all systems that are only relevant while developing the game to the [`dev_tools` plugin](../src/dev_tools.rs):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// dev_tools.rs
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
|
||||||
app.add_systems(Update, (draw_debug_lines, show_debug_console, show_fps_counter));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reasoning
|
|
||||||
|
|
||||||
The `dev_tools` plugin is only included in dev builds.
|
|
||||||
By adding your dev tools here, you automatically guarantee that they are not included in release builds.
|
|
||||||
|
|
||||||
## Screen States
|
|
||||||
|
|
||||||
### Pattern
|
|
||||||
|
|
||||||
Use the [`Screen`](../src/screen/mod.rs) enum to represent your game's screens as states:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[derive(States, Debug, Hash, PartialEq, Eq, Clone, Default)]
|
|
||||||
pub enum Screen {
|
|
||||||
#[default]
|
|
||||||
Splash,
|
|
||||||
Loading,
|
|
||||||
Title,
|
|
||||||
Credits,
|
|
||||||
Gameplay,
|
|
||||||
Victory,
|
|
||||||
Leaderboard,
|
|
||||||
MultiplayerLobby,
|
|
||||||
SecretMinigame,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Constrain entities that should only be present in a certain screen to that screen by adding a
|
|
||||||
[`StateScoped`](https://docs.rs/bevy/latest/bevy/prelude/struct.StateScoped.html) component to them.
|
|
||||||
Transition between screens by setting the [`NextState<Screen>`](https://docs.rs/bevy/latest/bevy/prelude/enum.NextState.html) resource.
|
|
||||||
|
|
||||||
For each screen, create a plugin that handles the setup and teardown of the screen with `OnEnter` and `OnExit`:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// game_over.rs
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
|
||||||
app.add_systems(OnEnter(Screen::Victory), show_victory_screen);
|
|
||||||
app.add_systems(OnExit(Screen::Victory), reset_highscore);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_victory_screen(mut commands: Commands) {
|
|
||||||
commands.
|
|
||||||
.ui_root()
|
|
||||||
.insert((Name::new("Victory screen"), StateScoped(Screen::Victory)))
|
|
||||||
.with_children(|parent| {
|
|
||||||
// Spawn UI elements.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reset_highscore(mut highscore: ResMut<Highscore>) {
|
|
||||||
*highscore = default();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reasoning
|
|
||||||
|
|
||||||
"Screen" is not meant as a physical screen, but as "what kind of screen is the game showing right now", e.g. the title screen, the loading screen, the credits screen, the victory screen, etc.
|
|
||||||
These screens usually correspond to different logical states of your game that have different systems running.
|
|
||||||
|
|
||||||
By using dedicated `State`s for each screen, you can easily manage systems and entities that are only relevant for a certain screen.
|
|
||||||
This allows you to flexibly transition between screens whenever your game logic requires it.
|
|
||||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 73 KiB |
@ -1,27 +0,0 @@
|
|||||||
# Known Issues
|
|
||||||
|
|
||||||
## My audio is stuttering on web
|
|
||||||
|
|
||||||
There are a number of issues with audio on web, so this is not an exhaustive list. The short version is that you can try the following:
|
|
||||||
|
|
||||||
- If you use materials, make sure to force render pipelines to [load at the start of the game](https://github.com/rparrett/bevy_pipelines_ready/blob/main/src/lib.rs).
|
|
||||||
- Keep the FPS high.
|
|
||||||
- Advise your users to play on Chromium-based browsers.
|
|
||||||
- Apply the suggestions from the blog post [Workaround for the Choppy Music in Bevy Web Builds](https://necrashter.github.io/bevy-choppy-music-workaround).
|
|
||||||
|
|
||||||
## My game window is flashing white for a split second when I start the game on native
|
|
||||||
|
|
||||||
The game window is created before the GPU is ready to render everything.
|
|
||||||
This means that it will start with a white screen for a little bit.
|
|
||||||
The workaround is to [spawn the Window hidden](https://github.com/bevyengine/bevy/blob/release-0.14.0/examples/window/window_settings.rs#L29-L32)
|
|
||||||
and then [make it visible after a few frames](https://github.com/bevyengine/bevy/blob/release-0.14.0/examples/window/window_settings.rs#L56-L64).
|
|
||||||
|
|
||||||
## My character or camera is not moving smoothly
|
|
||||||
|
|
||||||
Choppy movement is often caused by movement updates being tied to the frame rate.
|
|
||||||
See the [physics_in_fixed_timestep](https://github.com/bevyengine/bevy/blob/main/examples/movement/physics_in_fixed_timestep.rs) example
|
|
||||||
for how to fix this.
|
|
||||||
|
|
||||||
A camera not moving smoothly is pretty much always caused by the camera position being tied too tightly to the character's position.
|
|
||||||
To give the camera some inertia, use the [`smooth_nudge`](https://github.com/bevyengine/bevy/blob/main/examples/movement/smooth_follow.rs#L127-L142)
|
|
||||||
to interpolate the camera position towards its target position.
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
# Recommended 3rd-party tools
|
|
||||||
|
|
||||||
Check out the [Bevy Assets](https://bevyengine.org/assets/) page for more great options.
|
|
||||||
|
|
||||||
## Libraries
|
|
||||||
|
|
||||||
A few libraries that the authors of this template have vetted and think you might find useful:
|
|
||||||
|
|
||||||
| Name | Category | Description |
|
|
||||||
| -------------------------------------------------------------------------------------- | -------------- | ------------------------------------- |
|
|
||||||
| [`leafwing-input-manager`](https://github.com/Leafwing-Studios/leafwing-input-manager) | Input | Input -> Action mapping |
|
|
||||||
| [`bevy_mod_picking`](https://github.com/aevyrie/bevy_mod_picking) | Input | Advanced mouse interaction |
|
|
||||||
| [`bevy-inspector-egui`](https://github.com/jakobhellermann/bevy-inspector-egui) | Debugging | Live entity inspector |
|
|
||||||
| [`bevy_mod_debugdump`](https://github.com/jakobhellermann/bevy_mod_debugdump) | Debugging | Schedule inspector |
|
|
||||||
| [`avian`](https://github.com/Jondolf/avian) | Physics | Physics engine |
|
|
||||||
| [`bevy_rapier`](https://github.com/dimforge/bevy_rapier) | Physics | Physics engine (not ECS-driven) |
|
|
||||||
| [`bevy_common_assets`](https://github.com/NiklasEi/bevy_common_assets) | Asset loading | Asset loaders for common file formats |
|
|
||||||
| [`bevy_asset_loader`](https://github.com/NiklasEi/bevy_asset_loader) | Asset loading | Asset management tools |
|
|
||||||
| [`iyes_progress`](https://github.com/IyesGames/iyes_progress) | Asset loading | Progress tracking |
|
|
||||||
| [`bevy_kira_audio`](https://github.com/NiklasEi/bevy_kira_audio) | Audio | Advanced audio |
|
|
||||||
| [`sickle_ui`](https://github.com/UmbraLuminosa/sickle_ui) | UI | UI widgets |
|
|
||||||
| [`bevy_egui`](https://github.com/mvlabat/bevy_egui) | UI / Debugging | UI framework (great for debug UI) |
|
|
||||||
| [`tiny_bail`](https://github.com/benfrankel/tiny_bail) | Error handling | Error handling macros |
|
|
||||||
|
|
||||||
In particular:
|
|
||||||
|
|
||||||
- `leafwing-input-manager` and `bevy_mod_picking` are very likely to be upstreamed into Bevy in the near future.
|
|
||||||
- `bevy-inspector-egui` and `bevy_mod_debugdump` help fill the gap until Bevy has its own editor.
|
|
||||||
- `avian` or `bevy_rapier` helps fill the gap until Bevy has its own physics engine. `avian` is easier to use, while `bevy_rapier` is more performant.
|
|
||||||
- `sickle_ui` is well-aligned with `bevy_ui` and helps fill the gap until Bevy has a full collection of UI widgets.
|
|
||||||
|
|
||||||
None of these are necessary, but they can save you a lot of time and effort.
|
|
||||||
|
|
||||||
## VS Code extensions
|
|
||||||
|
|
||||||
If you're using [VS Code](https://code.visualstudio.com/), the following extensions are highly recommended:
|
|
||||||
|
|
||||||
| Name | Description |
|
|
||||||
|-----------------------------------------------------------------------------------------------------------|-----------------------------------|
|
|
||||||
| [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) | Rust support |
|
|
||||||
| [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml) | TOML support |
|
|
||||||
| [vscode-ron](https://marketplace.visualstudio.com/items?itemName=a5huynh.vscode-ron) | RON support |
|
|
||||||
| [Dependi](https://marketplace.visualstudio.com/items?itemName=fill-labs.dependi) | `crates.io` dependency resolution |
|
|
||||||
| [EditorConfig for VS Code](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) | `.editorconfig` support |
|
|
||||||
|
|
||||||
> [!Note]
|
|
||||||
> <details>
|
|
||||||
> <summary>About the included rust-analyzer settings</summary>
|
|
||||||
>
|
|
||||||
> This template sets [`rust-analyzer.cargo.targetDir`](https://rust-analyzer.github.io/generated_config.html#rust-analyzer.cargo.targetDir)
|
|
||||||
> to `true` in [`.vscode/settings.json`](../.vscode/settings.json).
|
|
||||||
>
|
|
||||||
> This makes `rust-analyzer` use a different `target` directory than `cargo`,
|
|
||||||
> which means that you can run commands like `cargo run` even while `rust-analyzer` is still indexing.
|
|
||||||
> As a trade-off, this will use more disk space.
|
|
||||||
>
|
|
||||||
> If that is an issue for you, you can set it to `false` or remove the setting entirely.
|
|
||||||
> </details>
|
|
||||||
|
|
||||||
## Other templates
|
|
||||||
|
|
||||||
There are many other Bevy templates out there.
|
|
||||||
Check out the [templates category](https://bevyengine.org/assets/#templates) on Bevy Assets for more options.
|
|
||||||
Even if you don't end up using them, they are a great way to learn how to implement certain features you might be interested in.
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
# Workflows
|
|
||||||
|
|
||||||
This template uses [GitHub workflows](https://docs.github.com/en/actions/using-workflows) for [CI / CD](https://www.redhat.com/en/topics/devops/what-is-ci-cd), defined in [`.github/workflows/`](../.github/workflows).
|
|
||||||
|
|
||||||
## CI (testing)
|
|
||||||
|
|
||||||
The [CI workflow](.github/workflows/ci.yaml) will trigger on every commit or PR to `main`, and do the following:
|
|
||||||
|
|
||||||
- Run tests.
|
|
||||||
- Run Clippy lints.
|
|
||||||
- Check formatting.
|
|
||||||
- Check documentation.
|
|
||||||
|
|
||||||
> [!Tip]
|
|
||||||
> <details>
|
|
||||||
> <summary>You may want to set up a <a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets">GitHub ruleset</a> to require that all commits to <code>main</code> pass CI.</summary>
|
|
||||||
>
|
|
||||||
> <img src="img/workflow-ruleset.png" alt="A screenshot showing a GitHub ruleset with status checks enabled" width="100%">
|
|
||||||
> </details>
|
|
||||||
|
|
||||||
## CD (releasing)
|
|
||||||
|
|
||||||
The [CD workflow](../.github/workflows/release.yaml) will trigger on every pushed tag in the format `v1.2.3`, and do the following:
|
|
||||||
|
|
||||||
- Create a release build for Windows, macOS, Linux, and web.
|
|
||||||
- (Optional) Upload to [GitHub releases](https://docs.github.com/en/repositories/releasing-projects-on-github).
|
|
||||||
- (Optional) Upload to [itch.io](https://itch.io).
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>This workflow can also be triggered manually.</summary>
|
|
||||||
|
|
||||||
In your GitHub repository, navigate to `Actions > Release > Run workflow`:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Enter a version number in the format `v1.2.3`, then hit the green `Run workflow` button.
|
|
||||||
</details>
|
|
||||||
|
|
||||||
> [!Important]
|
|
||||||
> Using this workflow requires some setup. We will go through this now.
|
|
||||||
|
|
||||||
### Configure environment variables
|
|
||||||
|
|
||||||
The release workflow can be configured by tweaking the environment variables in [`.github/workflows/release.yaml`](../.github/workflows/release.yaml).
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Click here for a list of variables and how they're used.</summary>
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# The base filename of the binary produced by `cargo build`.
|
|
||||||
cargo_build_binary_name: bevy_quickstart
|
|
||||||
|
|
||||||
# The path to the assets directory.
|
|
||||||
assets_path: assets
|
|
||||||
|
|
||||||
# Whether to upload the packages produced by this workflow to a GitHub release.
|
|
||||||
upload_to_github: true
|
|
||||||
|
|
||||||
# The itch.io project to upload to in the format `user-name/project-name`.
|
|
||||||
# There will be no upload to itch.io if this is commented out.
|
|
||||||
upload_to_itch: the-bevy-flock/bevy-quickstart
|
|
||||||
|
|
||||||
############
|
|
||||||
# ADVANCED #
|
|
||||||
############
|
|
||||||
|
|
||||||
# The ID of the app produced by this workflow.
|
|
||||||
# Applies to macOS releases.
|
|
||||||
# Must contain only A-Z, a-z, 0-9, hyphens, and periods: https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleidentifier
|
|
||||||
app_id: the-bevy-flock.bevy-quickstart
|
|
||||||
|
|
||||||
# The base filename of the binary in the package produced by this workflow.
|
|
||||||
# Applies to Windows, macOS, and Linux releases.
|
|
||||||
# Defaults to `cargo_build_binary_name` if commented out.
|
|
||||||
app_binary_name: bevy_quickstart
|
|
||||||
|
|
||||||
# The name of the `.zip` or `.dmg` file produced by this workflow.
|
|
||||||
# Defaults to `app_binary_name` if commented out.
|
|
||||||
app_package_name: bevy_quickstart
|
|
||||||
|
|
||||||
# The display name of the app produced by this workflow.
|
|
||||||
# Applies to macOS releases.
|
|
||||||
# Defaults to `app_package_name` if commented out.
|
|
||||||
app_display_name: Bevy Quickstart
|
|
||||||
|
|
||||||
# The short display name of the app produced by this workflow.
|
|
||||||
# Applies to macOS releases.
|
|
||||||
# Must be 15 or fewer characters: https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundlename
|
|
||||||
# Defaults to `app_display_name` if commented out.
|
|
||||||
app_short_name: Bevy Quickstart
|
|
||||||
|
|
||||||
# Before enabling LFS, please take a look at GitHub's documentation for costs and quota limits:
|
|
||||||
# https://docs.github.com/en/repositories/working-with-files/managing-large-files/about-storage-and-bandwidth-usage
|
|
||||||
git_lfs: false
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
The values are set automatically by `cargo generate`, or you can edit them yourself and push a commit.
|
|
||||||
|
|
||||||
### Set up itch.io upload
|
|
||||||
|
|
||||||
#### Add butler credentials
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>In your GitHub repository, navigate to <code>Settings > Secrets and variables > Actions</code>.</summary>
|
|
||||||
|
|
||||||

|
|
||||||
</details>
|
|
||||||
|
|
||||||
Hit `New repository secret` and enter the following values, then hit `Add secret`:
|
|
||||||
|
|
||||||
- **Name:** `BUTLER_CREDENTIALS`
|
|
||||||
- **Secret:** Your [itch.io API key](https://itch.io/user/settings/api-keys) (create a new one if necessary)
|
|
||||||
|
|
||||||
#### Create itch.io project
|
|
||||||
|
|
||||||
Create a new itch.io project with the same user and project name as in the `upload_to_itch` variable in [`.github/workflows/release.yaml`](../.github/workflows/release.yaml).
|
|
||||||
Hit `Save & view page` at the bottom of the page.
|
|
||||||
|
|
||||||
[Trigger the release workflow](#cd-releasing) for the first time. Once it's done, go back to itch.io and hit `Edit game` in the top left.
|
|
||||||
|
|
||||||
Set `Kind of project` to `HTML`, then find the newly uploaded `web` build and tick the box that says "This file will be played in the browser".
|
|
||||||
|
|
||||||

|
|
||||||
22
justfile
@ -4,16 +4,30 @@ default:
|
|||||||
|
|
||||||
# Run native dev
|
# Run native dev
|
||||||
native-dev:
|
native-dev:
|
||||||
RUST_BACKTRACE=full cargo run
|
RUSTC_WRAPPER=sccache RUST_BACKTRACE=full cargo run
|
||||||
|
|
||||||
# Run native release
|
# Run native release
|
||||||
native-release:
|
native-release:
|
||||||
cargo run --release --no-default-features
|
RUSTC_WRAPPER=sccache cargo run --release --no-default-features
|
||||||
|
|
||||||
# Run web dev
|
# Run web dev
|
||||||
web-dev:
|
web-dev:
|
||||||
RUST_BACKTRACE=full trunk serve
|
RUSTC_WRAPPER=sccache RUST_BACKTRACE=full trunk serve
|
||||||
|
|
||||||
# Run web release
|
# Run web release
|
||||||
web-release:
|
web-release:
|
||||||
trunk serve --release --no-default-features
|
RUSTC_WRAPPER=sccache trunk serve --release --no-default-features
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
test:
|
||||||
|
RUSTC_WRAPPER=sccache cargo test --doc --locked --workspace --no-default-features
|
||||||
|
RUSTC_WRAPPER=sccache cargo nextest run --no-default-features --all-targets
|
||||||
|
|
||||||
|
# Run CI localy
|
||||||
|
ci:
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets --all-features -- --deny warnings
|
||||||
|
cargo doc --workspace --all-features --document-private-items --no-deps
|
||||||
|
just test
|
||||||
|
|||||||
64
src/camera.rs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
use bevy::{input::mouse::MouseWheel, prelude::*};
|
||||||
|
|
||||||
|
use crate::constants::{BASE_ZOOM_SPEED, MAX_ZOOM, MIN_ZOOM, SCROLL_MODIFIER};
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.add_systems(Update, camera_zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct MainCamera;
|
||||||
|
|
||||||
|
pub fn spawn_camera(mut commands: Commands) {
|
||||||
|
commands.spawn((
|
||||||
|
Name::new("Camera"),
|
||||||
|
MainCamera,
|
||||||
|
Camera3d::default(),
|
||||||
|
Transform::from_xyz(200., 200., 0.).looking_at(Vec3::ZERO, Vec3::Y),
|
||||||
|
// Render all UI to this camera.
|
||||||
|
// Not strictly necessary since we only use one camera,
|
||||||
|
// but if we don't use this component, our UI will disappear as soon
|
||||||
|
// as we add another camera. This includes indirect ways of adding cameras like using
|
||||||
|
// [ui node outlines](https://bevyengine.org/news/bevy-0-14/#ui-node-outline-gizmos)
|
||||||
|
// for debugging. So it's good to have this here for future-proofing.
|
||||||
|
IsDefaultUiCamera,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn camera_zoom(
|
||||||
|
mut query: Query<&mut Transform, With<MainCamera>>,
|
||||||
|
mut scrool_evr: EventReader<MouseWheel>,
|
||||||
|
keyboard: Res<ButtonInput<KeyCode>>,
|
||||||
|
time: Res<Time>,
|
||||||
|
) {
|
||||||
|
let Ok(mut transform) = query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_distance = transform.translation.length();
|
||||||
|
|
||||||
|
// Calculate zoom speed based on distance
|
||||||
|
let distance_multiplier = (current_distance / MIN_ZOOM).sqrt();
|
||||||
|
let adjusted_zoom_speed = BASE_ZOOM_SPEED * distance_multiplier;
|
||||||
|
|
||||||
|
let mut zoom_delta = 0.0;
|
||||||
|
|
||||||
|
if keyboard.pressed(KeyCode::Equal) || keyboard.pressed(KeyCode::NumpadAdd) {
|
||||||
|
zoom_delta += adjusted_zoom_speed * time.delta_secs() * 25.;
|
||||||
|
}
|
||||||
|
|
||||||
|
if keyboard.pressed(KeyCode::Minus) || keyboard.pressed(KeyCode::NumpadSubtract) {
|
||||||
|
zoom_delta -= adjusted_zoom_speed * time.delta_secs() * 25.;
|
||||||
|
}
|
||||||
|
|
||||||
|
for ev in scrool_evr.read() {
|
||||||
|
zoom_delta += ev.y * adjusted_zoom_speed * SCROLL_MODIFIER;
|
||||||
|
}
|
||||||
|
|
||||||
|
if zoom_delta != 0.0 {
|
||||||
|
let forward = transform.translation.normalize();
|
||||||
|
let new_distance = (current_distance - zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM);
|
||||||
|
transform.translation = forward * new_distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,26 @@
|
|||||||
pub const MOVEMENT_THRESHOLD: f32 = 0.01;
|
pub const MOVEMENT_THRESHOLD: f32 = 0.01;
|
||||||
pub const WALL_OVERLAP_MODIFIER: f32 = 1.25;
|
pub const WALL_OVERLAP_MODIFIER: f32 = 1.25;
|
||||||
pub const FLOOR_Y_OFFSET: u8 = 100;
|
pub const FLOOR_Y_OFFSET: u8 = 200;
|
||||||
|
pub const MOVEMENT_COOLDOWN: f32 = 1.0; // one second cooldown
|
||||||
|
pub const TITLE: &str = "Maze Ascension: The Labyrinth of Echoes";
|
||||||
|
|
||||||
|
// Base score constants
|
||||||
|
pub const BASE_FLOOR_SCORE: usize = 100;
|
||||||
|
|
||||||
|
// Floor progression constants
|
||||||
|
pub const FLOOR_PROGRESSION_MULTIPLIER: f32 = 1.2;
|
||||||
|
pub const MIN_TIME_MULTIPLIER: f32 = 0.2; // Minimum score multiplier for time
|
||||||
|
pub const TIME_BONUS_MULTIPLIER: f32 = 1.5;
|
||||||
|
// Time scaling constants
|
||||||
|
pub const BASE_PERFECT_TIME: f32 = 10.0; // Base time for floor 1
|
||||||
|
pub const TIME_INCREASE_FACTOR: f32 = 0.15; // Each floor adds 15% more time
|
||||||
|
|
||||||
|
// Constants for camera control
|
||||||
|
|
||||||
|
pub const BASE_ZOOM_SPEED: f32 = 10.0;
|
||||||
|
#[cfg(not(target_family = "wasm"))]
|
||||||
|
pub const SCROLL_MODIFIER: f32 = 1.;
|
||||||
|
#[cfg(target_family = "wasm")]
|
||||||
|
pub const SCROLL_MODIFIER: f32 = 0.01;
|
||||||
|
pub const MIN_ZOOM: f32 = 50.0;
|
||||||
|
pub const MAX_ZOOM: f32 = 2500.0;
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
floor::components::{CurrentFloor, Floor},
|
floor::components::{CurrentFloor, Floor},
|
||||||
maze::{components::MazeConfig, events::RespawnMaze, GlobalMazeConfig, MazePluginLoaded},
|
maze::{commands::RespawnMaze, components::MazeConfig, GlobalMazeConfig},
|
||||||
player::events::RespawnPlayer,
|
player::commands::RespawnPlayer,
|
||||||
|
screens::Screen,
|
||||||
};
|
};
|
||||||
use bevy::{prelude::*, window::PrimaryWindow};
|
use bevy::{prelude::*, window::PrimaryWindow};
|
||||||
use bevy_egui::{
|
use bevy_egui::{
|
||||||
@ -13,9 +14,12 @@ use rand::{thread_rng, Rng};
|
|||||||
use std::ops::RangeInclusive;
|
use std::ops::RangeInclusive;
|
||||||
|
|
||||||
pub fn maze_controls_ui(world: &mut World) {
|
pub fn maze_controls_ui(world: &mut World) {
|
||||||
if world.get_resource::<MazePluginLoaded>().is_none() {
|
if let Some(state) = world.get_resource::<State<Screen>>() {
|
||||||
|
// Check if the current state is NOT Gameplay
|
||||||
|
if *state.get() != Screen::Gameplay {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let Ok(egui_context) = world
|
let Ok(egui_context) = world
|
||||||
.query_filtered::<&mut EguiContext, With<PrimaryWindow>>()
|
.query_filtered::<&mut EguiContext, With<PrimaryWindow>>()
|
||||||
@ -40,6 +44,16 @@ pub fn maze_controls_ui(world: &mut World) {
|
|||||||
if let Some(mut global_config) = world.get_resource_mut::<GlobalMazeConfig>() {
|
if let Some(mut global_config) = world.get_resource_mut::<GlobalMazeConfig>() {
|
||||||
ui.heading("Maze Configuration");
|
ui.heading("Maze Configuration");
|
||||||
|
|
||||||
|
// Display current floor as non-editable text
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Current floor:");
|
||||||
|
let mut floor_text = floor_value.to_string();
|
||||||
|
ui.add_enabled(
|
||||||
|
false,
|
||||||
|
TextEdit::singleline(&mut floor_text).desired_width(10.),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
changed |= add_seed_control(ui, &mut maze_config.seed);
|
changed |= add_seed_control(ui, &mut maze_config.seed);
|
||||||
changed |= add_drag_value_control(ui, "Radius:", &mut maze_config.radius, 1.0, 1..=100);
|
changed |= add_drag_value_control(ui, "Radius:", &mut maze_config.radius, 1.0, 1..=100);
|
||||||
changed |=
|
changed |=
|
||||||
@ -58,11 +72,12 @@ pub fn maze_controls_ui(world: &mut World) {
|
|||||||
// Handle updates
|
// Handle updates
|
||||||
if changed {
|
if changed {
|
||||||
maze_config.update(&global_config);
|
maze_config.update(&global_config);
|
||||||
world.trigger(RespawnMaze {
|
RespawnMaze {
|
||||||
floor: floor_value,
|
floor: floor_value,
|
||||||
config: maze_config,
|
config: maze_config,
|
||||||
});
|
}
|
||||||
world.trigger(RespawnPlayer);
|
.apply(world);
|
||||||
|
RespawnPlayer.apply(world);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,19 +1,17 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
#[derive(Debug, Reflect, Component, Deref, DerefMut)]
|
#[derive(Debug, Reflect, Component, Deref, DerefMut, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
#[reflect(Component)]
|
#[reflect(Component)]
|
||||||
pub struct Floor(pub u8);
|
pub struct Floor(pub u8);
|
||||||
|
|
||||||
#[derive(Debug, Reflect, Component)]
|
#[derive(Debug, Reflect, Component)]
|
||||||
#[reflect(Component)]
|
#[reflect(Component)]
|
||||||
|
#[require(Floor)]
|
||||||
pub struct CurrentFloor;
|
pub struct CurrentFloor;
|
||||||
|
|
||||||
#[derive(Debug, Reflect, Component)]
|
|
||||||
#[reflect(Component)]
|
|
||||||
pub struct NextFloor;
|
|
||||||
|
|
||||||
#[derive(Debug, Reflect, Component, Deref, DerefMut)]
|
#[derive(Debug, Reflect, Component, Deref, DerefMut)]
|
||||||
#[reflect(Component)]
|
#[reflect(Component)]
|
||||||
|
#[require(Floor)]
|
||||||
pub struct FloorYTarget(pub f32);
|
pub struct FloorYTarget(pub f32);
|
||||||
|
|
||||||
impl Default for Floor {
|
impl Default for Floor {
|
||||||
|
|||||||
@ -38,8 +38,8 @@ impl From<TransitionFloor> for f32 {
|
|||||||
impl From<&TransitionFloor> for f32 {
|
impl From<&TransitionFloor> for f32 {
|
||||||
fn from(value: &TransitionFloor) -> Self {
|
fn from(value: &TransitionFloor) -> Self {
|
||||||
match value {
|
match value {
|
||||||
TransitionFloor::Ascend => -1.,
|
TransitionFloor::Ascend => -1., // When ascending, floors move down
|
||||||
TransitionFloor::Descend => 1.,
|
TransitionFloor::Descend => 1., // When descending, floors move up
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/floor/systems/hide.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::floor::components::{CurrentFloor, Floor};
|
||||||
|
|
||||||
|
pub fn hide_upper_floors(
|
||||||
|
mut query: Query<(&mut Visibility, &Floor)>,
|
||||||
|
current_query: Query<&Floor, With<CurrentFloor>>,
|
||||||
|
) {
|
||||||
|
let Ok(current_floor) = current_query.get_single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (mut visibility, floor) in query.iter_mut() {
|
||||||
|
if floor > current_floor {
|
||||||
|
*visibility = Visibility::Hidden
|
||||||
|
} else {
|
||||||
|
*visibility = Visibility::Visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,12 @@
|
|||||||
mod clear_events;
|
|
||||||
mod despawn;
|
mod despawn;
|
||||||
|
mod hide;
|
||||||
mod movement;
|
mod movement;
|
||||||
mod spawn;
|
mod spawn;
|
||||||
|
|
||||||
use crate::maze::MazePluginLoaded;
|
use crate::screens::Screen;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use despawn::despawn_floor;
|
use despawn::despawn_floor;
|
||||||
|
use hide::hide_upper_floors;
|
||||||
use movement::{handle_floor_transition_events, move_floors};
|
use movement::{handle_floor_transition_events, move_floors};
|
||||||
use spawn::spawn_floor;
|
use spawn::spawn_floor;
|
||||||
|
|
||||||
@ -16,8 +17,10 @@ pub(super) fn plugin(app: &mut App) {
|
|||||||
spawn_floor,
|
spawn_floor,
|
||||||
despawn_floor,
|
despawn_floor,
|
||||||
handle_floor_transition_events,
|
handle_floor_transition_events,
|
||||||
move_floors.after(handle_floor_transition_events),
|
move_floors,
|
||||||
|
hide_upper_floors,
|
||||||
)
|
)
|
||||||
.run_if(resource_exists::<MazePluginLoaded>),
|
.chain()
|
||||||
|
.run_if(in_state(Screen::Gameplay)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,39 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
constants::{FLOOR_Y_OFFSET, MOVEMENT_THRESHOLD},
|
constants::{FLOOR_Y_OFFSET, MOVEMENT_THRESHOLD},
|
||||||
floor::{
|
floor::{
|
||||||
components::{CurrentFloor, FloorYTarget, NextFloor},
|
components::{CurrentFloor, Floor, FloorYTarget},
|
||||||
events::TransitionFloor,
|
events::TransitionFloor,
|
||||||
},
|
},
|
||||||
maze::components::HexMaze,
|
maze::components::{HexMaze, MazeConfig},
|
||||||
player::components::{MovementSpeed, Player},
|
player::components::{MovementSpeed, Player},
|
||||||
};
|
};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
/// Move floor entities to their target Y positions based on movement speed
|
||||||
|
///
|
||||||
|
/// # Behavior
|
||||||
|
/// - Calculates movement distance based on player speed and delta time
|
||||||
|
/// - Moves floors towards their target Y position
|
||||||
|
/// - Removes FloorYTarget component when floor reaches destination
|
||||||
pub fn move_floors(
|
pub fn move_floors(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut maze_query: Query<
|
mut maze_query: Query<
|
||||||
(Entity, &mut Transform, &FloorYTarget),
|
(
|
||||||
(With<HexMaze>, With<FloorYTarget>),
|
Entity,
|
||||||
|
&mut Transform,
|
||||||
|
&FloorYTarget,
|
||||||
|
&MazeConfig,
|
||||||
|
Has<CurrentFloor>,
|
||||||
|
),
|
||||||
|
With<FloorYTarget>,
|
||||||
>,
|
>,
|
||||||
player_query: Query<&MovementSpeed, With<Player>>,
|
player_query: Query<&MovementSpeed, With<Player>>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
) {
|
) {
|
||||||
let speed = player_query.get_single().map_or(100., |s| s.0);
|
let speed = player_query.get_single().map_or(100., |s| s.0);
|
||||||
let movement_distance = speed * time.delta_secs();
|
let movement_distance = speed * time.delta_secs();
|
||||||
for (entity, mut transform, movement_state) in maze_query.iter_mut() {
|
for (entity, mut transform, movement_state, config, is_current_floor) in maze_query.iter_mut() {
|
||||||
let delta = movement_state.0 - transform.translation.y;
|
let delta = movement_state.0 - transform.translation.y;
|
||||||
if delta.abs() > MOVEMENT_THRESHOLD {
|
if delta.abs() > MOVEMENT_THRESHOLD {
|
||||||
let movement = delta.signum() * movement_distance.min(delta.abs());
|
let movement = delta.signum() * movement_distance.min(delta.abs());
|
||||||
@ -29,51 +41,69 @@ pub fn move_floors(
|
|||||||
} else {
|
} else {
|
||||||
transform.translation.y = movement_state.0;
|
transform.translation.y = movement_state.0;
|
||||||
commands.entity(entity).remove::<FloorYTarget>();
|
commands.entity(entity).remove::<FloorYTarget>();
|
||||||
|
if is_current_floor {
|
||||||
|
info!("Current floor seed: {}", config.seed);
|
||||||
|
info!(
|
||||||
|
"Start pos: (q={}, r={}). End pos: (q={}, r={})",
|
||||||
|
config.start_pos.x, config.start_pos.y, config.end_pos.x, config.end_pos.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle floor transition events by setting up floor movement targets
|
||||||
|
///
|
||||||
|
/// # Behavior
|
||||||
|
/// - Checks if any floors are currently moving
|
||||||
|
/// - Processes floor transition events
|
||||||
|
/// - Sets target Y positions for all maze entities
|
||||||
|
/// - Updates current and next floor designations
|
||||||
pub fn handle_floor_transition_events(
|
pub fn handle_floor_transition_events(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut maze_query: Query<(Entity, &Transform, Option<&FloorYTarget>), With<HexMaze>>,
|
mut maze_query: Query<(Entity, &Transform, &Floor, Option<&FloorYTarget>), With<HexMaze>>,
|
||||||
current_query: Query<Entity, With<CurrentFloor>>,
|
current_query: Query<(Entity, &Floor), With<CurrentFloor>>,
|
||||||
next_query: Query<Entity, With<NextFloor>>,
|
|
||||||
mut event_reader: EventReader<TransitionFloor>,
|
mut event_reader: EventReader<TransitionFloor>,
|
||||||
) {
|
) {
|
||||||
let is_moving = maze_query
|
let is_moving = maze_query
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(_, _, movement_state)| movement_state.is_some());
|
.any(|(_, _, _, movement_state)| movement_state.is_some());
|
||||||
|
|
||||||
if is_moving {
|
if is_moving {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for event in event_reader.read() {
|
for event in event_reader.read() {
|
||||||
|
let Ok((current_entity, current_floor)) = current_query.get_single() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_floor_num = event.next_floor_num(current_floor);
|
||||||
|
|
||||||
|
let target_entity = maze_query
|
||||||
|
.iter()
|
||||||
|
.find(|(_, _, floor, _)| floor.0 == target_floor_num)
|
||||||
|
.map(|(entity, ..)| entity);
|
||||||
|
|
||||||
|
let Some(target_entity) = target_entity else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
let direction = event.into();
|
let direction = event.into();
|
||||||
|
|
||||||
let Some(current_entity) = current_query.get_single().ok() else {
|
for (entity, transforms, _, movement_state) in maze_query.iter_mut() {
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(next_entity) = next_query.get_single().ok() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (entity, transforms, movement_state) in maze_query.iter_mut() {
|
|
||||||
let target_y = (FLOOR_Y_OFFSET as f32).mul_add(direction, transforms.translation.y);
|
let target_y = (FLOOR_Y_OFFSET as f32).mul_add(direction, transforms.translation.y);
|
||||||
if movement_state.is_none() {
|
if movement_state.is_none() {
|
||||||
commands.entity(entity).insert(FloorYTarget(target_y));
|
commands.entity(entity).insert(FloorYTarget(target_y));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update_current_next_floor(&mut commands, current_entity, next_entity);
|
update_current_next_floor(&mut commands, current_entity, target_entity);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_current_next_floor(commands: &mut Commands, current_entity: Entity, next_entity: Entity) {
|
fn update_current_next_floor(commands: &mut Commands, current: Entity, target: Entity) {
|
||||||
commands.entity(current_entity).remove::<CurrentFloor>();
|
commands.entity(current).remove::<CurrentFloor>();
|
||||||
commands
|
commands.entity(target).insert(CurrentFloor);
|
||||||
.entity(next_entity)
|
|
||||||
.remove::<NextFloor>()
|
|
||||||
.insert(CurrentFloor);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,35 +1,38 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
floor::{
|
floor::{
|
||||||
components::{CurrentFloor, Floor, FloorYTarget},
|
components::{CurrentFloor, Floor, FloorYTarget},
|
||||||
events::TransitionFloor,
|
events::TransitionFloor,
|
||||||
resources::HighestFloor,
|
resources::HighestFloor,
|
||||||
},
|
},
|
||||||
maze::events::SpawnMaze,
|
maze::{commands::SpawnMaze, components::MazeConfig},
|
||||||
};
|
};
|
||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
pub(super) fn spawn_floor(
|
pub fn spawn_floor(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
query: Query<&mut Floor, (With<CurrentFloor>, Without<FloorYTarget>)>,
|
query: Query<(&mut Floor, &MazeConfig), (With<CurrentFloor>, Without<FloorYTarget>)>,
|
||||||
mut event_reader: EventReader<TransitionFloor>,
|
mut event_reader: EventReader<TransitionFloor>,
|
||||||
mut highest_floor: ResMut<HighestFloor>,
|
mut highest_floor: ResMut<HighestFloor>,
|
||||||
) {
|
) {
|
||||||
let Ok(floor) = query.get_single() else {
|
let Ok((current_floor, config)) = query.get_single() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
for event in event_reader.read() {
|
for event in event_reader.read() {
|
||||||
let floor = event.next_floor_num(floor);
|
if current_floor.0 == 1 && *event == TransitionFloor::Descend {
|
||||||
|
info!("Cannot descend below floor 1");
|
||||||
if floor == 1 && *event == TransitionFloor::Descend {
|
|
||||||
warn!("Cannot descend below floor 1");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
highest_floor.0 = highest_floor.0.max(floor);
|
let target_floor = event.next_floor_num(current_floor);
|
||||||
|
highest_floor.0 = highest_floor.0.max(target_floor);
|
||||||
|
|
||||||
info!("Creating level for floor {}", floor);
|
info!("Creating level for floor {}", target_floor);
|
||||||
|
|
||||||
commands.trigger(SpawnMaze { floor, ..default() });
|
commands.queue(SpawnMaze {
|
||||||
|
floor: target_floor,
|
||||||
|
config: MazeConfig::from_self(config),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/hint/assets.rs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Resource, Asset, Reflect, Clone)]
|
||||||
|
#[reflect(Resource)]
|
||||||
|
pub struct HintAssets {
|
||||||
|
#[dependency]
|
||||||
|
pub arrows: Handle<Image>,
|
||||||
|
#[dependency]
|
||||||
|
pub interaction: Handle<Image>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HintAssets {
|
||||||
|
pub const PATH_ARROWS: &str = "images/hints/arrows.png";
|
||||||
|
pub const PATH_INTERACTION: &str = "images/hints/interaction.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromWorld for HintAssets {
|
||||||
|
fn from_world(world: &mut World) -> Self {
|
||||||
|
let assets = world.resource::<AssetServer>();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
arrows: assets.load(Self::PATH_ARROWS),
|
||||||
|
interaction: assets.load(Self::PATH_INTERACTION),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/hint/components.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect, Component, PartialEq, Eq)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub enum Hint {
|
||||||
|
Movement,
|
||||||
|
Interaction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct IdleTimer {
|
||||||
|
pub timer: Timer,
|
||||||
|
pub movement_hint_visible: bool,
|
||||||
|
pub interaction_hint_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdleTimer {
|
||||||
|
pub fn hide_all(&mut self) {
|
||||||
|
self.movement_hint_visible = false;
|
||||||
|
self.interaction_hint_visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IdleTimer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timer: Timer::new(Duration::from_secs(3), TimerMode::Once),
|
||||||
|
movement_hint_visible: false,
|
||||||
|
interaction_hint_visible: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/hint/mod.rs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
pub mod assets;
|
||||||
|
pub mod components;
|
||||||
|
mod systems;
|
||||||
|
|
||||||
|
use bevy::{ecs::system::RunSystemOnce, prelude::*};
|
||||||
|
use components::IdleTimer;
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.register_type::<IdleTimer>()
|
||||||
|
.add_plugins(systems::plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_hint_command(world: &mut World) {
|
||||||
|
let _ = world.run_system_once(systems::spawn::spawn_hints);
|
||||||
|
}
|
||||||
86
src/hint/systems/check.rs
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
use hexx::Hex;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
floor::components::{CurrentFloor, Floor, FloorYTarget},
|
||||||
|
hint::components::{Hint, IdleTimer},
|
||||||
|
maze::components::MazeConfig,
|
||||||
|
player::components::{CurrentPosition, MovementTarget, Player},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn check_player_hints(
|
||||||
|
mut idle_query: Query<&mut IdleTimer>,
|
||||||
|
player_query: Query<(&CurrentPosition, &MovementTarget), With<Player>>,
|
||||||
|
tranitioning: Query<Has<FloorYTarget>>,
|
||||||
|
maze_query: Query<(&MazeConfig, &Floor), With<CurrentFloor>>,
|
||||||
|
mut hint_query: Query<(&mut Visibility, &Hint)>,
|
||||||
|
time: Res<Time>,
|
||||||
|
) {
|
||||||
|
let Ok(mut idle_timer) = idle_query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok((maze_config, floor)) = maze_query.get_single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok((player_pos, movement_target)) = player_query.get_single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_moving = movement_target.is_some() || tranitioning.iter().any(|x| x);
|
||||||
|
|
||||||
|
if is_moving {
|
||||||
|
// Reset timer and hide hints when player moves
|
||||||
|
idle_timer.timer.reset();
|
||||||
|
hide_all_hints(&mut hint_query, &mut idle_timer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick timer when player is idle
|
||||||
|
idle_timer.timer.tick(time.delta());
|
||||||
|
|
||||||
|
if idle_timer.timer.finished() {
|
||||||
|
let on_special_tile = is_on_special_tile(player_pos, maze_config, floor.0);
|
||||||
|
|
||||||
|
if !idle_timer.movement_hint_visible {
|
||||||
|
set_hint_visibility(&mut hint_query, Hint::Movement, true);
|
||||||
|
idle_timer.movement_hint_visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if on_special_tile && !idle_timer.interaction_hint_visible {
|
||||||
|
set_hint_visibility(&mut hint_query, Hint::Interaction, true);
|
||||||
|
idle_timer.interaction_hint_visible = true;
|
||||||
|
} else if !on_special_tile && idle_timer.interaction_hint_visible {
|
||||||
|
set_hint_visibility(&mut hint_query, Hint::Interaction, false);
|
||||||
|
idle_timer.interaction_hint_visible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_all_hints(hint_query: &mut Query<(&mut Visibility, &Hint)>, idle_timer: &mut IdleTimer) {
|
||||||
|
for (mut visibility, _) in hint_query.iter_mut() {
|
||||||
|
*visibility = Visibility::Hidden;
|
||||||
|
}
|
||||||
|
idle_timer.hide_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_hint_visibility(
|
||||||
|
hint_query: &mut Query<(&mut Visibility, &Hint)>,
|
||||||
|
hint: Hint,
|
||||||
|
visible: bool,
|
||||||
|
) {
|
||||||
|
for (mut visibility, hint_type) in hint_query.iter_mut() {
|
||||||
|
if *hint_type == hint {
|
||||||
|
*visibility = if visible {
|
||||||
|
Visibility::Visible
|
||||||
|
} else {
|
||||||
|
Visibility::Hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_on_special_tile(player_pos: &Hex, maze_config: &MazeConfig, floor: u8) -> bool {
|
||||||
|
(*player_pos == maze_config.start_pos && floor != 1) || *player_pos == maze_config.end_pos
|
||||||
|
}
|
||||||
16
src/hint/systems/mod.rs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
mod check;
|
||||||
|
pub mod spawn;
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use check::check_player_hints;
|
||||||
|
|
||||||
|
use super::assets::HintAssets;
|
||||||
|
use crate::{asset_tracking::LoadResource, screens::Screen};
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.load_resource::<HintAssets>();
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
check_player_hints.run_if(in_state(Screen::Gameplay)),
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/hint/systems/spawn.rs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
use bevy::{prelude::*, ui::Val::*};
|
||||||
|
|
||||||
|
use crate::hint::{
|
||||||
|
assets::HintAssets,
|
||||||
|
components::{Hint, IdleTimer},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn spawn_hints(mut commands: Commands, hint_assets: Res<HintAssets>) {
|
||||||
|
commands.spawn((
|
||||||
|
Name::new("Movement hint"),
|
||||||
|
Hint::Movement,
|
||||||
|
Visibility::Hidden,
|
||||||
|
ImageNode {
|
||||||
|
image: hint_assets.arrows.clone(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: Px(20.0),
|
||||||
|
bottom: Px(20.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
commands.spawn((
|
||||||
|
Name::new("Interaction hint"),
|
||||||
|
Hint::Interaction,
|
||||||
|
Visibility::Hidden,
|
||||||
|
ImageNode {
|
||||||
|
image: hint_assets.interaction.clone(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: Px(20.0),
|
||||||
|
bottom: Px(168.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
// Add idle timer
|
||||||
|
commands.spawn(IdleTimer::default());
|
||||||
|
}
|
||||||
33
src/lib.rs
@ -1,12 +1,15 @@
|
|||||||
pub mod asset_tracking;
|
pub mod asset_tracking;
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
|
pub mod camera;
|
||||||
pub mod constants;
|
pub mod constants;
|
||||||
#[cfg(feature = "dev")]
|
#[cfg(feature = "dev")]
|
||||||
pub mod dev_tools;
|
pub mod dev_tools;
|
||||||
pub mod floor;
|
pub mod floor;
|
||||||
|
pub mod hint;
|
||||||
pub mod maze;
|
pub mod maze;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod screens;
|
pub mod screens;
|
||||||
|
pub mod stats;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
|
||||||
use bevy::{
|
use bevy::{
|
||||||
@ -14,6 +17,9 @@ use bevy::{
|
|||||||
audio::{AudioPlugin, Volume},
|
audio::{AudioPlugin, Volume},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
};
|
};
|
||||||
|
use camera::spawn_camera;
|
||||||
|
use constants::TITLE;
|
||||||
|
use theme::{palette::rose_pine, prelude::ColorScheme};
|
||||||
|
|
||||||
pub struct AppPlugin;
|
pub struct AppPlugin;
|
||||||
|
|
||||||
@ -26,7 +32,7 @@ impl Plugin for AppPlugin {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Spawn the main camera.
|
// Spawn the main camera.
|
||||||
app.add_systems(Startup, spawn_camera);
|
app.add_systems(Startup, (spawn_camera, load_background));
|
||||||
|
|
||||||
// Add Bevy plugins.
|
// Add Bevy plugins.
|
||||||
app.add_plugins(
|
app.add_plugins(
|
||||||
@ -40,7 +46,7 @@ impl Plugin for AppPlugin {
|
|||||||
})
|
})
|
||||||
.set(WindowPlugin {
|
.set(WindowPlugin {
|
||||||
primary_window: Window {
|
primary_window: Window {
|
||||||
title: "Maze Ascension: The Labyrinth of Echoes".to_string(),
|
title: TITLE.to_string(),
|
||||||
canvas: Some("#bevy".to_string()),
|
canvas: Some("#bevy".to_string()),
|
||||||
fit_canvas_to_parent: true,
|
fit_canvas_to_parent: true,
|
||||||
prevent_default_event_handling: true,
|
prevent_default_event_handling: true,
|
||||||
@ -51,7 +57,7 @@ impl Plugin for AppPlugin {
|
|||||||
})
|
})
|
||||||
.set(AudioPlugin {
|
.set(AudioPlugin {
|
||||||
global_volume: GlobalVolume {
|
global_volume: GlobalVolume {
|
||||||
volume: Volume::new(0.),
|
volume: Volume::new(0.2),
|
||||||
},
|
},
|
||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
@ -65,6 +71,9 @@ impl Plugin for AppPlugin {
|
|||||||
maze::plugin,
|
maze::plugin,
|
||||||
floor::plugin,
|
floor::plugin,
|
||||||
player::plugin,
|
player::plugin,
|
||||||
|
hint::plugin,
|
||||||
|
stats::plugin,
|
||||||
|
camera::plugin,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Enable dev tools for dev builds.
|
// Enable dev tools for dev builds.
|
||||||
@ -82,21 +91,11 @@ enum AppSet {
|
|||||||
TickTimers,
|
TickTimers,
|
||||||
/// Record player input.
|
/// Record player input.
|
||||||
RecordInput,
|
RecordInput,
|
||||||
/// Do everything else (consider splitting this into further variants).
|
/// Do everything else.
|
||||||
Update,
|
Update,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_camera(mut commands: Commands) {
|
fn load_background(mut commands: Commands) {
|
||||||
commands.spawn((
|
let colorcheme = rose_pine::RosePineDawn::Base;
|
||||||
Name::new("Camera"),
|
commands.insert_resource(ClearColor(colorcheme.to_color()));
|
||||||
Camera3d::default(),
|
|
||||||
Transform::from_xyz(200., 200., 0.).looking_at(Vec3::ZERO, Vec3::Y),
|
|
||||||
// Render all UI to this camera.
|
|
||||||
// Not strictly necessary since we only use one camera,
|
|
||||||
// but if we don't use this component, our UI will disappear as soon
|
|
||||||
// as we add another camera. This includes indirect ways of adding cameras like using
|
|
||||||
// [ui node outlines](https://bevyengine.org/news/bevy-0-14/#ui-node-outline-gizmos)
|
|
||||||
// for debugging. So it's good to have this here for future-proofing.
|
|
||||||
IsDefaultUiCamera,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
|
//! Maze asset management and generation.
|
||||||
|
//!
|
||||||
|
//! Module handles the creation and management of meshes and materials
|
||||||
|
//! used in the maze visualization, including hexagonal tiles and walls.
|
||||||
|
|
||||||
use super::resources::GlobalMazeConfig;
|
use super::resources::GlobalMazeConfig;
|
||||||
use crate::{
|
use crate::{
|
||||||
constants::WALL_OVERLAP_MODIFIER,
|
constants::WALL_OVERLAP_MODIFIER,
|
||||||
theme::{palette::rose_pine::RosePine, prelude::ColorScheme},
|
theme::{palette::rose_pine::RosePineDawn, prelude::ColorScheme},
|
||||||
};
|
};
|
||||||
|
|
||||||
use bevy::{prelude::*, utils::HashMap};
|
use bevy::{prelude::*, utils::HashMap};
|
||||||
use std::f32::consts::FRAC_PI_2;
|
use std::f32::consts::FRAC_PI_2;
|
||||||
use strum::IntoEnumIterator;
|
use strum::IntoEnumIterator;
|
||||||
@ -10,21 +16,32 @@ use strum::IntoEnumIterator;
|
|||||||
const HEX_SIDES: u32 = 6;
|
const HEX_SIDES: u32 = 6;
|
||||||
const WHITE_EMISSION_INTENSITY: f32 = 10.;
|
const WHITE_EMISSION_INTENSITY: f32 = 10.;
|
||||||
|
|
||||||
|
/// Collection of mesh and material assets used in maze rendering.
|
||||||
|
///
|
||||||
|
/// This struct contains all the necessary assets for rendering maze components,
|
||||||
|
/// including hexagonal tiles, walls, and custom colored materials.
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct MazeAssets {
|
pub struct MazeAssets {
|
||||||
|
/// Mesh for hexagonal floor tiles
|
||||||
pub hex_mesh: Handle<Mesh>,
|
pub hex_mesh: Handle<Mesh>,
|
||||||
|
/// Mesh for wall segments
|
||||||
pub wall_mesh: Handle<Mesh>,
|
pub wall_mesh: Handle<Mesh>,
|
||||||
|
/// Default material for hexagonal tiles
|
||||||
pub hex_material: Handle<StandardMaterial>,
|
pub hex_material: Handle<StandardMaterial>,
|
||||||
|
/// Default material for walls
|
||||||
pub wall_material: Handle<StandardMaterial>,
|
pub wall_material: Handle<StandardMaterial>,
|
||||||
pub custom_materials: HashMap<RosePine, Handle<StandardMaterial>>,
|
/// Custom materials mapped to specific colors from the RosePineDawn palette
|
||||||
|
pub custom_materials: HashMap<RosePineDawn, Handle<StandardMaterial>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MazeAssets {
|
impl MazeAssets {
|
||||||
|
/// Creates a new instance of MazeAssets with all necessary meshes and materials.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
meshes: &mut ResMut<Assets<Mesh>>,
|
meshes: &mut Assets<Mesh>,
|
||||||
materials: &mut ResMut<Assets<StandardMaterial>>,
|
materials: &mut Assets<StandardMaterial>,
|
||||||
global_config: &GlobalMazeConfig,
|
global_config: &GlobalMazeConfig,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let custom_materials = RosePine::iter()
|
let custom_materials = RosePineDawn::iter()
|
||||||
.map(|color| (color, materials.add(color.to_standart_material())))
|
.map(|color| (color, materials.add(color.to_standart_material())))
|
||||||
.collect();
|
.collect();
|
||||||
Self {
|
Self {
|
||||||
@ -43,6 +60,7 @@ impl MazeAssets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates a hexagonal mesh for floor tiles.
|
||||||
fn generate_hex_mesh(radius: f32, depth: f32) -> Mesh {
|
fn generate_hex_mesh(radius: f32, depth: f32) -> Mesh {
|
||||||
let hexagon = RegularPolygon {
|
let hexagon = RegularPolygon {
|
||||||
sides: HEX_SIDES,
|
sides: HEX_SIDES,
|
||||||
@ -54,6 +72,7 @@ fn generate_hex_mesh(radius: f32, depth: f32) -> Mesh {
|
|||||||
Mesh::from(prism_shape).rotated_by(rotation)
|
Mesh::from(prism_shape).rotated_by(rotation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates a square mesh for wall segments.
|
||||||
fn generate_square_mesh(depth: f32, wall_size: f32) -> Mesh {
|
fn generate_square_mesh(depth: f32, wall_size: f32) -> Mesh {
|
||||||
let square = Rectangle::new(wall_size, wall_size);
|
let square = Rectangle::new(wall_size, wall_size);
|
||||||
let rectangular_prism = Extrusion::new(square, depth);
|
let rectangular_prism = Extrusion::new(square, depth);
|
||||||
@ -62,6 +81,7 @@ fn generate_square_mesh(depth: f32, wall_size: f32) -> Mesh {
|
|||||||
Mesh::from(rectangular_prism).rotated_by(rotation)
|
Mesh::from(rectangular_prism).rotated_by(rotation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a glowing white material for default tile appearance.
|
||||||
pub fn white_material() -> StandardMaterial {
|
pub fn white_material() -> StandardMaterial {
|
||||||
StandardMaterial {
|
StandardMaterial {
|
||||||
emissive: LinearRgba::new(
|
emissive: LinearRgba::new(
|
||||||
|
|||||||
49
src/maze/commands.rs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
use super::{
|
||||||
|
components::MazeConfig,
|
||||||
|
systems::{despawn::despawn_maze, respawn::respawn_maze, spawn::spawn_maze},
|
||||||
|
};
|
||||||
|
use bevy::{ecs::system::RunSystemOnce, prelude::*};
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect)]
|
||||||
|
pub struct SpawnMaze {
|
||||||
|
pub floor: u8,
|
||||||
|
pub config: MazeConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect)]
|
||||||
|
pub struct RespawnMaze {
|
||||||
|
pub floor: u8,
|
||||||
|
pub config: MazeConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect)]
|
||||||
|
pub struct DespawnMaze {
|
||||||
|
pub floor: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SpawnMaze {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
floor: 1,
|
||||||
|
config: MazeConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command for SpawnMaze {
|
||||||
|
fn apply(self, world: &mut World) {
|
||||||
|
let _ = world.run_system_once_with(self, spawn_maze);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command for RespawnMaze {
|
||||||
|
fn apply(self, world: &mut World) {
|
||||||
|
let _ = world.run_system_once_with(self, respawn_maze);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command for DespawnMaze {
|
||||||
|
fn apply(self, world: &mut World) {
|
||||||
|
let _ = world.run_system_once_with(self, despawn_maze);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,11 @@
|
|||||||
|
//! Maze components and configuration.
|
||||||
|
//!
|
||||||
|
//! Module defines the core components and configuration structures used
|
||||||
|
//! for maze generation and rendering, including hexagonal maze layouts,
|
||||||
|
//! tiles, walls, and maze configuration.
|
||||||
|
use super::{coordinates::is_within_radius, GlobalMazeConfig};
|
||||||
use crate::floor::components::Floor;
|
use crate::floor::components::Floor;
|
||||||
|
|
||||||
use super::GlobalMazeConfig;
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use hexlab::Maze;
|
use hexlab::Maze;
|
||||||
use hexx::{Hex, HexLayout, HexOrientation};
|
use hexx::{Hex, HexLayout, HexOrientation};
|
||||||
@ -19,37 +24,44 @@ pub struct Tile;
|
|||||||
#[reflect(Component)]
|
#[reflect(Component)]
|
||||||
pub struct Wall;
|
pub struct Wall;
|
||||||
|
|
||||||
|
/// Configuration for a single maze instance.
|
||||||
|
///
|
||||||
|
/// Contains all necessary parameters to generate and position a maze,
|
||||||
|
/// including its size, start/end positions, random seed, and layout.
|
||||||
#[derive(Debug, Reflect, Component, Clone)]
|
#[derive(Debug, Reflect, Component, Clone)]
|
||||||
#[reflect(Component)]
|
#[reflect(Component)]
|
||||||
pub struct MazeConfig {
|
pub struct MazeConfig {
|
||||||
|
/// Radius of the hexagonal maze
|
||||||
pub radius: u16,
|
pub radius: u16,
|
||||||
|
/// Starting position in hex coordinates
|
||||||
pub start_pos: Hex,
|
pub start_pos: Hex,
|
||||||
|
/// Ending position in hex coordinates
|
||||||
pub end_pos: Hex,
|
pub end_pos: Hex,
|
||||||
|
/// Random seed for maze generation
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
|
/// Layout configuration for hex-to-world space conversion
|
||||||
pub layout: HexLayout,
|
pub layout: HexLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MazeConfig {
|
impl MazeConfig {
|
||||||
|
/// Creates a new maze configuration with the specified parameters.
|
||||||
fn new(
|
fn new(
|
||||||
radius: u16,
|
radius: u16,
|
||||||
orientation: HexOrientation,
|
orientation: HexOrientation,
|
||||||
seed: Option<u64>,
|
seed: Option<u64>,
|
||||||
global_conig: &GlobalMazeConfig,
|
global_config: &GlobalMazeConfig,
|
||||||
|
start_pos: Option<Hex>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let seed = seed.unwrap_or_else(|| thread_rng().gen());
|
let (seed, mut rng) = setup_rng(seed);
|
||||||
let mut rng = StdRng::seed_from_u64(seed);
|
|
||||||
|
|
||||||
let start_pos = generate_pos(radius, &mut rng);
|
let start_pos = start_pos.unwrap_or_else(|| generate_pos(radius, &mut rng));
|
||||||
let end_pos = generate_pos(radius, &mut rng);
|
|
||||||
|
|
||||||
info!(
|
// Generate end position ensuring start and end are different
|
||||||
"Start pos: (q={}, r={}). End pos: (q={}, r={})",
|
let end_pos = generate_end_pos(radius, start_pos, &mut rng);
|
||||||
start_pos.x, start_pos.y, end_pos.x, end_pos.y
|
|
||||||
);
|
|
||||||
|
|
||||||
let layout = HexLayout {
|
let layout = HexLayout {
|
||||||
orientation,
|
orientation,
|
||||||
hex_size: Vec2::splat(global_conig.hex_size),
|
hex_size: Vec2::splat(global_config.hex_size),
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -62,6 +74,22 @@ impl MazeConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_self(config: &Self) -> Self {
|
||||||
|
let start_pos = config.end_pos;
|
||||||
|
let (seed, mut rng) = setup_rng(None);
|
||||||
|
|
||||||
|
let end_pos = generate_end_pos(config.radius, start_pos, &mut rng);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
radius: config.radius + 1,
|
||||||
|
start_pos,
|
||||||
|
end_pos,
|
||||||
|
seed,
|
||||||
|
layout: config.layout.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the maze configuration with new global settings.
|
||||||
pub fn update(&mut self, global_conig: &GlobalMazeConfig) {
|
pub fn update(&mut self, global_conig: &GlobalMazeConfig) {
|
||||||
self.layout.hex_size = Vec2::splat(global_conig.hex_size);
|
self.layout.hex_size = Vec2::splat(global_conig.hex_size);
|
||||||
}
|
}
|
||||||
@ -69,14 +97,265 @@ impl MazeConfig {
|
|||||||
|
|
||||||
impl Default for MazeConfig {
|
impl Default for MazeConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new(8, HexOrientation::Flat, None, &GlobalMazeConfig::default())
|
Self::new(
|
||||||
|
4,
|
||||||
|
HexOrientation::Flat,
|
||||||
|
None,
|
||||||
|
&GlobalMazeConfig::default(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setup_rng(seed: Option<u64>) -> (u64, StdRng) {
|
||||||
|
let seed = seed.unwrap_or_else(|| thread_rng().gen());
|
||||||
|
let rng = StdRng::seed_from_u64(seed);
|
||||||
|
(seed, rng)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_end_pos<R: Rng>(radius: u16, start_pos: Hex, rng: &mut R) -> Hex {
|
||||||
|
let mut end_pos;
|
||||||
|
loop {
|
||||||
|
end_pos = generate_pos(radius, rng);
|
||||||
|
if start_pos != end_pos {
|
||||||
|
return end_pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a random position within a hexagonal radius.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// A valid Hex coordinate within the specified radius
|
||||||
fn generate_pos<R: Rng>(radius: u16, rng: &mut R) -> Hex {
|
fn generate_pos<R: Rng>(radius: u16, rng: &mut R) -> Hex {
|
||||||
let radius = radius as i32;
|
let radius = radius as i32;
|
||||||
Hex::new(
|
|
||||||
rng.gen_range(-radius..radius),
|
loop {
|
||||||
rng.gen_range(-radius..radius),
|
// Generate coordinates using cube coordinate bounds
|
||||||
)
|
let q = rng.gen_range(-radius..=radius);
|
||||||
|
let r = rng.gen_range((-radius).max(-q - radius)..=radius.min(-q + radius));
|
||||||
|
|
||||||
|
if let Ok(is_valid) = is_within_radius(radius, &(q, r)) {
|
||||||
|
if is_valid {
|
||||||
|
return Hex::new(q, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use claims::*;
|
||||||
|
use rstest::*;
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(1)]
|
||||||
|
#[case(2)]
|
||||||
|
#[case(5)]
|
||||||
|
#[case(8)]
|
||||||
|
fn maze_config_new(#[case] radius: u16) {
|
||||||
|
let orientation = HexOrientation::Flat;
|
||||||
|
let seed = Some(12345);
|
||||||
|
let global_config = GlobalMazeConfig::default();
|
||||||
|
|
||||||
|
let config = MazeConfig::new(radius, orientation, seed, &global_config, None);
|
||||||
|
|
||||||
|
assert_eq!(config.radius, radius);
|
||||||
|
assert_eq!(config.seed, 12345);
|
||||||
|
assert_eq!(config.layout.orientation, orientation);
|
||||||
|
|
||||||
|
assert_ok!(is_within_radius(radius, &config.start_pos),);
|
||||||
|
assert_ok!(is_within_radius(radius, &config.end_pos));
|
||||||
|
assert_ne!(config.start_pos, config.end_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(100)]
|
||||||
|
fn maze_config_default(#[case] iterations: u32) {
|
||||||
|
for _ in 0..iterations {
|
||||||
|
let config = MazeConfig::default();
|
||||||
|
let radius = config.radius;
|
||||||
|
|
||||||
|
assert_ok!(is_within_radius(radius, &config.start_pos));
|
||||||
|
assert_ok!(is_within_radius(radius, &config.end_pos));
|
||||||
|
assert_ne!(config.start_pos, config.end_pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maze_config_default_with_seeds() {
|
||||||
|
let test_seeds = [
|
||||||
|
None,
|
||||||
|
Some(0),
|
||||||
|
Some(1),
|
||||||
|
Some(12345),
|
||||||
|
Some(u64::MAX),
|
||||||
|
Some(thread_rng().gen()),
|
||||||
|
];
|
||||||
|
|
||||||
|
for seed in test_seeds {
|
||||||
|
let config = MazeConfig::new(
|
||||||
|
8,
|
||||||
|
HexOrientation::Flat,
|
||||||
|
seed,
|
||||||
|
&GlobalMazeConfig::default(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(config.radius, 8);
|
||||||
|
assert_eq!(config.layout.orientation, HexOrientation::Flat);
|
||||||
|
assert_ok!(is_within_radius(8, &config.start_pos));
|
||||||
|
assert_ok!(is_within_radius(8, &config.end_pos));
|
||||||
|
assert_ne!(config.start_pos, config.end_pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(1.0)]
|
||||||
|
#[case(2.0)]
|
||||||
|
#[case(5.0)]
|
||||||
|
fn maze_config_update(#[case] new_size: f32) {
|
||||||
|
let mut config = MazeConfig::default();
|
||||||
|
let global_config = GlobalMazeConfig {
|
||||||
|
hex_size: new_size,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
config.update(&global_config);
|
||||||
|
|
||||||
|
assert_eq!(config.layout.hex_size.x, new_size);
|
||||||
|
assert_eq!(config.layout.hex_size.y, new_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(5, 1)]
|
||||||
|
#[case(5, 12345)]
|
||||||
|
#[case(8, 67890)]
|
||||||
|
fn generate_pos_with_seed(#[case] radius: u16, #[case] seed: u64) {
|
||||||
|
let mut rng = StdRng::seed_from_u64(seed);
|
||||||
|
|
||||||
|
for _ in 0..10 {
|
||||||
|
let pos = generate_pos(radius, &mut rng);
|
||||||
|
assert_ok!(is_within_radius(radius, &pos),);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_seeds_different_positions() {
|
||||||
|
let config1 = MazeConfig::new(
|
||||||
|
8,
|
||||||
|
HexOrientation::Flat,
|
||||||
|
Some(1),
|
||||||
|
&GlobalMazeConfig::default(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let config2 = MazeConfig::new(
|
||||||
|
8,
|
||||||
|
HexOrientation::Flat,
|
||||||
|
Some(2),
|
||||||
|
&GlobalMazeConfig::default(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_ne!(config1.start_pos, config2.start_pos);
|
||||||
|
assert_ne!(config1.end_pos, config2.end_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_seed_same_positions() {
|
||||||
|
let seed = Some(12345);
|
||||||
|
let config1 = MazeConfig::new(
|
||||||
|
8,
|
||||||
|
HexOrientation::Flat,
|
||||||
|
seed,
|
||||||
|
&GlobalMazeConfig::default(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let config2 = MazeConfig::new(
|
||||||
|
8,
|
||||||
|
HexOrientation::Flat,
|
||||||
|
seed,
|
||||||
|
&GlobalMazeConfig::default(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(config1.start_pos, config2.start_pos);
|
||||||
|
assert_eq!(config1.end_pos, config2.end_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orientation_pointy() {
|
||||||
|
let config = MazeConfig::new(
|
||||||
|
8,
|
||||||
|
HexOrientation::Pointy,
|
||||||
|
None,
|
||||||
|
&GlobalMazeConfig::default(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(config.layout.orientation, HexOrientation::Pointy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_size_zero() {
|
||||||
|
let config = MazeConfig::new(
|
||||||
|
8,
|
||||||
|
HexOrientation::Flat,
|
||||||
|
None,
|
||||||
|
&GlobalMazeConfig {
|
||||||
|
hex_size: 0.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(config.layout.hex_size.x, 0.0);
|
||||||
|
assert_eq!(config.layout.hex_size.y, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn basic_generation() {
|
||||||
|
let mut rng = thread_rng();
|
||||||
|
let radius = 2;
|
||||||
|
let hex = generate_pos(radius, &mut rng);
|
||||||
|
|
||||||
|
// Test that generated position is within radius
|
||||||
|
assert_ok!(is_within_radius(radius as i32, &(hex.x, hex.y)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(1)]
|
||||||
|
#[case(2)]
|
||||||
|
#[case(3)]
|
||||||
|
#[case(6)]
|
||||||
|
fn multiple_radii(#[case] radius: u16) {
|
||||||
|
let mut rng = thread_rng();
|
||||||
|
|
||||||
|
// Generate multiple points for each radius
|
||||||
|
for _ in 0..100 {
|
||||||
|
let hex = generate_pos(radius, &mut rng);
|
||||||
|
assert_ok!(is_within_radius(radius, &hex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_radius() {
|
||||||
|
let mut rng = thread_rng();
|
||||||
|
let hex = generate_pos(0, &mut rng);
|
||||||
|
|
||||||
|
// With radius 0, only (0,0) should be possible
|
||||||
|
assert_eq!(hex.x, 0);
|
||||||
|
assert_eq!(hex.y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_radius() {
|
||||||
|
let mut rng = thread_rng();
|
||||||
|
let radius = 100;
|
||||||
|
let iterations = 100;
|
||||||
|
|
||||||
|
for _ in 0..iterations {
|
||||||
|
let hex = generate_pos(radius, &mut rng);
|
||||||
|
assert_ok!(is_within_radius(radius, &hex));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
139
src/maze/coordinates.rs
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
use hexx::Hex;
|
||||||
|
|
||||||
|
use super::errors::RadiusError;
|
||||||
|
|
||||||
|
pub trait Coordinates {
|
||||||
|
fn get_coords(&self) -> (i32, i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Coordinates for (i32, i32) {
|
||||||
|
fn get_coords(&self) -> (i32, i32) {
|
||||||
|
*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Coordinates for Hex {
|
||||||
|
fn get_coords(&self) -> (i32, i32) {
|
||||||
|
(self.x, self.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_within_radius<R, C>(radius: R, coords: &C) -> Result<bool, RadiusError>
|
||||||
|
where
|
||||||
|
R: Into<i32>,
|
||||||
|
C: Coordinates,
|
||||||
|
{
|
||||||
|
let radius = radius.into();
|
||||||
|
|
||||||
|
if radius < 0 {
|
||||||
|
return Err(RadiusError::NegativeRadius(radius));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (q, r) = coords.get_coords();
|
||||||
|
let s = -q - r; // Calculate third axial coordinate (q + r + s = 0)
|
||||||
|
|
||||||
|
Ok(q.abs().max(r.abs()).max(s.abs()) <= radius)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use claims::*;
|
||||||
|
use rstest::*;
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
// Original test cases
|
||||||
|
#[case(0, (0, 0), true)] // Center point
|
||||||
|
#[case(1, (1, 0), true)] // Point at radius 1
|
||||||
|
#[case(1, (2, 0), false)] // Point outside radius 1
|
||||||
|
#[case(2, (2, 0), true)] // East
|
||||||
|
#[case(2, (0, 2), true)] // Southeast
|
||||||
|
#[case(2, (-2, 2), true)] // Southwest
|
||||||
|
#[case(2, (-2, 0), true)] // West
|
||||||
|
#[case(2, (0, -2), true)] // Northwest
|
||||||
|
#[case(2, (2, -2), true)] // Northeast
|
||||||
|
#[case(2, (3, 0), false)] // Just outside radius 2
|
||||||
|
// Large radius test cases
|
||||||
|
#[case(6, (6, 0), true)] // East at radius 6
|
||||||
|
#[case(6, (0, 6), true)] // Southeast at radius 6
|
||||||
|
#[case(6, (-6, 6), true)] // Southwest at radius 6
|
||||||
|
#[case(6, (-6, 0), true)] // West at radius 6
|
||||||
|
#[case(6, (0, -6), true)] // Northwest at radius 6
|
||||||
|
#[case(6, (6, -6), true)] // Northeast at radius 6
|
||||||
|
#[case(6, (7, 0), false)] // Just outside radius 6 east
|
||||||
|
#[case(6, (4, 4), false)] // Outside radius 6 diagonal
|
||||||
|
#[case(6, (5, 5), false)] // Outside radius 6 diagonal
|
||||||
|
// Edge cases with large radius
|
||||||
|
#[case(6, (6, -3), true)] // Complex position within radius 6
|
||||||
|
#[case(6, (-3, 6), true)] // Complex position within radius 6
|
||||||
|
#[case(6, (3, -6), true)] // Complex position within radius 6
|
||||||
|
#[case(6, (7, -7), false)] // Outside radius 6 corner
|
||||||
|
fn valid_radius_tuple(#[case] radius: i32, #[case] pos: (i32, i32), #[case] expected: bool) {
|
||||||
|
let result = is_within_radius(radius, &pos);
|
||||||
|
assert_ok_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
// Large radius test cases for Hex struct
|
||||||
|
#[case(6, (6, 0), true)] // East at radius 6
|
||||||
|
#[case(6, (0, 6), true)] // Southeast at radius 6
|
||||||
|
#[case(6, (-6, 6), true)] // Southwest at radius 6
|
||||||
|
#[case(6, (-6, 0), true)] // West at radius 6
|
||||||
|
#[case(6, (0, -6), true)] // Northwest at radius 6
|
||||||
|
#[case(6, (6, -6), true)] // Northeast at radius 6
|
||||||
|
#[case(6, (4, 4), false)] // Outside radius 6 diagonal
|
||||||
|
#[case(6, (5, 5), false)] // Outside radius 6 diagonal
|
||||||
|
fn valid_radius_hex(#[case] radius: i32, #[case] pos: (i32, i32), #[case] expected: bool) {
|
||||||
|
let hex = Hex::from(pos);
|
||||||
|
let result = is_within_radius(radius, &hex);
|
||||||
|
assert_ok_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(-1)]
|
||||||
|
#[case(-2)]
|
||||||
|
#[case(-5)]
|
||||||
|
fn negative_radius(#[case] radius: i32) {
|
||||||
|
let result = is_within_radius(radius, &(0, 0));
|
||||||
|
assert_err!(&result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boundary_points() {
|
||||||
|
let radius = 3;
|
||||||
|
// Test points exactly on the boundary of radius 3
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(3, 0)), true); // East boundary
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(0, 3)), true); // Southeast boundary
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(-3, 3)), true); // Southwest boundary
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(-3, 0)), true); // West boundary
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(0, -3)), true); // Northwest boundary
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(3, -3)), true); // Northeast boundary
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_boundary_points() {
|
||||||
|
let radius = 6;
|
||||||
|
// Test points exactly on the boundary of radius 6
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(6, 0)), true); // East boundary
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(0, 6)), true); // Southeast boundary
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(-6, 6)), true); // Southwest boundary
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(-6, 0)), true); // West boundary
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(0, -6)), true); // Northwest boundary
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(6, -6)), true); // Northeast boundary
|
||||||
|
|
||||||
|
// Test points just outside the boundary
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(7, 0)), false); // Just outside east
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(0, 7)), false); // Just outside southeast
|
||||||
|
assert_ok_eq!(is_within_radius(radius, &(-7, 7)), false); // Just outside southwest
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_coordinate_types() {
|
||||||
|
// Test with tuple coordinates
|
||||||
|
assert_ok_eq!(is_within_radius(2, &(1, 1)), true);
|
||||||
|
|
||||||
|
// Test with Hex struct
|
||||||
|
let hex = Hex { x: 1, y: 1 };
|
||||||
|
assert_ok_eq!(is_within_radius(2, &hex), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,7 +25,11 @@ pub enum MazeError {
|
|||||||
Other(#[from] anyhow::Error),
|
Other(#[from] anyhow::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type MazeResult<T> = Result<T, MazeError>;
|
#[derive(Debug, Error)]
|
||||||
|
pub enum RadiusError {
|
||||||
|
#[error("Radius cannot be negative: {0}")]
|
||||||
|
NegativeRadius(i32),
|
||||||
|
}
|
||||||
|
|
||||||
impl MazeError {
|
impl MazeError {
|
||||||
pub fn config_error(msg: impl Into<String>) -> Self {
|
pub fn config_error(msg: impl Into<String>) -> Self {
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
use super::components::MazeConfig;
|
|
||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Debug, Reflect, Event)]
|
|
||||||
pub struct SpawnMaze {
|
|
||||||
pub floor: u8,
|
|
||||||
pub config: MazeConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Reflect, Event)]
|
|
||||||
pub struct RespawnMaze {
|
|
||||||
pub floor: u8,
|
|
||||||
pub config: MazeConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Reflect, Event)]
|
|
||||||
pub struct DespawnMaze {
|
|
||||||
pub floor: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SpawnMaze {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
floor: 1,
|
|
||||||
config: MazeConfig::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +1,20 @@
|
|||||||
mod assets;
|
mod assets;
|
||||||
|
pub mod commands;
|
||||||
pub mod components;
|
pub mod components;
|
||||||
|
pub mod coordinates;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod events;
|
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
mod systems;
|
mod systems;
|
||||||
mod triggers;
|
|
||||||
|
|
||||||
use bevy::{ecs::system::RunSystemOnce, prelude::*};
|
use bevy::prelude::*;
|
||||||
use components::HexMaze;
|
use commands::SpawnMaze;
|
||||||
use events::{DespawnMaze, RespawnMaze, SpawnMaze};
|
pub use resources::GlobalMazeConfig;
|
||||||
pub use resources::{GlobalMazeConfig, MazePluginLoaded};
|
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
pub(super) fn plugin(app: &mut App) {
|
||||||
app.init_resource::<GlobalMazeConfig>()
|
app.init_resource::<GlobalMazeConfig>()
|
||||||
.add_event::<SpawnMaze>()
|
.add_plugins(systems::plugin);
|
||||||
.add_event::<RespawnMaze>()
|
|
||||||
.add_event::<DespawnMaze>()
|
|
||||||
.register_type::<HexMaze>()
|
|
||||||
.add_plugins((systems::plugin, triggers::plugin));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_level_command(world: &mut World) {
|
pub fn spawn_level_command(world: &mut World) {
|
||||||
let _ = world.run_system_once(systems::setup::setup);
|
SpawnMaze::default().apply(world);
|
||||||
world.insert_resource(MazePluginLoaded);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
#[derive(Debug, Default, Reflect, Resource)]
|
|
||||||
#[reflect(Resource)]
|
|
||||||
pub struct MazePluginLoaded;
|
|
||||||
|
|
||||||
#[derive(Debug, Reflect, Resource, Clone)]
|
#[derive(Debug, Reflect, Resource, Clone)]
|
||||||
#[reflect(Resource)]
|
#[reflect(Resource)]
|
||||||
pub struct GlobalMazeConfig {
|
pub struct GlobalMazeConfig {
|
||||||
|
|||||||
27
src/maze/systems/common.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
//! Common maze generation utilities.
|
||||||
|
use crate::maze::{components::MazeConfig, errors::MazeError};
|
||||||
|
use hexlab::prelude::*;
|
||||||
|
|
||||||
|
/// Generates a new maze based on the provided configuration.
|
||||||
|
///
|
||||||
|
/// This function uses a recursive backtracking algorithm to generate
|
||||||
|
/// a hexagonal maze with the specified parameters.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// - `config` - Configuration parameters for maze generation including radius and seed.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// `Result<Maze, MazeError>` - The generated maze or an error if generation fails.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns `MazeError::GenerationFailed` if:
|
||||||
|
/// - The maze builder fails to create a valid maze
|
||||||
|
/// - The provided radius or seed results in an invalid configuration
|
||||||
|
pub fn generate_maze(config: &MazeConfig) -> Result<Maze, MazeError> {
|
||||||
|
MazeBuilder::new()
|
||||||
|
.with_radius(config.radius)
|
||||||
|
.with_seed(config.seed)
|
||||||
|
.with_generator(GeneratorType::RecursiveBacktracking)
|
||||||
|
.build()
|
||||||
|
.map_err(|_| MazeError::generation_failed(config.radius, config.seed))
|
||||||
|
}
|
||||||
19
src/maze/systems/despawn.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
//! Maze despawning functionality.
|
||||||
|
//!
|
||||||
|
//! Module handles the cleanup of maze entities when they need to be removed,
|
||||||
|
//! ensuring proper cleanup of both the maze and all its child entities.
|
||||||
|
use crate::{floor::components::Floor, maze::commands::DespawnMaze};
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
/// Despawns a maze and all its associated entities for a given floor.
|
||||||
|
pub fn despawn_maze(
|
||||||
|
In(DespawnMaze { floor }): In<DespawnMaze>,
|
||||||
|
mut commands: Commands,
|
||||||
|
query: Query<(Entity, &Floor)>,
|
||||||
|
) {
|
||||||
|
match query.iter().find(|(_, f)| f.0 == floor) {
|
||||||
|
Some((entity, _)) => commands.entity(entity).despawn_recursive(),
|
||||||
|
_ => warn!("Floor {} not found for removal", floor),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,14 @@
|
|||||||
pub mod setup;
|
pub mod common;
|
||||||
|
pub mod despawn;
|
||||||
|
pub mod respawn;
|
||||||
|
pub mod spawn;
|
||||||
|
mod toggle_pause;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use toggle_pause::toggle_walls;
|
||||||
|
|
||||||
pub(super) fn plugin(_app: &mut App) {}
|
use crate::screens::Screen;
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.add_systems(Update, toggle_walls.run_if(state_changed::<Screen>));
|
||||||
|
}
|
||||||
|
|||||||
@ -1,25 +1,37 @@
|
|||||||
use super::{common::generate_maze, spawn::spawn_maze_tiles};
|
//! Maze respawning functionality.
|
||||||
|
//!
|
||||||
|
//! Module provides the ability to regenerate mazes for existing floors,
|
||||||
|
//! maintaining the same floor entity but replacing its internal maze structure.
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
floor::components::Floor,
|
floor::components::Floor,
|
||||||
maze::{assets::MazeAssets, errors::MazeError, events::RespawnMaze, GlobalMazeConfig},
|
maze::{assets::MazeAssets, commands::RespawnMaze, errors::MazeError, GlobalMazeConfig},
|
||||||
};
|
};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use hexlab::Maze;
|
use hexlab::Maze;
|
||||||
|
|
||||||
pub(super) fn respawn_maze(
|
use super::{common::generate_maze, spawn::spawn_maze_tiles};
|
||||||
trigger: Trigger<RespawnMaze>,
|
|
||||||
|
/// Respawns a maze for an existing floor with a new configuration.
|
||||||
|
///
|
||||||
|
/// # Behavior:
|
||||||
|
/// - Finds the target floor
|
||||||
|
/// - Generates a new maze configuration
|
||||||
|
/// - Cleans up existing maze tiles
|
||||||
|
/// - Spawns new maze tiles
|
||||||
|
/// - Updates the floor's configuration
|
||||||
|
pub fn respawn_maze(
|
||||||
|
In(RespawnMaze { floor, config }): In<RespawnMaze>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut meshes: ResMut<Assets<Mesh>>,
|
mut meshes: ResMut<Assets<Mesh>>,
|
||||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||||
mut maze_query: Query<(Entity, &Floor, &mut Maze)>,
|
mut maze_query: Query<(Entity, &Floor, &mut Maze)>,
|
||||||
global_config: Res<GlobalMazeConfig>,
|
global_config: Res<GlobalMazeConfig>,
|
||||||
) {
|
) {
|
||||||
let RespawnMaze { floor, config } = trigger.event();
|
|
||||||
|
|
||||||
let (entity, _, mut maze) = match maze_query
|
let (entity, _, mut maze) = match maze_query
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|(_, f, _)| f.0 == *floor)
|
.find(|(_, f, _)| f.0 == floor)
|
||||||
.ok_or(MazeError::FloorNotFound(*floor))
|
.ok_or(MazeError::FloorNotFound(floor))
|
||||||
{
|
{
|
||||||
Ok((entity, floor, maze)) => (entity, floor, maze),
|
Ok((entity, floor, maze)) => (entity, floor, maze),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@ -28,7 +40,7 @@ pub(super) fn respawn_maze(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
*maze = match generate_maze(config) {
|
*maze = match generate_maze(&config) {
|
||||||
Ok(generated_maze) => generated_maze,
|
Ok(generated_maze) => generated_maze,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to update floor ({floor}). {e}");
|
warn!("Failed to update floor ({floor}). {e}");
|
||||||
@ -43,7 +55,7 @@ pub(super) fn respawn_maze(
|
|||||||
entity,
|
entity,
|
||||||
&maze,
|
&maze,
|
||||||
&assets,
|
&assets,
|
||||||
config,
|
&config,
|
||||||
&global_config,
|
&global_config,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
use crate::maze::events::SpawnMaze;
|
|
||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
pub fn setup(mut commands: Commands) {
|
|
||||||
commands.trigger(SpawnMaze::default());
|
|
||||||
}
|
|
||||||
@ -1,36 +1,45 @@
|
|||||||
|
//! Maze spawning and rendering functionality.
|
||||||
|
//!
|
||||||
|
//! Module handles the creation and visualization of hexagonal mazes.
|
||||||
|
|
||||||
use super::common::generate_maze;
|
use super::common::generate_maze;
|
||||||
use crate::{
|
use crate::{
|
||||||
constants::FLOOR_Y_OFFSET,
|
constants::FLOOR_Y_OFFSET,
|
||||||
floor::components::{CurrentFloor, Floor, NextFloor},
|
floor::{
|
||||||
|
components::{CurrentFloor, Floor},
|
||||||
|
events::TransitionFloor,
|
||||||
|
},
|
||||||
maze::{
|
maze::{
|
||||||
assets::MazeAssets,
|
assets::MazeAssets,
|
||||||
|
commands::SpawnMaze,
|
||||||
components::{HexMaze, MazeConfig, Tile, Wall},
|
components::{HexMaze, MazeConfig, Tile, Wall},
|
||||||
events::SpawnMaze,
|
|
||||||
resources::GlobalMazeConfig,
|
resources::GlobalMazeConfig,
|
||||||
},
|
},
|
||||||
theme::palette::rose_pine::RosePine,
|
screens::GameplayElement,
|
||||||
|
theme::palette::rose_pine::RosePineDawn,
|
||||||
};
|
};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use hexlab::prelude::{Tile as HexTile, *};
|
use hexlab::prelude::{Tile as HexTile, *};
|
||||||
use hexx::HexOrientation;
|
use hexx::HexOrientation;
|
||||||
use std::f32::consts::{FRAC_PI_2, FRAC_PI_3, FRAC_PI_6};
|
use std::f32::consts::{FRAC_PI_2, FRAC_PI_3, FRAC_PI_6};
|
||||||
|
|
||||||
pub(super) fn spawn_maze(
|
/// Spawns a new maze for the specified floor on [`SpawnMaze`] event.
|
||||||
trigger: Trigger<SpawnMaze>,
|
pub fn spawn_maze(
|
||||||
|
In(SpawnMaze { floor, config }): In<SpawnMaze>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut meshes: ResMut<Assets<Mesh>>,
|
mut meshes: ResMut<Assets<Mesh>>,
|
||||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||||
maze_query: Query<(Entity, &Floor, &Maze)>,
|
maze_query: Query<(Entity, &Floor, &Maze)>,
|
||||||
global_config: Res<GlobalMazeConfig>,
|
global_config: Res<GlobalMazeConfig>,
|
||||||
|
mut event_writer: EventWriter<TransitionFloor>,
|
||||||
) {
|
) {
|
||||||
let SpawnMaze { floor, config } = trigger.event();
|
if maze_query.iter().any(|(_, f, _)| f.0 == floor) {
|
||||||
|
info!("Floor {} already exists, skipping creation", floor);
|
||||||
if maze_query.iter().any(|(_, f, _)| f.0 == *floor) {
|
|
||||||
warn!("Floor {} already exists, skipping creation", floor);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let maze = match generate_maze(config) {
|
let maze = match generate_maze(&config) {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to generate maze for floor {floor}: {:?}", e);
|
error!("Failed to generate maze for floor {floor}: {:?}", e);
|
||||||
@ -38,38 +47,44 @@ pub(super) fn spawn_maze(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let y_offset = match *floor {
|
// Calculate vertical offset based on floor number
|
||||||
1 => 0,
|
let y_offset = match floor {
|
||||||
_ => FLOOR_Y_OFFSET,
|
1 => 0, // Ground/Initial floor (floor 1) is at y=0
|
||||||
|
_ => FLOOR_Y_OFFSET, // Other floors are offset vertically
|
||||||
} as f32;
|
} as f32;
|
||||||
|
|
||||||
// (floor - 1) * FLOOR_Y_OFFSET
|
|
||||||
|
|
||||||
let entity = commands
|
let entity = commands
|
||||||
.spawn((
|
.spawn((
|
||||||
Name::new(format!("Floor {}", floor)),
|
Name::new(format!("Floor {}", floor)),
|
||||||
HexMaze,
|
HexMaze,
|
||||||
maze.clone(),
|
maze.clone(),
|
||||||
Floor(*floor),
|
Floor(floor),
|
||||||
config.clone(),
|
config.clone(),
|
||||||
Transform::from_translation(Vec3::ZERO.with_y(y_offset)),
|
Transform::from_translation(Vec3::ZERO.with_y(y_offset)),
|
||||||
Visibility::Visible,
|
Visibility::Visible,
|
||||||
|
GameplayElement,
|
||||||
))
|
))
|
||||||
.insert_if(CurrentFloor, || *floor == 1)
|
.insert_if(CurrentFloor, || floor == 1) // Only floor 1 gets CurrentFloor
|
||||||
.insert_if(NextFloor, || *floor != 1)
|
|
||||||
.id();
|
.id();
|
||||||
|
|
||||||
let assets = MazeAssets::new(&mut meshes, &mut materials, &global_config);
|
let assets = MazeAssets::new(&mut meshes, &mut materials, &global_config);
|
||||||
|
|
||||||
spawn_maze_tiles(
|
spawn_maze_tiles(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
entity,
|
entity,
|
||||||
&maze,
|
&maze,
|
||||||
&assets,
|
&assets,
|
||||||
config,
|
&config,
|
||||||
&global_config,
|
&global_config,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TODO: find a better way to handle double event indirection
|
||||||
|
if floor != 1 {
|
||||||
|
event_writer.send(TransitionFloor::Ascend);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns all tiles for a maze as children of the parent maze entity
|
||||||
pub fn spawn_maze_tiles(
|
pub fn spawn_maze_tiles(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
parent_entity: Entity,
|
parent_entity: Entity,
|
||||||
@ -85,6 +100,7 @@ pub fn spawn_maze_tiles(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns a single hexagonal tile with appropriate transforms and materials
|
||||||
pub(super) fn spawn_single_hex_tile(
|
pub(super) fn spawn_single_hex_tile(
|
||||||
parent: &mut ChildBuilder,
|
parent: &mut ChildBuilder,
|
||||||
assets: &MazeAssets,
|
assets: &MazeAssets,
|
||||||
@ -98,15 +114,16 @@ pub(super) fn spawn_single_hex_tile(
|
|||||||
HexOrientation::Flat => Quat::from_rotation_y(FRAC_PI_6), // 30 degrees rotation
|
HexOrientation::Flat => Quat::from_rotation_y(FRAC_PI_6), // 30 degrees rotation
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Select material based on tile position: start, end, or default
|
||||||
let material = match tile.pos() {
|
let material = match tile.pos() {
|
||||||
pos if pos == maze_config.start_pos => assets
|
pos if pos == maze_config.start_pos => assets
|
||||||
.custom_materials
|
.custom_materials
|
||||||
.get(&RosePine::Pine)
|
.get(&RosePineDawn::Pine)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
pos if pos == maze_config.end_pos => assets
|
pos if pos == maze_config.end_pos => assets
|
||||||
.custom_materials
|
.custom_materials
|
||||||
.get(&RosePine::Love)
|
.get(&RosePineDawn::Love)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
_ => assets.hex_material.clone(),
|
_ => assets.hex_material.clone(),
|
||||||
@ -123,12 +140,14 @@ pub(super) fn spawn_single_hex_tile(
|
|||||||
.with_children(|parent| spawn_walls(parent, assets, tile.walls(), global_config));
|
.with_children(|parent| spawn_walls(parent, assets, tile.walls(), global_config));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns walls around a hexagonal tile based on the walls configuration
|
||||||
fn spawn_walls(
|
fn spawn_walls(
|
||||||
parent: &mut ChildBuilder,
|
parent: &mut ChildBuilder,
|
||||||
assets: &MazeAssets,
|
assets: &MazeAssets,
|
||||||
walls: &Walls,
|
walls: &Walls,
|
||||||
global_config: &GlobalMazeConfig,
|
global_config: &GlobalMazeConfig,
|
||||||
) {
|
) {
|
||||||
|
// Base rotation for wall alignment (90 degrees counter-clockwise)
|
||||||
let z_rotation = Quat::from_rotation_z(-FRAC_PI_2);
|
let z_rotation = Quat::from_rotation_z(-FRAC_PI_2);
|
||||||
let y_offset = global_config.height / 2.;
|
let y_offset = global_config.height / 2.;
|
||||||
|
|
||||||
@ -137,12 +156,25 @@ fn spawn_walls(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate the angle for this wall
|
||||||
|
// FRAC_PI_3 = 60 deg
|
||||||
|
// Negative because going clockwise
|
||||||
|
// i * 60 produces: 0, 60, 120, 180, 240, 300
|
||||||
let wall_angle = -FRAC_PI_3 * i as f32;
|
let wall_angle = -FRAC_PI_3 * i as f32;
|
||||||
|
|
||||||
|
// cos(angle) gives x coordinate on unit circle
|
||||||
|
// sin(angle) gives z coordinate on unit circle
|
||||||
|
// Multiply by wall_offset to get actual distance from center
|
||||||
let x_offset = global_config.wall_offset() * f32::cos(wall_angle);
|
let x_offset = global_config.wall_offset() * f32::cos(wall_angle);
|
||||||
let z_offset = global_config.wall_offset() * f32::sin(wall_angle);
|
let z_offset = global_config.wall_offset() * f32::sin(wall_angle);
|
||||||
|
|
||||||
|
// x: distance along x-axis from center
|
||||||
|
// y: vertical offset from center
|
||||||
|
// z: distance along z-axis from center
|
||||||
let pos = Vec3::new(x_offset, y_offset, z_offset);
|
let pos = Vec3::new(x_offset, y_offset, z_offset);
|
||||||
|
|
||||||
|
// 1. Rotate around x-axis to align wall with angle
|
||||||
|
// 2. Add FRAC_PI_2 (90) to make wall perpendicular to angle
|
||||||
let x_rotation = Quat::from_rotation_x(wall_angle + FRAC_PI_2);
|
let x_rotation = Quat::from_rotation_x(wall_angle + FRAC_PI_2);
|
||||||
let final_rotation = z_rotation * x_rotation;
|
let final_rotation = z_rotation * x_rotation;
|
||||||
|
|
||||||
@ -150,6 +182,7 @@ fn spawn_walls(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns a single wall segment with the specified rotation and position
|
||||||
fn spawn_single_wall(parent: &mut ChildBuilder, assets: &MazeAssets, rotation: Quat, offset: Vec3) {
|
fn spawn_single_wall(parent: &mut ChildBuilder, assets: &MazeAssets, rotation: Quat, offset: Vec3) {
|
||||||
parent.spawn((
|
parent.spawn((
|
||||||
Name::new("Wall"),
|
Name::new("Wall"),
|
||||||
13
src/maze/systems/toggle_pause.rs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{maze::components::Wall, screens::Screen};
|
||||||
|
|
||||||
|
pub fn toggle_walls(mut query: Query<&mut Visibility, With<Wall>>, state: Res<State<Screen>>) {
|
||||||
|
for mut visibility in query.iter_mut() {
|
||||||
|
*visibility = match *state.get() {
|
||||||
|
Screen::Gameplay => Visibility::Inherited,
|
||||||
|
Screen::Pause => Visibility::Hidden,
|
||||||
|
_ => *visibility,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +0,0 @@
|
|||||||
use crate::maze::{
|
|
||||||
components::MazeConfig,
|
|
||||||
errors::{MazeError, MazeResult},
|
|
||||||
};
|
|
||||||
use hexlab::prelude::*;
|
|
||||||
|
|
||||||
pub fn generate_maze(config: &MazeConfig) -> MazeResult<Maze> {
|
|
||||||
MazeBuilder::new()
|
|
||||||
.with_radius(config.radius)
|
|
||||||
.with_seed(config.seed)
|
|
||||||
.with_generator(GeneratorType::RecursiveBacktracking)
|
|
||||||
.build()
|
|
||||||
.map_err(|_| MazeError::generation_failed(config.radius, config.seed))
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
use crate::{floor::components::Floor, maze::events::DespawnMaze};
|
|
||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
pub(super) fn despawn_maze(
|
|
||||||
trigger: Trigger<DespawnMaze>,
|
|
||||||
mut commands: Commands,
|
|
||||||
query: Query<(Entity, &Floor)>,
|
|
||||||
) {
|
|
||||||
let DespawnMaze { floor } = trigger.event();
|
|
||||||
match query.iter().find(|(_, f)| f.0 == *floor) {
|
|
||||||
Some((entity, _)) => commands.entity(entity).despawn_recursive(),
|
|
||||||
_ => warn!("Floor {} not found for removal", floor),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
pub mod common;
|
|
||||||
mod despawn;
|
|
||||||
mod respawn;
|
|
||||||
pub mod spawn;
|
|
||||||
|
|
||||||
use bevy::prelude::*;
|
|
||||||
use despawn::despawn_maze;
|
|
||||||
use respawn::respawn_maze;
|
|
||||||
use spawn::spawn_maze;
|
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
|
||||||
app.add_observer(spawn_maze)
|
|
||||||
.add_observer(respawn_maze)
|
|
||||||
.add_observer(despawn_maze);
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
use bevy::prelude::*;
|
use crate::theme::{palette::rose_pine::RosePineDawn, prelude::ColorScheme};
|
||||||
|
|
||||||
use crate::theme::{palette::rose_pine::RosePine, prelude::ColorScheme};
|
use bevy::prelude::*;
|
||||||
|
|
||||||
pub(super) fn generate_pill_mesh(radius: f32, half_length: f32) -> Mesh {
|
pub(super) fn generate_pill_mesh(radius: f32, half_length: f32) -> Mesh {
|
||||||
Mesh::from(Capsule3d {
|
Mesh::from(Capsule3d {
|
||||||
@ -10,10 +10,39 @@ pub(super) fn generate_pill_mesh(radius: f32, half_length: f32) -> Mesh {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn blue_material() -> StandardMaterial {
|
pub(super) fn blue_material() -> StandardMaterial {
|
||||||
let color = RosePine::Pine;
|
let color = RosePineDawn::Pine;
|
||||||
StandardMaterial {
|
StandardMaterial {
|
||||||
base_color: color.to_color(),
|
base_color: color.to_color(),
|
||||||
emissive: color.to_linear_rgba() * 3.,
|
emissive: color.to_linear_rgba() * 3.,
|
||||||
..default()
|
..default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Resource, Asset, Reflect, Clone)]
|
||||||
|
pub struct PlayerAssets {
|
||||||
|
// This #[dependency] attribute marks the field as a dependency of the Asset.
|
||||||
|
// This means that it will not finish loading until the labeled asset is also loaded.
|
||||||
|
#[dependency]
|
||||||
|
pub steps: Vec<Handle<AudioSource>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerAssets {
|
||||||
|
pub const PATH_STEP_1: &str = "audio/sound_effects/step1.ogg";
|
||||||
|
pub const PATH_STEP_2: &str = "audio/sound_effects/step2.ogg";
|
||||||
|
pub const PATH_STEP_3: &str = "audio/sound_effects/step3.ogg";
|
||||||
|
pub const PATH_STEP_4: &str = "audio/sound_effects/step4.ogg";
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromWorld for PlayerAssets {
|
||||||
|
fn from_world(world: &mut World) -> Self {
|
||||||
|
let assets = world.resource::<AssetServer>();
|
||||||
|
Self {
|
||||||
|
steps: vec![
|
||||||
|
assets.load(Self::PATH_STEP_1),
|
||||||
|
assets.load(Self::PATH_STEP_2),
|
||||||
|
assets.load(Self::PATH_STEP_3),
|
||||||
|
assets.load(Self::PATH_STEP_4),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
30
src/player/commands.rs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
use bevy::{ecs::system::RunSystemOnce, prelude::*};
|
||||||
|
|
||||||
|
use super::systems::{despawn::despawn_players, respawn::respawn_player, spawn::spawn_player};
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect)]
|
||||||
|
pub struct SpawnPlayer;
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect)]
|
||||||
|
pub struct RespawnPlayer;
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect)]
|
||||||
|
pub struct DespawnPlayer;
|
||||||
|
|
||||||
|
impl Command for SpawnPlayer {
|
||||||
|
fn apply(self, world: &mut World) {
|
||||||
|
let _ = world.run_system_once(spawn_player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command for RespawnPlayer {
|
||||||
|
fn apply(self, world: &mut World) {
|
||||||
|
let _ = world.run_system_once(respawn_player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command for DespawnPlayer {
|
||||||
|
fn apply(self, world: &mut World) {
|
||||||
|
let _ = world.run_system_once(despawn_players);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +0,0 @@
|
|||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
#[derive(Debug, Event)]
|
|
||||||
pub struct SpawnPlayer;
|
|
||||||
|
|
||||||
#[derive(Debug, Event)]
|
|
||||||
pub struct RespawnPlayer;
|
|
||||||
|
|
||||||
#[derive(Debug, Event)]
|
|
||||||
pub struct DespawnPlayer;
|
|
||||||
@ -1,21 +1,20 @@
|
|||||||
mod assets;
|
pub mod assets;
|
||||||
|
pub mod commands;
|
||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod events;
|
|
||||||
mod systems;
|
mod systems;
|
||||||
mod triggers;
|
|
||||||
|
|
||||||
|
use assets::PlayerAssets;
|
||||||
use bevy::{ecs::system::RunSystemOnce, prelude::*};
|
use bevy::{ecs::system::RunSystemOnce, prelude::*};
|
||||||
use components::Player;
|
use components::Player;
|
||||||
use events::{DespawnPlayer, RespawnPlayer, SpawnPlayer};
|
|
||||||
|
use crate::asset_tracking::LoadResource;
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
pub(super) fn plugin(app: &mut App) {
|
||||||
app.register_type::<Player>()
|
app.register_type::<Player>()
|
||||||
.add_event::<SpawnPlayer>()
|
.load_resource::<PlayerAssets>()
|
||||||
.add_event::<RespawnPlayer>()
|
.add_plugins(systems::plugin);
|
||||||
.add_event::<DespawnPlayer>()
|
|
||||||
.add_plugins((triggers::plugin, systems::plugin));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_player_command(world: &mut World) {
|
pub fn spawn_player_command(world: &mut World) {
|
||||||
let _ = world.run_system_once(systems::setup::setup);
|
let _ = world.run_system_once(systems::spawn::spawn_player);
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/player/systems/despawn.rs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
use crate::player::components::Player;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
pub fn despawn_players(mut commands: Commands, query: Query<Entity, With<Player>>) {
|
||||||
|
for entity in query.iter() {
|
||||||
|
commands.entity(entity).despawn_recursive();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
floor::components::CurrentFloor,
|
floor::components::{CurrentFloor, FloorYTarget},
|
||||||
maze::components::MazeConfig,
|
maze::components::MazeConfig,
|
||||||
player::components::{CurrentPosition, MovementTarget, Player},
|
player::components::{CurrentPosition, MovementTarget, Player},
|
||||||
};
|
};
|
||||||
@ -7,69 +7,344 @@ use bevy::prelude::*;
|
|||||||
use hexlab::prelude::*;
|
use hexlab::prelude::*;
|
||||||
use hexx::{EdgeDirection, HexOrientation};
|
use hexx::{EdgeDirection, HexOrientation};
|
||||||
|
|
||||||
pub(super) fn player_input(
|
/// Handles player movement input based on keyboard controls and maze configuration
|
||||||
|
pub fn player_input(
|
||||||
input: Res<ButtonInput<KeyCode>>,
|
input: Res<ButtonInput<KeyCode>>,
|
||||||
mut player_query: Query<(&mut MovementTarget, &CurrentPosition), With<Player>>,
|
mut player_query: Query<(&mut MovementTarget, &CurrentPosition), With<Player>>,
|
||||||
maze_query: Query<(&Maze, &MazeConfig), With<CurrentFloor>>,
|
maze_query: Query<(&Maze, &MazeConfig, Has<FloorYTarget>), With<CurrentFloor>>,
|
||||||
) {
|
) {
|
||||||
let Ok((maze, maze_config)) = maze_query.get_single() else {
|
let Ok((maze, maze_config, has_y_target)) = maze_query.get_single() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Disable movement while transitioning floors
|
||||||
|
if has_y_target {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (mut target_pos, current_pos) in player_query.iter_mut() {
|
for (mut target_pos, current_pos) in player_query.iter_mut() {
|
||||||
if target_pos.is_some() {
|
if target_pos.is_some() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(direction) = create_direction(&input, &maze_config.layout.orientation) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(tile) = maze.get(current_pos) else {
|
let Some(tile) = maze.get(current_pos) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
if tile.walls().contains(direction) {
|
let Ok(key_direction) = KeyDirection::try_from(&*input) else {
|
||||||
continue;
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let possible_directions = key_direction.related_directions(&maze_config.layout.orientation);
|
||||||
|
|
||||||
|
// Convert to edge directions and filter out walls
|
||||||
|
let mut available_directions = possible_directions
|
||||||
|
.into_iter()
|
||||||
|
.map(EdgeDirection::from)
|
||||||
|
.filter(|dir| !tile.walls().contains(*dir))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if let Some(logical_dir) = key_direction.exact_direction(&maze_config.layout.orientation) {
|
||||||
|
let edge_dir = EdgeDirection::from(logical_dir);
|
||||||
|
if available_directions.contains(&edge_dir) {
|
||||||
|
available_directions = vec![edge_dir];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let next_hex = current_pos.0.neighbor(direction);
|
if available_directions.len() == 1 {
|
||||||
|
if let Some(&next_tile) = available_directions.first() {
|
||||||
|
let next_hex = current_pos.0.neighbor(next_tile);
|
||||||
target_pos.0 = Some(next_hex);
|
target_pos.0 = Some(next_hex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn create_direction(
|
/// Represents possible movement directions from keyboard input
|
||||||
input: &ButtonInput<KeyCode>,
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
orientation: &HexOrientation,
|
enum KeyDirection {
|
||||||
) -> Option<EdgeDirection> {
|
Up, // Single press: W
|
||||||
let w = input.pressed(KeyCode::KeyW);
|
Right, // Single press: D
|
||||||
let a = input.pressed(KeyCode::KeyA);
|
Down, // Single press: S
|
||||||
let s = input.pressed(KeyCode::KeyS);
|
Left, // Single press: A
|
||||||
let d = input.pressed(KeyCode::KeyD);
|
UpRight, // Diagonal: W+D
|
||||||
|
UpLeft, // Diagonal: W+A
|
||||||
|
DownRight, // Diagonal: S+D
|
||||||
|
DownLeft, // Diagonal: S+A
|
||||||
|
}
|
||||||
|
|
||||||
let direction = match orientation {
|
impl KeyDirection {
|
||||||
HexOrientation::Pointy => {
|
/// Converts key direction to exact logical direction based on hex orientation
|
||||||
match (w, a, s, d) {
|
const fn exact_direction(&self, orientation: &HexOrientation) -> Option<LogicalDirection> {
|
||||||
(true, false, false, false) => Some(EdgeDirection::POINTY_WEST), // W
|
match orientation {
|
||||||
(false, false, true, false) => Some(EdgeDirection::POINTY_EAST), // S
|
HexOrientation::Pointy => match self {
|
||||||
(false, true, true, false) => Some(EdgeDirection::POINTY_NORTH_EAST), // A+S
|
Self::Up => Some(LogicalDirection::PointyNorth),
|
||||||
(false, false, true, true) => Some(EdgeDirection::POINTY_SOUTH_EAST), // S+D
|
Self::Down => Some(LogicalDirection::PointySouth),
|
||||||
(true, true, false, false) => Some(EdgeDirection::POINTY_NORTH_WEST), // W+A
|
Self::UpRight => Some(LogicalDirection::PointyNorthEast),
|
||||||
(true, false, false, true) => Some(EdgeDirection::POINTY_SOUTH_WEST), // W+D
|
Self::UpLeft => Some(LogicalDirection::PointyNorthWest),
|
||||||
|
Self::DownRight => Some(LogicalDirection::PointySouthEast),
|
||||||
|
Self::DownLeft => Some(LogicalDirection::PointySouthWest),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
},
|
||||||
}
|
HexOrientation::Flat => match self {
|
||||||
HexOrientation::Flat => {
|
Self::Right => Some(LogicalDirection::FlatEast),
|
||||||
match (w, a, s, d) {
|
Self::Left => Some(LogicalDirection::FlatWest),
|
||||||
(false, true, false, false) => Some(EdgeDirection::FLAT_NORTH), // A
|
Self::UpRight => Some(LogicalDirection::FlatNorthEast),
|
||||||
(false, false, false, true) => Some(EdgeDirection::FLAT_SOUTH), // D
|
Self::UpLeft => Some(LogicalDirection::FlatNorthWest),
|
||||||
(false, true, true, false) => Some(EdgeDirection::FLAT_NORTH_EAST), // A+S
|
Self::DownRight => Some(LogicalDirection::FlatSouthEast),
|
||||||
(false, false, true, true) => Some(EdgeDirection::FLAT_SOUTH_EAST), // S+D
|
Self::DownLeft => Some(LogicalDirection::FlatSouthWest),
|
||||||
(true, true, false, false) => Some(EdgeDirection::FLAT_NORTH_WEST), // W+A
|
|
||||||
(true, false, false, true) => Some(EdgeDirection::FLAT_SOUTH_WEST), // W+D
|
|
||||||
_ => None,
|
_ => None,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}?;
|
|
||||||
Some(direction.rotate_cw(0))
|
/// Returns all possible logical directions for the given key input and hex orientation
|
||||||
|
fn related_directions(&self, orientation: &HexOrientation) -> Vec<LogicalDirection> {
|
||||||
|
match orientation {
|
||||||
|
HexOrientation::Pointy => match self {
|
||||||
|
// Single key presses check multiple directions
|
||||||
|
Self::Up => vec![
|
||||||
|
LogicalDirection::PointyNorth,
|
||||||
|
LogicalDirection::PointyNorthEast,
|
||||||
|
LogicalDirection::PointyNorthWest,
|
||||||
|
],
|
||||||
|
Self::Right => vec![
|
||||||
|
LogicalDirection::PointyNorthEast,
|
||||||
|
LogicalDirection::PointySouthEast,
|
||||||
|
],
|
||||||
|
Self::Down => vec![
|
||||||
|
LogicalDirection::PointySouth,
|
||||||
|
LogicalDirection::PointySouthEast,
|
||||||
|
LogicalDirection::PointySouthWest,
|
||||||
|
],
|
||||||
|
Self::Left => vec![
|
||||||
|
LogicalDirection::PointyNorthWest,
|
||||||
|
LogicalDirection::PointySouthWest,
|
||||||
|
],
|
||||||
|
// Diagonal combinations check specific directions
|
||||||
|
Self::UpRight => vec![LogicalDirection::PointyNorthEast],
|
||||||
|
Self::UpLeft => vec![LogicalDirection::PointyNorthWest],
|
||||||
|
Self::DownRight => vec![LogicalDirection::PointySouthEast],
|
||||||
|
Self::DownLeft => vec![LogicalDirection::PointySouthWest],
|
||||||
|
},
|
||||||
|
HexOrientation::Flat => match self {
|
||||||
|
Self::Up => vec![
|
||||||
|
LogicalDirection::FlatNorthEast,
|
||||||
|
LogicalDirection::FlatNorthWest,
|
||||||
|
],
|
||||||
|
Self::Right => vec![
|
||||||
|
LogicalDirection::FlatEast,
|
||||||
|
LogicalDirection::FlatNorthEast,
|
||||||
|
LogicalDirection::FlatSouthEast,
|
||||||
|
],
|
||||||
|
Self::Down => vec![
|
||||||
|
LogicalDirection::FlatSouthEast,
|
||||||
|
LogicalDirection::FlatSouthWest,
|
||||||
|
],
|
||||||
|
Self::Left => vec![
|
||||||
|
LogicalDirection::FlatWest,
|
||||||
|
LogicalDirection::FlatNorthWest,
|
||||||
|
LogicalDirection::FlatSouthWest,
|
||||||
|
],
|
||||||
|
// Diagonal combinations check specific directions
|
||||||
|
Self::UpRight => vec![LogicalDirection::FlatNorthEast],
|
||||||
|
Self::UpLeft => vec![LogicalDirection::FlatNorthWest],
|
||||||
|
Self::DownRight => vec![LogicalDirection::FlatSouthEast],
|
||||||
|
Self::DownLeft => vec![LogicalDirection::FlatSouthWest],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&ButtonInput<KeyCode>> for KeyDirection {
|
||||||
|
type Error = String;
|
||||||
|
fn try_from(value: &ButtonInput<KeyCode>) -> Result<Self, Self::Error> {
|
||||||
|
let w = value.pressed(KeyCode::KeyW);
|
||||||
|
let a = value.pressed(KeyCode::KeyA);
|
||||||
|
let s = value.pressed(KeyCode::KeyS);
|
||||||
|
let d = value.pressed(KeyCode::KeyD);
|
||||||
|
|
||||||
|
match (w, a, s, d) {
|
||||||
|
// Single key presses
|
||||||
|
(true, false, false, false) => Ok(Self::Up),
|
||||||
|
(false, true, false, false) => Ok(Self::Left),
|
||||||
|
(false, false, true, false) => Ok(Self::Down),
|
||||||
|
(false, false, false, true) => Ok(Self::Right),
|
||||||
|
// Diagonal combinations
|
||||||
|
(true, false, false, true) => Ok(Self::UpRight),
|
||||||
|
(true, true, false, false) => Ok(Self::UpLeft),
|
||||||
|
(false, false, true, true) => Ok(Self::DownRight),
|
||||||
|
(false, true, true, false) => Ok(Self::DownLeft),
|
||||||
|
_ => Err("Invalid direction key combination".to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents logical directions in both pointy and flat hex orientations
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum LogicalDirection {
|
||||||
|
// For Pointy orientation
|
||||||
|
PointyNorth, // W
|
||||||
|
PointySouth, // S
|
||||||
|
PointyNorthEast, // W+D
|
||||||
|
PointySouthEast, // S+D
|
||||||
|
PointyNorthWest, // W+A
|
||||||
|
PointySouthWest, // S+A
|
||||||
|
|
||||||
|
// For Flat orientation
|
||||||
|
FlatWest, // A
|
||||||
|
FlatEast, // D
|
||||||
|
FlatNorthEast, // W+D
|
||||||
|
FlatSouthEast, // S+D
|
||||||
|
FlatNorthWest, // W+A
|
||||||
|
FlatSouthWest, // S+A
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LogicalDirection> for EdgeDirection {
|
||||||
|
fn from(value: LogicalDirection) -> Self {
|
||||||
|
let direction = match value {
|
||||||
|
// Pointy orientation mappings
|
||||||
|
LogicalDirection::PointyNorth => Self::POINTY_WEST,
|
||||||
|
LogicalDirection::PointySouth => Self::POINTY_EAST,
|
||||||
|
LogicalDirection::PointyNorthEast => Self::POINTY_SOUTH_WEST,
|
||||||
|
LogicalDirection::PointySouthEast => Self::POINTY_SOUTH_EAST,
|
||||||
|
LogicalDirection::PointyNorthWest => Self::POINTY_NORTH_WEST,
|
||||||
|
LogicalDirection::PointySouthWest => Self::POINTY_NORTH_EAST,
|
||||||
|
|
||||||
|
// Flat orientation mappings
|
||||||
|
LogicalDirection::FlatWest => Self::FLAT_NORTH,
|
||||||
|
LogicalDirection::FlatEast => Self::FLAT_SOUTH,
|
||||||
|
LogicalDirection::FlatNorthEast => Self::FLAT_SOUTH_WEST,
|
||||||
|
LogicalDirection::FlatSouthEast => Self::FLAT_SOUTH_EAST,
|
||||||
|
LogicalDirection::FlatNorthWest => Self::FLAT_NORTH_WEST,
|
||||||
|
LogicalDirection::FlatSouthWest => Self::FLAT_NORTH_EAST,
|
||||||
|
};
|
||||||
|
direction.rotate_cw(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Helper function to create a button input with specific key states
|
||||||
|
fn create_input(w: bool, a: bool, s: bool, d: bool) -> ButtonInput<KeyCode> {
|
||||||
|
let mut input = ButtonInput::default();
|
||||||
|
if w {
|
||||||
|
input.press(KeyCode::KeyW);
|
||||||
|
}
|
||||||
|
if a {
|
||||||
|
input.press(KeyCode::KeyA);
|
||||||
|
}
|
||||||
|
if s {
|
||||||
|
input.press(KeyCode::KeyS);
|
||||||
|
}
|
||||||
|
if d {
|
||||||
|
input.press(KeyCode::KeyD);
|
||||||
|
}
|
||||||
|
input
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_direction_single_keys() {
|
||||||
|
assert!(matches!(
|
||||||
|
KeyDirection::try_from(&create_input(true, false, false, false)),
|
||||||
|
Ok(KeyDirection::Up)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
KeyDirection::try_from(&create_input(false, true, false, false)),
|
||||||
|
Ok(KeyDirection::Left)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
KeyDirection::try_from(&create_input(false, false, true, false)),
|
||||||
|
Ok(KeyDirection::Down)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
KeyDirection::try_from(&create_input(false, false, false, true)),
|
||||||
|
Ok(KeyDirection::Right)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_direction_diagonal_combinations() {
|
||||||
|
assert!(matches!(
|
||||||
|
KeyDirection::try_from(&create_input(true, false, false, true)),
|
||||||
|
Ok(KeyDirection::UpRight)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
KeyDirection::try_from(&create_input(true, true, false, false)),
|
||||||
|
Ok(KeyDirection::UpLeft)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
KeyDirection::try_from(&create_input(false, false, true, true)),
|
||||||
|
Ok(KeyDirection::DownRight)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
KeyDirection::try_from(&create_input(false, true, true, false)),
|
||||||
|
Ok(KeyDirection::DownLeft)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_direction_invalid_combinations() {
|
||||||
|
assert!(KeyDirection::try_from(&create_input(true, true, true, false)).is_err());
|
||||||
|
assert!(KeyDirection::try_from(&create_input(true, true, false, true)).is_err());
|
||||||
|
assert!(KeyDirection::try_from(&create_input(true, true, true, true)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_direction_pointy() {
|
||||||
|
let orientation = HexOrientation::Pointy;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
KeyDirection::Up.exact_direction(&orientation),
|
||||||
|
Some(LogicalDirection::PointyNorth)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
KeyDirection::Down.exact_direction(&orientation),
|
||||||
|
Some(LogicalDirection::PointySouth)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
KeyDirection::UpRight.exact_direction(&orientation),
|
||||||
|
Some(LogicalDirection::PointyNorthEast)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_direction_flat() {
|
||||||
|
let orientation = HexOrientation::Flat;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
KeyDirection::Right.exact_direction(&orientation),
|
||||||
|
Some(LogicalDirection::FlatEast)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
KeyDirection::Left.exact_direction(&orientation),
|
||||||
|
Some(LogicalDirection::FlatWest)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
KeyDirection::UpRight.exact_direction(&orientation),
|
||||||
|
Some(LogicalDirection::FlatNorthEast)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn related_directions_pointy() {
|
||||||
|
let orientation = HexOrientation::Pointy;
|
||||||
|
|
||||||
|
let up_directions = KeyDirection::Up.related_directions(&orientation);
|
||||||
|
assert!(up_directions.contains(&LogicalDirection::PointyNorth));
|
||||||
|
assert!(up_directions.contains(&LogicalDirection::PointyNorthEast));
|
||||||
|
assert!(up_directions.contains(&LogicalDirection::PointyNorthWest));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn related_directions_flat() {
|
||||||
|
let orientation = HexOrientation::Flat;
|
||||||
|
|
||||||
|
let right_directions = KeyDirection::Right.related_directions(&orientation);
|
||||||
|
assert!(right_directions.contains(&LogicalDirection::FlatEast));
|
||||||
|
assert!(right_directions.contains(&LogicalDirection::FlatNorthEast));
|
||||||
|
assert!(right_directions.contains(&LogicalDirection::FlatSouthEast));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,36 @@
|
|||||||
|
pub mod despawn;
|
||||||
mod input;
|
mod input;
|
||||||
mod movement;
|
mod movement;
|
||||||
pub mod setup;
|
pub mod respawn;
|
||||||
|
mod sound_effect;
|
||||||
|
pub mod spawn;
|
||||||
|
mod toggle_pause;
|
||||||
mod vertical_transition;
|
mod vertical_transition;
|
||||||
|
|
||||||
use crate::maze::MazePluginLoaded;
|
use crate::{screens::Screen, AppSet};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use input::player_input;
|
use input::player_input;
|
||||||
use movement::player_movement;
|
use movement::player_movement;
|
||||||
|
use sound_effect::play_movement_sound;
|
||||||
|
use toggle_pause::toggle_player;
|
||||||
use vertical_transition::handle_floor_transition;
|
use vertical_transition::handle_floor_transition;
|
||||||
|
|
||||||
|
use super::assets::PlayerAssets;
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
pub(super) fn plugin(app: &mut App) {
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
player_input,
|
player_input.in_set(AppSet::RecordInput),
|
||||||
player_movement.after(player_input),
|
player_movement,
|
||||||
handle_floor_transition,
|
handle_floor_transition.in_set(AppSet::RecordInput),
|
||||||
|
(play_movement_sound)
|
||||||
|
.chain()
|
||||||
|
.run_if(resource_exists::<PlayerAssets>)
|
||||||
|
.in_set(AppSet::Update),
|
||||||
)
|
)
|
||||||
.run_if(resource_exists::<MazePluginLoaded>),
|
.chain()
|
||||||
|
.run_if(in_state(Screen::Gameplay)),
|
||||||
);
|
);
|
||||||
|
app.add_systems(Update, toggle_player.run_if(state_changed::<Screen>));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,22 @@
|
|||||||
|
//! Player movement system and related utilities.
|
||||||
|
//!
|
||||||
|
//! This module handles smooth player movement between hexagonal tiles,
|
||||||
|
//! including position interpolation and movement completion detection.
|
||||||
use crate::{
|
use crate::{
|
||||||
constants::MOVEMENT_THRESHOLD,
|
constants::MOVEMENT_THRESHOLD,
|
||||||
floor::components::CurrentFloor,
|
floor::components::CurrentFloor,
|
||||||
maze::components::MazeConfig,
|
maze::components::MazeConfig,
|
||||||
player::components::{CurrentPosition, MovementSpeed, MovementTarget, Player},
|
player::components::{CurrentPosition, MovementSpeed, MovementTarget, Player},
|
||||||
};
|
};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use hexx::Hex;
|
use hexx::Hex;
|
||||||
|
|
||||||
pub(super) fn player_movement(
|
/// System handles player movement between hexagonal tiles.
|
||||||
|
///
|
||||||
|
/// Smoothly interpolates player position between hexagonal tiles,
|
||||||
|
/// handling movement target acquisition, position updates, and movement completion.
|
||||||
|
pub fn player_movement(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut query: Query<
|
mut query: Query<
|
||||||
(
|
(
|
||||||
@ -48,10 +57,12 @@ pub(super) fn player_movement(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Determines if the movement should be completed based on proximity to target.
|
||||||
fn should_complete_movement(current_pos: Vec3, target_pos: Vec3) -> bool {
|
fn should_complete_movement(current_pos: Vec3, target_pos: Vec3) -> bool {
|
||||||
(target_pos - current_pos).length() < MOVEMENT_THRESHOLD
|
(target_pos - current_pos).length() < MOVEMENT_THRESHOLD
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the player's position based on movement parameters.
|
||||||
fn update_position(
|
fn update_position(
|
||||||
transform: &mut Transform,
|
transform: &mut Transform,
|
||||||
current_pos: Vec3,
|
current_pos: Vec3,
|
||||||
@ -69,6 +80,7 @@ fn update_position(
|
|||||||
transform.translation += movement;
|
transform.translation += movement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculates the world position for a given hex coordinate.
|
||||||
fn calculate_target_position(maze_config: &MazeConfig, target_hex: Hex, y: f32) -> Vec3 {
|
fn calculate_target_position(maze_config: &MazeConfig, target_hex: Hex, y: f32) -> Vec3 {
|
||||||
let world_pos = maze_config.layout.hex_to_world_pos(target_hex);
|
let world_pos = maze_config.layout.hex_to_world_pos(target_hex);
|
||||||
Vec3::new(world_pos.x, y, world_pos.y)
|
Vec3::new(world_pos.x, y, world_pos.y)
|
||||||
|
|||||||
7
src/player/systems/respawn.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
use crate::player::commands::{DespawnPlayer, SpawnPlayer};
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
pub fn respawn_player(mut commands: Commands) {
|
||||||
|
commands.queue(DespawnPlayer);
|
||||||
|
commands.queue(SpawnPlayer);
|
||||||
|
}
|
||||||
@ -1,6 +0,0 @@
|
|||||||
use crate::player::events::SpawnPlayer;
|
|
||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
pub fn setup(mut commands: Commands) {
|
|
||||||
commands.trigger(SpawnPlayer);
|
|
||||||
}
|
|
||||||
31
src/player/systems/sound_effect.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
use crate::{
|
||||||
|
audio::SoundEffect,
|
||||||
|
player::{
|
||||||
|
assets::PlayerAssets,
|
||||||
|
components::{MovementTarget, Player},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
|
|
||||||
|
pub fn play_movement_sound(
|
||||||
|
mut commands: Commands,
|
||||||
|
player_assets: Res<PlayerAssets>,
|
||||||
|
moving_players: Query<&MovementTarget, (Changed<MovementTarget>, With<Player>)>,
|
||||||
|
) {
|
||||||
|
for movement_target in moving_players.iter() {
|
||||||
|
if movement_target.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rng = &mut rand::thread_rng();
|
||||||
|
if let Some(random_step) = player_assets.steps.choose(rng) {
|
||||||
|
commands.spawn((
|
||||||
|
AudioPlayer(random_step.clone()),
|
||||||
|
PlaybackSettings::DESPAWN,
|
||||||
|
SoundEffect,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,13 +4,12 @@ use crate::{
|
|||||||
player::{
|
player::{
|
||||||
assets::{blue_material, generate_pill_mesh},
|
assets::{blue_material, generate_pill_mesh},
|
||||||
components::{CurrentPosition, Player},
|
components::{CurrentPosition, Player},
|
||||||
events::SpawnPlayer,
|
|
||||||
},
|
},
|
||||||
|
screens::GameplayElement,
|
||||||
};
|
};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
pub(super) fn spawn_player(
|
pub fn spawn_player(
|
||||||
_trigger: Trigger<SpawnPlayer>,
|
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut meshes: ResMut<Assets<Mesh>>,
|
mut meshes: ResMut<Assets<Mesh>>,
|
||||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||||
@ -34,5 +33,6 @@ pub(super) fn spawn_player(
|
|||||||
Mesh3d(meshes.add(generate_pill_mesh(player_radius, player_height / 2.))),
|
Mesh3d(meshes.add(generate_pill_mesh(player_radius, player_height / 2.))),
|
||||||
MeshMaterial3d(materials.add(blue_material())),
|
MeshMaterial3d(materials.add(blue_material())),
|
||||||
Transform::from_xyz(start_pos.x, y_offset, start_pos.y),
|
Transform::from_xyz(start_pos.x, y_offset, start_pos.y),
|
||||||
|
GameplayElement,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
13
src/player/systems/toggle_pause.rs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{player::components::Player, screens::Screen};
|
||||||
|
|
||||||
|
pub fn toggle_player(mut query: Query<&mut Visibility, With<Player>>, state: Res<State<Screen>>) {
|
||||||
|
for mut visibility in query.iter_mut() {
|
||||||
|
*visibility = match *state.get() {
|
||||||
|
Screen::Gameplay => Visibility::Visible,
|
||||||
|
Screen::Pause => Visibility::Hidden,
|
||||||
|
_ => *visibility,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,8 @@
|
|||||||
use bevy::prelude::*;
|
//! Floor transition handling system.
|
||||||
|
//!
|
||||||
|
//! This module manages player transitions between different maze floors,
|
||||||
|
//! handling both ascending and descending movements based on player position
|
||||||
|
//! and input.
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
floor::{
|
floor::{
|
||||||
@ -9,29 +13,38 @@ use crate::{
|
|||||||
player::components::{CurrentPosition, Player},
|
player::components::{CurrentPosition, Player},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
/// Handles floor transitions when a player reaches start/end positions.
|
||||||
|
///
|
||||||
|
/// System checks if the player is at a valid transition point (start or end position)
|
||||||
|
/// and triggers floor transitions when the appropriate input is received.
|
||||||
pub fn handle_floor_transition(
|
pub fn handle_floor_transition(
|
||||||
player_query: Query<&CurrentPosition, With<Player>>,
|
mut player_query: Query<&CurrentPosition, With<Player>>,
|
||||||
maze_query: Query<(&MazeConfig, &Floor), With<CurrentFloor>>,
|
maze_query: Query<(&MazeConfig, &Floor), With<CurrentFloor>>,
|
||||||
mut event_writer: EventWriter<TransitionFloor>,
|
mut event_writer: EventWriter<TransitionFloor>,
|
||||||
|
input: Res<ButtonInput<KeyCode>>,
|
||||||
) {
|
) {
|
||||||
|
if !input.just_pressed(KeyCode::KeyE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let Ok((config, floor)) = maze_query.get_single() else {
|
let Ok((config, floor)) = maze_query.get_single() else {
|
||||||
warn!("Failed to get maze configuration for current floor - cannot ascend/descend player.");
|
warn!("Failed to get maze configuration for current floor - cannot ascend/descend player.");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
for current_hex in player_query.iter() {
|
for current_hex in player_query.iter_mut() {
|
||||||
// Check for ascending
|
// Check for ascending (at end position)
|
||||||
if current_hex.0 == config.end_pos {
|
if current_hex.0 == config.end_pos {
|
||||||
dbg!("Ascending");
|
info!("Ascending");
|
||||||
event_writer.send(TransitionFloor::Ascend);
|
event_writer.send(TransitionFloor::Ascend);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for descending
|
// Check for descending (at start position, not on first floor)
|
||||||
if current_hex.0 == config.start_pos && floor.0 != 1 {
|
if current_hex.0 == config.start_pos && floor.0 != 1 {
|
||||||
dbg!("Descending");
|
info!("Descending");
|
||||||
event_writer.send(TransitionFloor::Descend);
|
event_writer.send(TransitionFloor::Descend);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
use crate::player::{components::Player, events::DespawnPlayer};
|
|
||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
pub(super) fn despawn_players(
|
|
||||||
_trigger: Trigger<DespawnPlayer>,
|
|
||||||
mut commands: Commands,
|
|
||||||
query: Query<Entity, With<Player>>,
|
|
||||||
) {
|
|
||||||
for entity in query.iter() {
|
|
||||||
commands.entity(entity).despawn_recursive();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
mod despawn;
|
|
||||||
mod respawn;
|
|
||||||
mod spawn;
|
|
||||||
|
|
||||||
use bevy::prelude::*;
|
|
||||||
use despawn::despawn_players;
|
|
||||||
use respawn::respawn_player;
|
|
||||||
use spawn::spawn_player;
|
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
|
||||||
app.add_observer(spawn_player)
|
|
||||||
.add_observer(respawn_player)
|
|
||||||
.add_observer(despawn_players);
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
use crate::player::events::{DespawnPlayer, RespawnPlayer, SpawnPlayer};
|
|
||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
pub(super) fn respawn_player(_trigger: Trigger<RespawnPlayer>, mut commands: Commands) {
|
|
||||||
commands.trigger(DespawnPlayer);
|
|
||||||
commands.trigger(SpawnPlayer);
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
//! A credits screen that can be accessed from the title screen.
|
|
||||||
|
|
||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
use crate::{asset_tracking::LoadResource, audio::Music, screens::Screen, theme::prelude::*};
|
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
|
||||||
app.add_systems(OnEnter(Screen::Credits), spawn_credits_screen);
|
|
||||||
|
|
||||||
app.load_resource::<CreditsMusic>();
|
|
||||||
app.add_systems(OnEnter(Screen::Credits), play_credits_music);
|
|
||||||
app.add_systems(OnExit(Screen::Credits), stop_music);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_credits_screen(mut commands: Commands) {
|
|
||||||
commands
|
|
||||||
.ui_root()
|
|
||||||
.insert(StateScoped(Screen::Credits))
|
|
||||||
.with_children(|children| {
|
|
||||||
children.header("Made by");
|
|
||||||
children.label("Joe Shmoe - Implemented aligator wrestling AI");
|
|
||||||
children.label("Jane Doe - Made the music for the alien invasion");
|
|
||||||
|
|
||||||
children.header("Assets");
|
|
||||||
children.label("Bevy logo - All rights reserved by the Bevy Foundation. Permission granted for splash screen use when unmodified.");
|
|
||||||
children.label("Ducky sprite - CC0 by Caz Creates Games");
|
|
||||||
children.label("Button SFX - CC0 by Jaszunio15");
|
|
||||||
children.label("Music - CC BY 3.0 by Kevin MacLeod");
|
|
||||||
|
|
||||||
children.button("Back").observe(enter_title_screen);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enter_title_screen(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
|
|
||||||
next_screen.set(Screen::Title);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Resource, Asset, Reflect, Clone)]
|
|
||||||
pub struct CreditsMusic {
|
|
||||||
#[dependency]
|
|
||||||
music: Handle<AudioSource>,
|
|
||||||
entity: Option<Entity>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromWorld for CreditsMusic {
|
|
||||||
fn from_world(world: &mut World) -> Self {
|
|
||||||
let assets = world.resource::<AssetServer>();
|
|
||||||
Self {
|
|
||||||
music: assets.load("audio/music/Monkeys Spinning Monkeys.ogg"),
|
|
||||||
entity: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn play_credits_music(mut commands: Commands, mut music: ResMut<CreditsMusic>) {
|
|
||||||
music.entity = Some(
|
|
||||||
commands
|
|
||||||
.spawn((
|
|
||||||
AudioPlayer::<AudioSource>(music.music.clone()),
|
|
||||||
PlaybackSettings::LOOP,
|
|
||||||
Music,
|
|
||||||
))
|
|
||||||
.id(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stop_music(mut commands: Commands, mut music: ResMut<CreditsMusic>) {
|
|
||||||
if let Some(entity) = music.entity.take() {
|
|
||||||
commands.entity(entity).despawn_recursive();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +1,62 @@
|
|||||||
//! The screen state for the main gameplay.
|
//! The screen state for the main gameplay.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
hint::spawn_hint_command, maze::spawn_level_command, player::spawn_player_command,
|
||||||
|
screens::Screen, stats::spawn_stats_command,
|
||||||
|
};
|
||||||
|
|
||||||
use bevy::{input::common_conditions::input_just_pressed, prelude::*};
|
use bevy::{input::common_conditions::input_just_pressed, prelude::*};
|
||||||
|
|
||||||
use crate::maze::spawn_level_command;
|
|
||||||
use crate::player::spawn_player_command;
|
|
||||||
use crate::{asset_tracking::LoadResource, audio::Music, screens::Screen};
|
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.init_resource::<GameplayInitialized>();
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
OnEnter(Screen::Gameplay),
|
OnEnter(Screen::Gameplay),
|
||||||
(spawn_level_command, spawn_player_command).chain(),
|
(
|
||||||
|
spawn_level_command,
|
||||||
|
spawn_player_command,
|
||||||
|
spawn_hint_command,
|
||||||
|
spawn_stats_command,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.run_if(not(resource_exists::<GameplayInitialized>)),
|
||||||
);
|
);
|
||||||
|
app.add_systems(OnEnter(Screen::Gameplay), |mut commands: Commands| {
|
||||||
|
commands.insert_resource(GameplayInitialized(true));
|
||||||
|
});
|
||||||
|
app.add_systems(Update, cleanup_game.run_if(state_changed::<Screen>));
|
||||||
|
|
||||||
app.load_resource::<GameplayMusic>();
|
app.add_systems(OnEnter(Screen::Title), reset_gameplay_state);
|
||||||
app.add_systems(OnEnter(Screen::Gameplay), play_gameplay_music);
|
|
||||||
app.add_systems(OnExit(Screen::Gameplay), stop_music);
|
|
||||||
|
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
Update,
|
||||||
return_to_title_screen
|
pause_game.run_if(in_state(Screen::Gameplay).and(input_just_pressed(KeyCode::Escape))),
|
||||||
.run_if(in_state(Screen::Gameplay).and(input_just_pressed(KeyCode::Escape))),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Resource, Asset, Reflect, Clone)]
|
fn pause_game(mut next_screen: ResMut<NextState<Screen>>) {
|
||||||
pub struct GameplayMusic {
|
next_screen.set(Screen::Pause);
|
||||||
#[dependency]
|
|
||||||
handle: Handle<AudioSource>,
|
|
||||||
entity: Option<Entity>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromWorld for GameplayMusic {
|
fn reset_gameplay_state(mut commands: Commands) {
|
||||||
fn from_world(world: &mut World) -> Self {
|
commands.remove_resource::<GameplayInitialized>();
|
||||||
let assets = world.resource::<AssetServer>();
|
|
||||||
Self {
|
|
||||||
handle: assets.load("audio/music/Fluffing A Duck.ogg"),
|
|
||||||
entity: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn play_gameplay_music(mut commands: Commands, mut music: ResMut<GameplayMusic>) {
|
#[derive(Debug, Default, Reflect, Resource, DerefMut, Deref)]
|
||||||
music.entity = Some(
|
#[reflect(Resource)]
|
||||||
commands
|
pub struct GameplayInitialized(bool);
|
||||||
.spawn((
|
|
||||||
AudioPlayer::<AudioSource>(music.handle.clone()),
|
|
||||||
PlaybackSettings::LOOP,
|
|
||||||
Music,
|
|
||||||
))
|
|
||||||
.id(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stop_music(mut commands: Commands, mut music: ResMut<GameplayMusic>) {
|
#[derive(Debug, Reflect, Component)]
|
||||||
if let Some(entity) = music.entity.take() {
|
#[reflect(Component)]
|
||||||
|
pub struct GameplayElement;
|
||||||
|
|
||||||
|
fn cleanup_game(
|
||||||
|
mut commands: Commands,
|
||||||
|
query: Query<Entity, With<GameplayElement>>,
|
||||||
|
state: Res<State<Screen>>,
|
||||||
|
) {
|
||||||
|
if !matches!(*state.get(), Screen::Gameplay | Screen::Pause) {
|
||||||
|
for entity in query.iter() {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn_recursive();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn return_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
|
|
||||||
next_screen.set(Screen::Title);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,13 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
screens::{credits::CreditsMusic, gameplay::GameplayMusic, Screen},
|
hint::assets::HintAssets,
|
||||||
theme::{interaction::InteractionAssets, prelude::*},
|
player::assets::PlayerAssets,
|
||||||
|
screens::Screen,
|
||||||
|
theme::{assets::InteractionAssets, prelude::*},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
pub fn plugin(app: &mut App) {
|
||||||
app.add_systems(OnEnter(Screen::Loading), spawn_loading_screen);
|
app.add_systems(OnEnter(Screen::Loading), spawn_loading_screen);
|
||||||
|
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
@ -21,8 +23,8 @@ fn spawn_loading_screen(mut commands: Commands) {
|
|||||||
commands
|
commands
|
||||||
.ui_root()
|
.ui_root()
|
||||||
.insert(StateScoped(Screen::Loading))
|
.insert(StateScoped(Screen::Loading))
|
||||||
.with_children(|children| {
|
.with_children(|parent| {
|
||||||
children.label("Loading...").insert(Node {
|
parent.label("Loading...").insert(Node {
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
..default()
|
..default()
|
||||||
});
|
});
|
||||||
@ -34,9 +36,9 @@ fn continue_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fn all_assets_loaded(
|
const fn all_assets_loaded(
|
||||||
|
player_assets: Option<Res<PlayerAssets>>,
|
||||||
interaction_assets: Option<Res<InteractionAssets>>,
|
interaction_assets: Option<Res<InteractionAssets>>,
|
||||||
credits_music: Option<Res<CreditsMusic>>,
|
hints_assets: Option<Res<HintAssets>>,
|
||||||
gameplay_music: Option<Res<GameplayMusic>>,
|
|
||||||
) -> bool {
|
) -> bool {
|
||||||
interaction_assets.is_some() && credits_music.is_some() && gameplay_music.is_some()
|
player_assets.is_some() && interaction_assets.is_some() && hints_assets.is_some()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,24 @@
|
|||||||
//! The game's main screen states and transitions between them.
|
//! The game's main screen states and transitions between them.
|
||||||
|
|
||||||
mod credits;
|
|
||||||
mod gameplay;
|
mod gameplay;
|
||||||
mod loading;
|
mod loading;
|
||||||
|
mod pause;
|
||||||
mod splash;
|
mod splash;
|
||||||
mod title;
|
mod title;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
pub use gameplay::{GameplayElement, GameplayInitialized};
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
pub(super) fn plugin(app: &mut App) {
|
||||||
app.init_state::<Screen>();
|
app.init_state::<Screen>();
|
||||||
app.enable_state_scoped_entities::<Screen>();
|
app.enable_state_scoped_entities::<Screen>();
|
||||||
|
|
||||||
app.add_plugins((
|
app.add_plugins((
|
||||||
credits::plugin,
|
|
||||||
gameplay::plugin,
|
gameplay::plugin,
|
||||||
loading::plugin,
|
loading::plugin,
|
||||||
splash::plugin,
|
splash::plugin,
|
||||||
title::plugin,
|
title::plugin,
|
||||||
|
pause::plugin,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,6 +30,6 @@ pub enum Screen {
|
|||||||
#[cfg_attr(feature = "dev", default)]
|
#[cfg_attr(feature = "dev", default)]
|
||||||
Loading,
|
Loading,
|
||||||
Title,
|
Title,
|
||||||
Credits,
|
|
||||||
Gameplay,
|
Gameplay,
|
||||||
|
Pause,
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/screens/pause.rs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
use bevy::{input::common_conditions::input_just_pressed, prelude::*};
|
||||||
|
|
||||||
|
use crate::theme::{
|
||||||
|
events::OnPress,
|
||||||
|
palette::rose_pine::RosePineDawn,
|
||||||
|
prelude::ColorScheme,
|
||||||
|
widgets::{Containers, Widgets},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::Screen;
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.add_systems(OnEnter(Screen::Pause), spawn_pause_overlay);
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
return_to_game.run_if(in_state(Screen::Pause).and(input_just_pressed(KeyCode::Escape))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_pause_overlay(mut commands: Commands) {
|
||||||
|
commands
|
||||||
|
.ui_root()
|
||||||
|
.insert((
|
||||||
|
StateScoped(Screen::Pause),
|
||||||
|
BackgroundColor(RosePineDawn::Muted.to_color().with_alpha(0.5)),
|
||||||
|
))
|
||||||
|
.with_children(|parent| {
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
bottom: Val::Px(100.),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|parent| {
|
||||||
|
parent.header("Paused");
|
||||||
|
});
|
||||||
|
|
||||||
|
parent.button("Continue").observe(return_to_game_trigger);
|
||||||
|
parent
|
||||||
|
.button("Exit")
|
||||||
|
.observe(return_to_title_screen_trigger);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn return_to_game_trigger(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
|
||||||
|
next_screen.set(Screen::Gameplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn return_to_title_screen_trigger(
|
||||||
|
_trigger: Trigger<OnPress>,
|
||||||
|
mut next_screen: ResMut<NextState<Screen>>,
|
||||||
|
) {
|
||||||
|
next_screen.set(Screen::Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn return_to_game(mut next_screen: ResMut<NextState<Screen>>) {
|
||||||
|
next_screen.set(Screen::Gameplay);
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@ use bevy::{
|
|||||||
|
|
||||||
use crate::{screens::Screen, theme::prelude::*, AppSet};
|
use crate::{screens::Screen, theme::prelude::*, AppSet};
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
pub fn plugin(app: &mut App) {
|
||||||
// Spawn splash screen.
|
// Spawn splash screen.
|
||||||
app.insert_resource(ClearColor(SPLASH_BACKGROUND_COLOR));
|
app.insert_resource(ClearColor(SPLASH_BACKGROUND_COLOR));
|
||||||
app.add_systems(OnEnter(Screen::Splash), spawn_splash_screen);
|
app.add_systems(OnEnter(Screen::Splash), spawn_splash_screen);
|
||||||
|
|||||||
@ -12,12 +12,19 @@ fn spawn_title_screen(mut commands: Commands) {
|
|||||||
commands
|
commands
|
||||||
.ui_root()
|
.ui_root()
|
||||||
.insert(StateScoped(Screen::Title))
|
.insert(StateScoped(Screen::Title))
|
||||||
.with_children(|children| {
|
.with_children(|parent| {
|
||||||
children.button("Play").observe(enter_gameplay_screen);
|
parent
|
||||||
children.button("Credits").observe(enter_credits_screen);
|
.spawn(Node {
|
||||||
|
bottom: Val::Px(70.),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|parent| {
|
||||||
|
parent.header("Maze Ascension");
|
||||||
|
});
|
||||||
|
parent.button("Play").observe(enter_gameplay_screen);
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
children.button("Exit").observe(exit_app);
|
parent.button("Quit").observe(exit_app);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,10 +32,6 @@ fn enter_gameplay_screen(_trigger: Trigger<OnPress>, mut next_screen: ResMut<Nex
|
|||||||
next_screen.set(Screen::Gameplay);
|
next_screen.set(Screen::Gameplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enter_credits_screen(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
|
|
||||||
next_screen.set(Screen::Credits);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn exit_app(_trigger: Trigger<OnPress>, mut app_exit: EventWriter<AppExit>) {
|
fn exit_app(_trigger: Trigger<OnPress>, mut app_exit: EventWriter<AppExit>) {
|
||||||
app_exit.send(AppExit::Success);
|
app_exit.send(AppExit::Success);
|
||||||
|
|||||||
21
src/stats/components.rs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct FloorDisplay;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct HighestFloorDisplay;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct ScoreDisplay;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct FloorTimerDisplay;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct TotalTimerDisplay;
|
||||||
22
src/stats/container.rs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
pub trait StatsContainer {
|
||||||
|
fn ui_stats(&mut self) -> EntityCommands;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatsContainer for Commands<'_, '_> {
|
||||||
|
fn ui_stats(&mut self) -> EntityCommands {
|
||||||
|
self.spawn((
|
||||||
|
Name::new("Stats Root"),
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
top: Val::Px(10.),
|
||||||
|
right: Val::Px(10.),
|
||||||
|
row_gap: Val::Px(8.),
|
||||||
|
align_items: AlignItems::End,
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/stats/mod.rs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
pub mod components;
|
||||||
|
pub mod container;
|
||||||
|
pub mod resources;
|
||||||
|
mod systems;
|
||||||
|
|
||||||
|
use bevy::{ecs::system::RunSystemOnce, prelude::*};
|
||||||
|
use resources::{FloorTimer, Score, TotalTimer};
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.init_resource::<Score>()
|
||||||
|
.init_resource::<TotalTimer>()
|
||||||
|
.init_resource::<FloorTimer>()
|
||||||
|
.add_plugins(systems::plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_stats_command(world: &mut World) {
|
||||||
|
let _ = world.run_system_once(systems::spawn::spawn_stats);
|
||||||
|
}
|
||||||
31
src/stats/resources.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Reflect, Resource, Deref, DerefMut)]
|
||||||
|
#[reflect(Resource)]
|
||||||
|
pub struct Score(pub usize);
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect, Resource, Deref, DerefMut)]
|
||||||
|
#[reflect(Resource)]
|
||||||
|
pub struct TotalTimer(pub Timer);
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect, Resource, Deref, DerefMut)]
|
||||||
|
#[reflect(Resource)]
|
||||||
|
pub struct FloorTimer(pub Timer);
|
||||||
|
|
||||||
|
impl Default for TotalTimer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(init_timer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FloorTimer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(init_timer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_timer() -> Timer {
|
||||||
|
Timer::new(Duration::MAX, TimerMode::Once)
|
||||||
|
}
|
||||||
28
src/stats/systems/common.rs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
pub fn format_duration_adaptive(seconds: f32) -> String {
|
||||||
|
let total_millis = (seconds * 1000.0) as u64;
|
||||||
|
let millis = total_millis % 1000;
|
||||||
|
let total_seconds = total_millis / 1000;
|
||||||
|
let seconds = total_seconds % 60;
|
||||||
|
let total_minutes = total_seconds / 60;
|
||||||
|
let minutes = total_minutes % 60;
|
||||||
|
let total_hours = total_minutes / 60;
|
||||||
|
let hours = total_hours % 24;
|
||||||
|
let days = total_hours / 24;
|
||||||
|
|
||||||
|
let mut result = String::new();
|
||||||
|
|
||||||
|
if days > 0 {
|
||||||
|
result.push_str(&format!("{}d ", days));
|
||||||
|
}
|
||||||
|
if hours > 0 || days > 0 {
|
||||||
|
result.push_str(&format!("{:02}:", hours));
|
||||||
|
}
|
||||||
|
if minutes > 0 || hours > 0 || days > 0 {
|
||||||
|
result.push_str(&format!("{:02}:", minutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show at least seconds and milliseconds
|
||||||
|
result.push_str(&format!("{:02}.{:03}", seconds, millis));
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
39
src/stats/systems/floor.rs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
floor::{
|
||||||
|
components::{CurrentFloor, Floor},
|
||||||
|
resources::HighestFloor,
|
||||||
|
},
|
||||||
|
stats::components::{FloorDisplay, HighestFloorDisplay},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn update_floor_display(
|
||||||
|
floor_query: Query<&Floor, With<CurrentFloor>>,
|
||||||
|
mut text_query: Query<&mut Text, With<FloorDisplay>>,
|
||||||
|
) {
|
||||||
|
let Ok(floor) = floor_query.get_single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(mut text) = text_query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
text.0 = format!("Floor: {}", floor.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_highest_floor_display(
|
||||||
|
hightes_floor: Res<HighestFloor>,
|
||||||
|
mut text_query: Query<&mut Text, With<HighestFloorDisplay>>,
|
||||||
|
) {
|
||||||
|
if !hightes_floor.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(mut text) = text_query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
text.0 = format!("Highest Floor: {}", hightes_floor.0);
|
||||||
|
}
|
||||||
33
src/stats/systems/floor_timer.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
floor::resources::HighestFloor,
|
||||||
|
stats::{components::FloorTimerDisplay, resources::FloorTimer},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::common::format_duration_adaptive;
|
||||||
|
|
||||||
|
pub fn update_floor_timer(
|
||||||
|
mut floor_timer: ResMut<FloorTimer>,
|
||||||
|
time: Res<Time>,
|
||||||
|
hightes_floor: Res<HighestFloor>,
|
||||||
|
) {
|
||||||
|
floor_timer.tick(time.delta());
|
||||||
|
if hightes_floor.is_changed() {
|
||||||
|
floor_timer.0.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_floor_timer_display(
|
||||||
|
mut text_query: Query<&mut Text, With<FloorTimerDisplay>>,
|
||||||
|
floor_timer: Res<FloorTimer>,
|
||||||
|
) {
|
||||||
|
let Ok(mut text) = text_query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
text.0 = format!(
|
||||||
|
"Floor Timer: {}",
|
||||||
|
format_duration_adaptive(floor_timer.0.elapsed_secs())
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/stats/systems/mod.rs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
mod common;
|
||||||
|
mod floor;
|
||||||
|
mod floor_timer;
|
||||||
|
mod reset;
|
||||||
|
mod score;
|
||||||
|
pub mod spawn;
|
||||||
|
mod total_timer;
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use floor::{update_floor_display, update_highest_floor_display};
|
||||||
|
use floor_timer::{update_floor_timer, update_floor_timer_display};
|
||||||
|
use reset::reset_timers;
|
||||||
|
use score::{update_score, update_score_display};
|
||||||
|
use total_timer::{update_total_timer, update_total_timer_display};
|
||||||
|
|
||||||
|
use crate::screens::{GameplayInitialized, Screen};
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.add_systems(
|
||||||
|
OnEnter(Screen::Gameplay),
|
||||||
|
reset_timers.run_if(not(resource_exists::<GameplayInitialized>)),
|
||||||
|
);
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
(
|
||||||
|
update_score.before(update_floor_timer),
|
||||||
|
update_score_display,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
(update_floor_timer, update_floor_timer_display).chain(),
|
||||||
|
(update_total_timer, update_total_timer_display).chain(),
|
||||||
|
update_floor_display,
|
||||||
|
update_highest_floor_display,
|
||||||
|
)
|
||||||
|
.run_if(in_state(Screen::Gameplay)),
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/stats/systems/reset.rs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::stats::resources::{FloorTimer, Score, TotalTimer};
|
||||||
|
|
||||||
|
pub fn reset_timers(
|
||||||
|
mut floor_timer: ResMut<FloorTimer>,
|
||||||
|
mut total_timer: ResMut<TotalTimer>,
|
||||||
|
mut score: ResMut<Score>,
|
||||||
|
) {
|
||||||
|
floor_timer.reset();
|
||||||
|
total_timer.reset();
|
||||||
|
score.0 = 0;
|
||||||
|
}
|
||||||
188
src/stats/systems/score.rs
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
constants::{
|
||||||
|
BASE_FLOOR_SCORE, BASE_PERFECT_TIME, FLOOR_PROGRESSION_MULTIPLIER, MIN_TIME_MULTIPLIER,
|
||||||
|
TIME_BONUS_MULTIPLIER, TIME_INCREASE_FACTOR,
|
||||||
|
},
|
||||||
|
floor::resources::HighestFloor,
|
||||||
|
stats::{
|
||||||
|
components::ScoreDisplay,
|
||||||
|
resources::{FloorTimer, Score},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn update_score(
|
||||||
|
mut score: ResMut<Score>,
|
||||||
|
hightes_floor: Res<HighestFloor>,
|
||||||
|
floor_timer: Res<FloorTimer>,
|
||||||
|
) {
|
||||||
|
if !hightes_floor.is_changed() || hightes_floor.is_added() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
score.0 += calculate_score(
|
||||||
|
hightes_floor.0.saturating_sub(1),
|
||||||
|
floor_timer.elapsed_secs(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_score_display(
|
||||||
|
mut text_query: Query<&mut Text, With<ScoreDisplay>>,
|
||||||
|
score: Res<Score>,
|
||||||
|
) {
|
||||||
|
let Ok(mut text) = text_query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
text.0 = format!("Score: {}", score.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_score(floor_number: u8, completion_time: f32) -> usize {
|
||||||
|
let perfect_time = calculate_perfect_time(floor_number);
|
||||||
|
|
||||||
|
// Floor progression using exponential scaling for better high-floor rewards
|
||||||
|
let floor_multiplier = (1.0 + floor_number as f32).powf(FLOOR_PROGRESSION_MULTIPLIER);
|
||||||
|
let base_score = BASE_FLOOR_SCORE as f32 * floor_multiplier;
|
||||||
|
|
||||||
|
// Time bonus calculation
|
||||||
|
// Perfect time or better gets maximum bonus
|
||||||
|
// Longer times get diminishing returns but never below minimum
|
||||||
|
let time_multiplier = if completion_time <= perfect_time {
|
||||||
|
// Bonus for being faster than perfect time
|
||||||
|
let speed_ratio = perfect_time / completion_time;
|
||||||
|
speed_ratio * TIME_BONUS_MULTIPLIER
|
||||||
|
} else {
|
||||||
|
// Penalty for being slower than perfect time, with smooth degradation
|
||||||
|
let overtime_ratio = completion_time / perfect_time;
|
||||||
|
let time_factor = 1.0 / overtime_ratio;
|
||||||
|
time_factor.max(MIN_TIME_MULTIPLIER) * TIME_BONUS_MULTIPLIER
|
||||||
|
};
|
||||||
|
|
||||||
|
(base_score * time_multiplier) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perfect time increases with floor number
|
||||||
|
fn calculate_perfect_time(floor_number: u8) -> f32 {
|
||||||
|
BASE_PERFECT_TIME * (floor_number as f32 - 1.).mul_add(TIME_INCREASE_FACTOR, 1.)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use claims::*;
|
||||||
|
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
|
||||||
|
use rstest::*;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
fn floors() -> Vec<u8> {
|
||||||
|
(1..=100).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
fn times() -> Vec<f32> {
|
||||||
|
vec![
|
||||||
|
BASE_PERFECT_TIME * 0.5, // Much faster than perfect
|
||||||
|
BASE_PERFECT_TIME * 0.8, // Faster than perfect
|
||||||
|
BASE_PERFECT_TIME, // Perfect time
|
||||||
|
BASE_PERFECT_TIME * 1.5, // Slower than perfect
|
||||||
|
BASE_PERFECT_TIME * 2.0, // Much slower
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(1, BASE_PERFECT_TIME)]
|
||||||
|
#[case(2, BASE_PERFECT_TIME * (1.0 + TIME_INCREASE_FACTOR))]
|
||||||
|
#[case(5, BASE_PERFECT_TIME * 4.0f32.mul_add(TIME_INCREASE_FACTOR, 1.))]
|
||||||
|
fn specific_perfect_times(#[case] floor: u8, #[case] expected_time: f32) {
|
||||||
|
let calculated_time = calculate_perfect_time(floor);
|
||||||
|
assert_le!(
|
||||||
|
(calculated_time - expected_time).abs(),
|
||||||
|
0.001,
|
||||||
|
"Perfect time calculation mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn score_progression(floors: Vec<u8>, times: Vec<f32>) {
|
||||||
|
let floor_scores = floors
|
||||||
|
.par_iter()
|
||||||
|
.map(|floor| {
|
||||||
|
let scores = times
|
||||||
|
.par_iter()
|
||||||
|
.map(|&time| (*floor, time, calculate_score(*floor, time)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
(*floor, scores)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for (floor, scores) in floor_scores {
|
||||||
|
scores.windows(2).for_each(|window| {
|
||||||
|
let (_, time1, score1) = window[0];
|
||||||
|
let (_, time2, score2) = window[1];
|
||||||
|
|
||||||
|
if time1 < time2 {
|
||||||
|
assert_gt!(
|
||||||
|
score1,
|
||||||
|
score2,
|
||||||
|
"Floor {}: Faster time ({}) should give higher score than slower time ({})",
|
||||||
|
floor,
|
||||||
|
time1,
|
||||||
|
time2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn perfect_time_progression(floors: Vec<u8>) {
|
||||||
|
let perfect_scores = floors
|
||||||
|
.par_iter()
|
||||||
|
.map(|&floor| {
|
||||||
|
let perfect_time = calculate_perfect_time(floor);
|
||||||
|
(floor, calculate_score(floor, perfect_time))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
perfect_scores.windows(2).for_each(|window| {
|
||||||
|
let (floor1, score1) = window[0];
|
||||||
|
let (floor2, score2) = window[1];
|
||||||
|
assert_gt!(
|
||||||
|
score2,
|
||||||
|
score1,
|
||||||
|
"Floor {} perfect score ({}) should be higher than floor {} perfect score ({})",
|
||||||
|
floor2,
|
||||||
|
score2,
|
||||||
|
floor1,
|
||||||
|
score1
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn minimum_score_guarantee(floors: Vec<u8>) {
|
||||||
|
let very_slow_time = BASE_PERFECT_TIME * 10.0;
|
||||||
|
|
||||||
|
// Test minimum scores in parallel
|
||||||
|
let min_scores = floors
|
||||||
|
.par_iter()
|
||||||
|
.map(|&floor| calculate_score(floor, very_slow_time))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// Verify minimum scores
|
||||||
|
min_scores.windows(2).for_each(|window| {
|
||||||
|
assert_gt!(
|
||||||
|
window[1],
|
||||||
|
window[0],
|
||||||
|
"Higher floor should give better minimum score"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify all scores are above zero
|
||||||
|
min_scores.iter().for_each(|&score| {
|
||||||
|
assert_gt!(score, 0, "Score should never be zero");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/stats/systems/spawn.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
screens::GameplayElement,
|
||||||
|
stats::{
|
||||||
|
components::{
|
||||||
|
FloorDisplay, FloorTimerDisplay, HighestFloorDisplay, ScoreDisplay, TotalTimerDisplay,
|
||||||
|
},
|
||||||
|
container::StatsContainer,
|
||||||
|
},
|
||||||
|
theme::widgets::Widgets,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn spawn_stats(mut commands: Commands) {
|
||||||
|
commands
|
||||||
|
.ui_stats()
|
||||||
|
.insert(GameplayElement)
|
||||||
|
.with_children(|parent| {
|
||||||
|
parent.stats("Floor: 1", FloorDisplay);
|
||||||
|
parent.stats("Highest Floor: 1", HighestFloorDisplay);
|
||||||
|
parent.stats("Score: 0", ScoreDisplay);
|
||||||
|
parent.stats("Floor Timer", FloorTimerDisplay);
|
||||||
|
parent.stats("Total Timer", TotalTimerDisplay);
|
||||||
|
});
|
||||||
|
}
|
||||||
23
src/stats/systems/total_timer.rs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::stats::{components::TotalTimerDisplay, resources::TotalTimer};
|
||||||
|
|
||||||
|
use super::common::format_duration_adaptive;
|
||||||
|
|
||||||
|
pub fn update_total_timer(mut total_timer: ResMut<TotalTimer>, time: Res<Time>) {
|
||||||
|
total_timer.tick(time.delta());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_total_timer_display(
|
||||||
|
mut text_query: Query<&mut Text, With<TotalTimerDisplay>>,
|
||||||
|
total_timer: Res<TotalTimer>,
|
||||||
|
) {
|
||||||
|
let Ok(mut text) = text_query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
text.0 = format!(
|
||||||
|
"Total Timer: {}",
|
||||||
|
format_duration_adaptive(total_timer.0.elapsed_secs())
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/theme/assets.rs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Resource, Asset, Reflect, Clone)]
|
||||||
|
pub struct InteractionAssets {
|
||||||
|
#[dependency]
|
||||||
|
pub(super) hover: Handle<AudioSource>,
|
||||||
|
#[dependency]
|
||||||
|
pub(super) press: Handle<AudioSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InteractionAssets {
|
||||||
|
pub const PATH_BUTTON_HOVER: &'static str = "audio/sound_effects/button_hover.ogg";
|
||||||
|
pub const PATH_BUTTON_PRESS: &'static str = "audio/sound_effects/button_press.ogg";
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromWorld for InteractionAssets {
|
||||||
|
fn from_world(world: &mut World) -> Self {
|
||||||
|
let assets = world.resource::<AssetServer>();
|
||||||
|
Self {
|
||||||
|
hover: assets.load(Self::PATH_BUTTON_HOVER),
|
||||||
|
press: assets.load(Self::PATH_BUTTON_PRESS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/theme/components.rs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
/// Palette for widget interactions. Add this to an entity that supports
|
||||||
|
/// [`Interaction`]s, such as a button, to change its [`BackgroundColor`] based
|
||||||
|
/// on the current interaction state.
|
||||||
|
#[derive(Component, Debug, Reflect)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct InteractionPalette {
|
||||||
|
pub none: Color,
|
||||||
|
pub hovered: Color,
|
||||||
|
pub pressed: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct UrlLink(pub String);
|
||||||
6
src/theme/events.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
/// Event triggered on a UI entity when the [`Interaction`] component on the same entity changes to
|
||||||
|
/// [`Interaction::Pressed`]. Observe this event to detect e.g. button presses.
|
||||||
|
#[derive(Event)]
|
||||||
|
pub struct OnPress;
|
||||||
@ -1,102 +0,0 @@
|
|||||||
use bevy::prelude::*;
|
|
||||||
|
|
||||||
use crate::{asset_tracking::LoadResource, audio::SoundEffect};
|
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
|
||||||
app.register_type::<InteractionPalette>();
|
|
||||||
app.load_resource::<InteractionAssets>();
|
|
||||||
app.add_systems(
|
|
||||||
Update,
|
|
||||||
(
|
|
||||||
trigger_on_press,
|
|
||||||
apply_interaction_palette,
|
|
||||||
trigger_interaction_sound_effect,
|
|
||||||
)
|
|
||||||
.run_if(resource_exists::<InteractionAssets>),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Palette for widget interactions. Add this to an entity that supports
|
|
||||||
/// [`Interaction`]s, such as a button, to change its [`BackgroundColor`] based
|
|
||||||
/// on the current interaction state.
|
|
||||||
#[derive(Component, Debug, Reflect)]
|
|
||||||
#[reflect(Component)]
|
|
||||||
pub struct InteractionPalette {
|
|
||||||
pub none: Color,
|
|
||||||
pub hovered: Color,
|
|
||||||
pub pressed: Color,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Event triggered on a UI entity when the [`Interaction`] component on the same entity changes to
|
|
||||||
/// [`Interaction::Pressed`]. Observe this event to detect e.g. button presses.
|
|
||||||
#[derive(Event)]
|
|
||||||
pub struct OnPress;
|
|
||||||
|
|
||||||
fn trigger_on_press(
|
|
||||||
interaction_query: Query<(Entity, &Interaction), Changed<Interaction>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
for (entity, interaction) in &interaction_query {
|
|
||||||
if matches!(interaction, Interaction::Pressed) {
|
|
||||||
commands.trigger_targets(OnPress, entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_interaction_palette(
|
|
||||||
mut palette_query: Query<
|
|
||||||
(&Interaction, &InteractionPalette, &mut BackgroundColor),
|
|
||||||
Changed<Interaction>,
|
|
||||||
>,
|
|
||||||
) {
|
|
||||||
for (interaction, palette, mut background) in &mut palette_query {
|
|
||||||
*background = match interaction {
|
|
||||||
Interaction::None => palette.none,
|
|
||||||
Interaction::Hovered => palette.hovered,
|
|
||||||
Interaction::Pressed => palette.pressed,
|
|
||||||
}
|
|
||||||
.into();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Resource, Asset, Reflect, Clone)]
|
|
||||||
pub struct InteractionAssets {
|
|
||||||
#[dependency]
|
|
||||||
hover: Handle<AudioSource>,
|
|
||||||
#[dependency]
|
|
||||||
press: Handle<AudioSource>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InteractionAssets {
|
|
||||||
pub const PATH_BUTTON_HOVER: &'static str = "audio/sound_effects/button_hover.ogg";
|
|
||||||
pub const PATH_BUTTON_PRESS: &'static str = "audio/sound_effects/button_press.ogg";
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromWorld for InteractionAssets {
|
|
||||||
fn from_world(world: &mut World) -> Self {
|
|
||||||
let assets = world.resource::<AssetServer>();
|
|
||||||
Self {
|
|
||||||
hover: assets.load(Self::PATH_BUTTON_HOVER),
|
|
||||||
press: assets.load(Self::PATH_BUTTON_PRESS),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn trigger_interaction_sound_effect(
|
|
||||||
interaction_query: Query<&Interaction, Changed<Interaction>>,
|
|
||||||
interaction_assets: Res<InteractionAssets>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
for interaction in &interaction_query {
|
|
||||||
let source = match interaction {
|
|
||||||
Interaction::Hovered => interaction_assets.hover.clone(),
|
|
||||||
Interaction::Pressed => interaction_assets.press.clone(),
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
commands.spawn((
|
|
||||||
AudioPlayer::<AudioSource>(source),
|
|
||||||
PlaybackSettings::DESPAWN,
|
|
||||||
SoundEffect,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,23 +2,33 @@
|
|||||||
|
|
||||||
// Unused utilities may trigger this lints undesirably.
|
// Unused utilities may trigger this lints undesirably.
|
||||||
|
|
||||||
|
pub mod assets;
|
||||||
mod colorscheme;
|
mod colorscheme;
|
||||||
pub mod interaction;
|
pub mod components;
|
||||||
|
pub mod events;
|
||||||
pub mod palette;
|
pub mod palette;
|
||||||
mod widgets;
|
mod systems;
|
||||||
|
pub mod widgets;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub mod prelude {
|
pub mod prelude {
|
||||||
pub use super::{
|
pub use super::{
|
||||||
colorscheme::{ColorScheme, ColorSchemeWrapper},
|
colorscheme::{ColorScheme, ColorSchemeWrapper},
|
||||||
interaction::{InteractionPalette, OnPress},
|
components::{InteractionPalette, UrlLink},
|
||||||
|
events::OnPress,
|
||||||
palette as ui_palette,
|
palette as ui_palette,
|
||||||
widgets::{Containers as _, Widgets as _},
|
widgets::{Containers as _, Widgets as _},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use assets::InteractionAssets;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use prelude::InteractionPalette;
|
||||||
|
|
||||||
|
use crate::asset_tracking::LoadResource;
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
pub(super) fn plugin(app: &mut App) {
|
||||||
app.add_plugins(interaction::plugin);
|
app.register_type::<InteractionPalette>();
|
||||||
|
app.load_resource::<InteractionAssets>();
|
||||||
|
app.add_plugins(systems::plugin);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,15 +2,6 @@ pub mod rose_pine;
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
pub const BUTTON_HOVERED_BACKGROUND: Color = Color::srgb(0.186, 0.328, 0.573);
|
|
||||||
pub const BUTTON_PRESSED_BACKGROUND: Color = Color::srgb(0.286, 0.478, 0.773);
|
|
||||||
|
|
||||||
pub const BUTTON_TEXT: Color = Color::srgb(0.925, 0.925, 0.925);
|
|
||||||
pub const LABEL_TEXT: Color = Color::srgb(0.867, 0.827, 0.412);
|
|
||||||
pub const HEADER_TEXT: Color = Color::srgb(0.867, 0.827, 0.412);
|
|
||||||
|
|
||||||
pub const NODE_BACKGROUND: Color = Color::srgb(0.286, 0.478, 0.773);
|
|
||||||
|
|
||||||
const MAX_COLOR_VALUE: f32 = 255.;
|
const MAX_COLOR_VALUE: f32 = 255.;
|
||||||
|
|
||||||
pub(super) const fn rgb_u8(red: u8, green: u8, blue: u8) -> Color {
|
pub(super) const fn rgb_u8(red: u8, green: u8, blue: u8) -> Color {
|
||||||
|
|||||||
@ -1,10 +1,92 @@
|
|||||||
use super::rgb_u8;
|
use crate::{
|
||||||
use crate::theme::prelude::ColorScheme;
|
create_color_scheme,
|
||||||
|
theme::{colorscheme::ColorScheme, palette::rgb_u8},
|
||||||
|
};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use strum::EnumIter;
|
use strum::EnumIter;
|
||||||
|
|
||||||
|
create_color_scheme!(
|
||||||
|
pub RosePine, {
|
||||||
|
Base: "#191724",
|
||||||
|
Surface: "#1f1d2e",
|
||||||
|
Overlay: "#26233a",
|
||||||
|
Muted: "#6e6a86",
|
||||||
|
Subtle: "#908caa",
|
||||||
|
Text: "#e0def4",
|
||||||
|
Love: "#eb6f92",
|
||||||
|
Gold: "#f6c177",
|
||||||
|
Rose: "#ebbcba",
|
||||||
|
Pine: "#31748f",
|
||||||
|
Foam: "#9ccfd8",
|
||||||
|
Iris: "#c4a7e7",
|
||||||
|
HighlightLow: "#21202e",
|
||||||
|
HighlightMed: "#403d52",
|
||||||
|
HighlightHigh: "#524f67"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
create_color_scheme!(
|
||||||
|
pub RosePineMoon, {
|
||||||
|
Base: "#232136",
|
||||||
|
Surface: "#2a273f",
|
||||||
|
Overlay: "#393552",
|
||||||
|
Muted: "#6e6a86",
|
||||||
|
Subtle: "#908caa",
|
||||||
|
Text: "#e0def4",
|
||||||
|
Love: "#eb6f92",
|
||||||
|
Gold: "#f6c177",
|
||||||
|
Rose: "#ea9a97",
|
||||||
|
Pine: "#3e8fb0",
|
||||||
|
Foam: "#9ccfd8",
|
||||||
|
Iris: "#c4a7e7",
|
||||||
|
HighlightLow: "#2a283e",
|
||||||
|
HighlightMed: "#44415a",
|
||||||
|
HighlightHigh: "#56526e"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
create_color_scheme!(
|
||||||
|
pub RosePineDawn, {
|
||||||
|
Base: "#faf4ed",
|
||||||
|
Surface: "#fffaf3",
|
||||||
|
Overlay: "#f2e9e1",
|
||||||
|
Muted: "#9893a5",
|
||||||
|
Subtle: "#797593",
|
||||||
|
Text: "#575279",
|
||||||
|
Love: "#b4637a",
|
||||||
|
Gold: "#ea9d34",
|
||||||
|
Rose: "#d7827e",
|
||||||
|
Pine: "#286983",
|
||||||
|
Foam: "#56949f",
|
||||||
|
Iris: "#907aa9",
|
||||||
|
HighlightLow: "#f4ede8",
|
||||||
|
HighlightMed: "#dfdad9",
|
||||||
|
HighlightHigh: "#cecacd"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! create_color_scheme {
|
||||||
|
($(#[$meta:meta])* $vis:vis $name:ident, {
|
||||||
|
Base: $base:expr,
|
||||||
|
Surface: $surface:expr,
|
||||||
|
Overlay: $overlay:expr,
|
||||||
|
Muted: $muted:expr,
|
||||||
|
Subtle: $subtle:expr,
|
||||||
|
Text: $text:expr,
|
||||||
|
Love: $love:expr,
|
||||||
|
Gold: $gold:expr,
|
||||||
|
Rose: $rose:expr,
|
||||||
|
Pine: $pine:expr,
|
||||||
|
Foam: $foam:expr,
|
||||||
|
Iris: $iris:expr,
|
||||||
|
HighlightLow: $hl_low:expr,
|
||||||
|
HighlightMed: $hl_med:expr,
|
||||||
|
HighlightHigh: $hl_high:expr
|
||||||
|
}) => {
|
||||||
|
$(#[$meta])*
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)]
|
||||||
pub enum RosePine {
|
$vis enum $name {
|
||||||
Base,
|
Base,
|
||||||
Surface,
|
Surface,
|
||||||
Overlay,
|
Overlay,
|
||||||
@ -22,24 +104,81 @@ pub enum RosePine {
|
|||||||
HighlightHigh,
|
HighlightHigh,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ColorScheme for RosePine {
|
impl $name {
|
||||||
|
fn hex_to_rgb(hex: &str) -> (u8, u8, u8) {
|
||||||
|
let hex = hex.strip_prefix('#').unwrap_or(hex);
|
||||||
|
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||||||
|
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
||||||
|
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
|
||||||
|
(r, g, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorScheme for $name {
|
||||||
fn to_color(&self) -> Color {
|
fn to_color(&self) -> Color {
|
||||||
match self {
|
match self {
|
||||||
Self::Base => rgb_u8(25, 23, 36),
|
Self::Base => {
|
||||||
Self::Surface => rgb_u8(31, 29, 46),
|
let (r, g, b) = Self::hex_to_rgb($base);
|
||||||
Self::Overlay => rgb_u8(38, 35, 58),
|
rgb_u8(r, g, b)
|
||||||
Self::Muted => rgb_u8(110, 106, 134),
|
},
|
||||||
Self::Subtle => rgb_u8(144, 140, 170),
|
Self::Surface => {
|
||||||
Self::Text => rgb_u8(224, 222, 244),
|
let (r, g, b) = Self::hex_to_rgb($surface);
|
||||||
Self::Love => rgb_u8(235, 111, 146),
|
rgb_u8(r, g, b)
|
||||||
Self::Gold => rgb_u8(246, 193, 119),
|
},
|
||||||
Self::Rose => rgb_u8(235, 188, 186),
|
Self::Overlay => {
|
||||||
Self::Pine => rgb_u8(49, 116, 143),
|
let (r, g, b) = Self::hex_to_rgb($overlay);
|
||||||
Self::Foam => rgb_u8(156, 207, 216),
|
rgb_u8(r, g, b)
|
||||||
Self::Iris => rgb_u8(196, 167, 231),
|
},
|
||||||
Self::HighlightLow => rgb_u8(33, 32, 46),
|
Self::Muted => {
|
||||||
Self::HighlightMed => rgb_u8(64, 61, 82),
|
let (r, g, b) = Self::hex_to_rgb($muted);
|
||||||
Self::HighlightHigh => rgb_u8(82, 79, 103),
|
rgb_u8(r, g, b)
|
||||||
|
},
|
||||||
|
Self::Subtle => {
|
||||||
|
let (r, g, b) = Self::hex_to_rgb($subtle);
|
||||||
|
rgb_u8(r, g, b)
|
||||||
|
},
|
||||||
|
Self::Text => {
|
||||||
|
let (r, g, b) = Self::hex_to_rgb($text);
|
||||||
|
rgb_u8(r, g, b)
|
||||||
|
},
|
||||||
|
Self::Love => {
|
||||||
|
let (r, g, b) = Self::hex_to_rgb($love);
|
||||||
|
rgb_u8(r, g, b)
|
||||||
|
},
|
||||||
|
Self::Gold => {
|
||||||
|
let (r, g, b) = Self::hex_to_rgb($gold);
|
||||||
|
rgb_u8(r, g, b)
|
||||||
|
},
|
||||||
|
Self::Rose => {
|
||||||
|
let (r, g, b) = Self::hex_to_rgb($rose);
|
||||||
|
rgb_u8(r, g, b)
|
||||||
|
},
|
||||||
|
Self::Pine => {
|
||||||
|
let (r, g, b) = Self::hex_to_rgb($pine);
|
||||||
|
rgb_u8(r, g, b)
|
||||||
|
},
|
||||||
|
Self::Foam => {
|
||||||
|
let (r, g, b) = Self::hex_to_rgb($foam);
|
||||||
|
rgb_u8(r, g, b)
|
||||||
|
},
|
||||||
|
Self::Iris => {
|
||||||
|
let (r, g, b) = Self::hex_to_rgb($iris);
|
||||||
|
rgb_u8(r, g, b)
|
||||||
|
},
|
||||||
|
Self::HighlightLow => {
|
||||||
|
let (r, g, b) = Self::hex_to_rgb($hl_low);
|
||||||
|
rgb_u8(r, g, b)
|
||||||
|
},
|
||||||
|
Self::HighlightMed => {
|
||||||
|
let (r, g, b) = Self::hex_to_rgb($hl_med);
|
||||||
|
rgb_u8(r, g, b)
|
||||||
|
},
|
||||||
|
Self::HighlightHigh => {
|
||||||
|
let (r, g, b) = Self::hex_to_rgb($hl_high);
|
||||||
|
rgb_u8(r, g, b)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||