Compare commits

...

145 Commits
v0.0.2 ... main

Author SHA1 Message Date
ed77d18e1e
Merge pull request #42 from kristoferssolo/fix/visibility 2025-01-18 17:34:58 +02:00
5dd932a6fe fix: wall visibility 2025-01-18 17:34:13 +02:00
d820b19988
Merge pull request #41 from kristoferssolo/feature/hide-floors 2025-01-18 16:59:43 +02:00
f08bd72038 feat(floors): hide floors above #18 2025-01-18 16:58:43 +02:00
4864ecca93
Merge pull request #40 from kristoferssolo/feature/pause-screen 2025-01-18 16:41:57 +02:00
7fa567d522 feat: add game entity cleanup 2025-01-18 16:41:10 +02:00
7a4bcd81f9 feat(pause): hide walls and player when paused 2025-01-18 16:41:10 +02:00
5d50daf768 feat(screens): add translucent pause screen #36 2025-01-18 16:41:10 +02:00
e7bdb37093 feat(sceen): add pause screen 2025-01-18 15:52:06 +02:00
c8e968e76e
Merge pull request #39 from kristoferssolo/fix/web-zoom 2025-01-17 15:17:22 +02:00
099d163325 refactor(score): change scoring algorithm 2025-01-17 15:16:40 +02:00
4398620ac8 fix: scroll wheel sensitivity in WASM 2025-01-17 14:27:02 +02:00
e1fa12b6b9
Merge pull request #38 from kristoferssolo/fix/clippy 2025-01-17 13:52:00 +02:00
df4dcdf3cb chore: bump version number 2025-01-17 13:51:30 +02:00
62a91f5765 fix: clippy warnings 2025-01-17 12:41:22 +02:00
48a39d4430
Merge pull request #37 from kristoferssolo/feature/camera 2025-01-17 12:12:51 +02:00
88c46d679d feat(camera): add camera controls #34 2025-01-17 12:09:55 +02:00
d01e987b89
Merge pull request #35 from kristoferssolo/feture/score 2025-01-17 00:58:07 +02:00
5e1e4a546a refactor(stats): make score a resource 2025-01-17 00:56:26 +02:00
58276ea8f7 feat(score): add score calculator 2025-01-17 00:41:16 +02:00
d2dd57bcff feat: add game stats 2025-01-16 23:26:37 +02:00
472a238a1c chore: update hexlab version 2025-01-16 21:48:56 +02:00
2bd115a714
Merge pull request #32 from kristoferssolo/revert/speed 2025-01-10 19:28:13 +02:00
5a7c92cd96 revert: player speed 2025-01-10 19:27:50 +02:00
3abf8e2331
Merge pull request #31 from kristoferssolo/fix/floor-position-overlap 2025-01-10 19:22:43 +02:00
4d37a547ff fix(floor): issue #29 2025-01-10 19:19:13 +02:00
95b173c504 fix: typo 2025-01-09 21:13:31 +02:00
e9f02e362a feat(maze): add Coordinates trait 2025-01-08 18:27:07 +02:00
0f4899319d refactor(themes): separate into files 2025-01-08 17:33:15 +02:00
69eacd42d5
Merge pull request #28 from kristoferssolo/fix/asset-loading 2025-01-06 16:44:43 +02:00
5bc87e65a8 CI: remove nextest 2025-01-06 16:44:25 +02:00
2341ee664e fix(assets): wait for assets load 2025-01-06 16:42:48 +02:00
7ff943e829 style: format web files 2025-01-06 12:55:01 +02:00
a698495c06
Merge pull request #27 from kristoferssolo/refactor 2025-01-06 12:43:20 +02:00
68096ee108 chore: bump version number 2025-01-06 12:42:42 +02:00
ef9bb50fba refactor(player): remove triggers 2025-01-06 11:42:18 +02:00
77407f7a90 refactor(maze): remove triggers and observers 2025-01-06 11:37:37 +02:00
b64930ed9e
Merge pull request #26 from kristoferssolo/readme 2025-01-05 23:40:25 +02:00
22193243a1 chore: update readme 2025-01-05 23:39:57 +02:00
919f063934
Merge pull request #25 from kristoferssolo/release 2025-01-05 23:29:15 +02:00
a1ed564bad chore: bump version number 2025-01-05 23:28:45 +02:00
2a12ab8cbe
Merge pull request #24 from kristoferssolo/feat/hint 2025-01-05 22:44:52 +02:00
9d68276086 feat(hints): add hint assets 2025-01-05 22:43:38 +02:00
a224a74d05 feat(hints): add hint systems 2025-01-05 22:03:29 +02:00
cbf3f7c835
Merge pull request #23 from kristoferssolo/feat/music 2025-01-05 21:03:50 +02:00
07f0cafcf8 ci: update CI 2025-01-05 20:54:48 +02:00
399db7605c chore: remove ducky 2025-01-05 20:49:13 +02:00
fea57af6d1 feat(vfx): add movement sound 2025-01-05 20:47:12 +02:00
6685e3e2c9 feat(music): remove music 2025-01-05 20:28:02 +02:00
29b18d0ed0 chore: delete docs dir 2025-01-05 20:15:13 +02:00
3d158a4e7c docs: add comments 2025-01-05 20:15:13 +02:00
cfaf565891 fix(floor): double event call 2025-01-05 19:49:48 +02:00
74836df618
Merge pull request #22 from kristoferssolo/feat/UI 2025-01-05 19:12:56 +02:00
b509b128bb fix: tests 2025-01-05 19:12:15 +02:00
285d35d87e
Merge pull request #21 from kristoferssolo/feat/UI 2025-01-05 19:06:37 +02:00
35e6420e68 chore(maze): change default maze radius 2025-01-05 18:48:06 +02:00
3709bfa58d refactor: remove MazePluginLoaded resource 2025-01-05 18:39:20 +02:00
101626cf3d feat(UI): style header 2025-01-05 18:29:16 +02:00
f117dd5e1c feat(UI): add title 2025-01-05 18:20:29 +02:00
1c01feee27 chore(screen): delete Credits screen 2025-01-05 17:44:51 +02:00
603d0646bf fix: lint errors 2025-01-05 17:40:37 +02:00
ecd98ea1e2 feat(player): make movement easier #16 2025-01-05 17:38:28 +02:00
db121bffa5 feat(maze): increase maze radius with new levels #17 2025-01-05 15:32:36 +02:00
58501cf536 feat(floor): move floors on "E" 2025-01-05 15:31:54 +02:00
e096216806
Merge pull request #20 from kristoferssolo/fix/floor-transitions 2025-01-05 15:03:58 +02:00
afd863a9be chore: update justfile 2025-01-05 15:02:53 +02:00
34ca2cfee7 fix(floor): transition misses 2025-01-05 15:01:17 +02:00
2c3a1a2fff fix(player): block movement during floor transition 2025-01-05 14:57:41 +02:00
a4e819b4b6 feat(floor): synchronize floor start/end positions #13 2025-01-05 14:05:33 +02:00
e15c055f06 fix: lint warnings 2025-01-05 13:55:36 +02:00
4145abda19 feat(colors): add all Rosé Pine variants 2025-01-05 00:33:27 +02:00
f2f333b8cf chore: update justfile 2025-01-04 23:38:29 +02:00
9198560978
Merge pull request #19 from kristoferssolo/feat/floor-transitions 2025-01-04 23:35:54 +02:00
c587371544 fix(floor): #8 2025-01-04 23:24:47 +02:00
c4dcedd723 fix(floor): descend 2025-01-04 22:41:24 +02:00
f68c68f167 feat(dev): add floor display 2025-01-04 21:30:38 +02:00
ead980b7fe
Merge pull request #11 from kristoferssolo/feature/floors 2025-01-01 22:55:02 +02:00
e352b45401 chore: rename MovementState -> FloorYTarget 2025-01-01 22:53:57 +02:00
90f5443804 feat(hexlab): enable bevy feature 2024-12-30 17:33:09 +02:00
9e3538f571 fix: hexlab changes 2024-12-29 17:11:31 +02:00
3b5c92e998 refactor(colorscheme): add RosePine enum 2024-12-27 14:01:52 +02:00
4635b0f134 fix(floor): multiple floor create 2024-12-27 11:49:22 +02:00
433a3ce5e8 refactor(floor): separate systems 2024-12-23 14:55:27 +02:00
3659ffa1a6 fix: floor movement 2024-12-23 14:48:46 +02:00
8683f727f2 chore: bump version number 2024-12-23 13:55:56 +02:00
b966d38e94 fix: clippy warnings 2024-12-23 13:55:48 +02:00
45511e4d80 fix(maze): spawning 2024-12-23 13:39:06 +02:00
a76cbdc02b fix: typo 2024-12-23 12:46:32 +02:00
6c07bc0670 feat(floor): add NextFloor component 2024-12-22 18:25:40 +02:00
f8ea1edd87 fix(floor): spawning 2024-12-19 18:38:18 +02:00
86bbee8cb8 feat(floor): spawn next floor 2024-12-18 19:15:57 +02:00
de4989ccfb test(floor): add min/max tests 2024-12-18 17:11:02 +02:00
269686323f feat(player): add ascension/descension 2024-12-18 17:01:21 +02:00
4640862402
Merge pull request #10 from kristoferssolo/feature/floors
refactor: move to observer based systems
2024-12-17 15:45:30 +02:00
cec07c9069 refactor: move to [observer](https://bevyengine.org/examples/ecs-entity-component-system/observers/) based systems 2024-12-17 15:43:26 +02:00
e65790c743
Merge pull request #9 from kristoferssolo/feature/event-based-spawns
Feature/event based spawns
2024-12-16 18:29:35 +02:00
518077e8fd chore: fix clippy warnings 2024-12-14 20:34:59 +02:00
5c14631e53 fix(maze): rendering 2024-12-14 20:09:04 +02:00
3751ef3ee3 refactor(maze): add event based maze updates 2024-12-14 19:46:33 +02:00
3770dcd395 feat(floor): create floor module 2024-12-13 18:48:02 +02:00
72b16dc8bb
Merge pull request #7 from kristoferssolo/feature/player
Feature/player
2024-12-12 21:10:36 +02:00
f4aefb00fa feat(player): add player respawn and despawn 2024-12-12 21:10:03 +02:00
b89921dcd6 fix(player): spawn position 2024-12-12 19:33:35 +02:00
30d6cf5fba feat(player): add collision check 2024-12-12 17:24:14 +02:00
6bdfc2f672 refactor(player): separate into 2 systems 2024-12-11 18:10:36 +02:00
4ac52cfb38 feat(player): add tile movement 2024-12-11 18:10:36 +02:00
0fb3504b81 feat(player): add movement system 2024-12-11 18:10:34 +02:00
6f0f8471c5 feat(player): spawn player pill 2024-12-11 18:08:29 +02:00
c694281e88 refactor: use private plugins 2024-12-11 18:08:27 +02:00
80bc027477
Merge pull request #6 from kristoferssolo/feature/update-bevy
chore: update to bevy0.15
2024-12-11 18:01:40 +02:00
995cc56e28 fix: lint warnings 2024-12-11 18:01:22 +02:00
b89556679c chore: update to bevy0.15 2024-12-11 17:40:12 +02:00
3a7f8b6401
Merge pull request #5 from kristoferssolo/fix/maze-generation
Fix/maze generation
2024-12-08 19:39:01 +02:00
dfb653898f chore: remove demo project 2024-12-08 19:37:07 +02:00
8ef2db1d48 feat(dev-tools): add maze orientation toggle 2024-12-08 19:30:58 +02:00
1a0a859fec refactor(devt-tools): remove custom inspector 2024-12-08 18:49:41 +02:00
9ecb38b442 refactor(dev-tools): reorganize dev tools module code 2024-12-08 18:46:01 +02:00
dca6747f83 feat(maze): use seed 2024-12-08 17:50:56 +02:00
24b92a24cc feat(maze): add maze recreation ability 2024-12-08 17:09:21 +02:00
0ca94082a9 refactor(maze): reorganize maze module code 2024-12-08 16:27:11 +02:00
34f85be4ef fix(maze): tile wall orientation 2024-12-04 18:17:13 +02:00
41a6059912 update Cargo.lock 2024-11-11 17:32:43 +02:00
42ac091748
Merge pull request #4 from kristoferssolo/rename-repo
refactor(name): change project name
2024-11-11 17:29:12 +02:00
468c5bfe4a refactor(name): change project name 2024-11-11 17:28:39 +02:00
5eefa234e1 Merge branch 'feature/isometric-camera' 2024-11-04 20:45:36 +02:00
8f454cb2c6 fix: warnings 2024-11-04 20:37:25 +02:00
1aa1bd1c41 chore: bump version to 0.0.4 2024-11-04 20:29:26 +02:00
00c90916e5 feat(walls): draw tile walls 2024-11-04 20:18:04 +02:00
07794aa993 feat(lighting): add ambient light 2024-10-25 17:22:37 +03:00
c471382f6f feat(grid): draw 3d hex grid 2024-10-12 15:59:52 +03:00
1a8eeb97b5 feat(prism): impl Default 2024-09-30 12:00:13 +03:00
623b53c34f feat(dev): add bevy inspector egui 2024-09-26 12:25:40 +03:00
ec9ac21b8f feat(hex): draw hex prisms 2024-09-25 20:29:58 +03:00
c5f8dede6d feat(grid): add precidualy generated maze grid 2024-09-23 20:42:43 +03:00
f16fd51090 feat(grid): generate maze 2024-09-22 15:28:07 +03:00
0ee94c826a refactor(grid): make it a Plugin 2024-09-22 14:36:00 +03:00
f11b701ec3 feat(grid): generate grid with random walls 2024-09-22 14:25:08 +03:00
869d11e810 feat: add bevy_protoype_lyon dependency 2024-09-20 16:59:24 +03:00
5929f255a8 refactor(grid): remove dead code 2024-09-18 21:35:21 +03:00
590c293dc2 feat(grid): generate a hex grid 2024-09-18 17:15:11 +03:00
7db07f7e4d Revert 1 commits
846b232 'feat(camera): add 3d orthogrpahic camera setup'
2024-09-18 15:39:47 +03:00
846b2326a3 feat(camera): add 3d orthogrpahic camera setup 2024-09-18 15:33:39 +03:00
0a64641941 feat(hex): generate a hex grid 2024-09-18 13:05:58 +03:00
3f0ba6d0d8 chore: add justfile 2024-09-17 22:10:39 +03:00
70579168a2 chore: bump version to 0.0.3 2024-09-17 22:10:24 +03:00
114 changed files with 6111 additions and 2323 deletions

View File

@ -27,9 +27,7 @@ jobs:
sweep-cache: true
- name: Run tests
run: |
cargo test --locked --workspace --all-features --all-targets
# Workaround for https://github.com/rust-lang/cargo/issues/6669
cargo test --locked --workspace --all-features --doc
cargo test --locked --workspace --no-default-features
# Run clippy lints.
clippy:
name: Clippy

View File

@ -15,14 +15,14 @@ on:
# Configure the release workflow by editing these values.
env:
# The base filename of the binary produced by `cargo build`.
cargo_build_binary_name: the-labyrinth-of-echoes
cargo_build_binary_name: maze-ascension
# 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: kristoferssolo/the-labyrinth-of-echoes
upload_to_itch: kristoferssolo/maze-ascension
############
# ADVANCED #
############
@ -30,26 +30,26 @@ env:
# The ID of the app produced by this workflow.
# Applies to macOS releases.
# Must contain only A-Z, a-z, 0-9, hyphen, and period: https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleidentifier
app_id: kristoferssolo.the-labyrinth-of-echoes
app_id: kristoferssolo.maze-ascension.
# 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: the-labyrinth-of-echoes
#app_binary_name: maze-ascension
# The name of the `.zip` or `.dmg` file produced by this workflow.
# Defaults to `app_binary_name` if commented out.
#app_package_name: the-labyrinth-of-echoes
#app_package_name: maze-ascension
# 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: The Labyrinth Of Echoes
#app_display_name: Maze Ascension: The Labyrinth Of Echoes
# 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: The Labyrint…
#app_short_name: Maze Ascension
# 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

2526
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
[package]
name = "the-labyrinth-of-echoes"
name = "maze-ascension"
authors = ["Kristofers Solo <dev@kristofers.xyz>"]
version = "0.0.1"
version = "1.1.3"
edition = "2021"
[dependencies]
bevy = { version = "0.14", features = ["wayland"] }
bevy = { version = "0.15", features = ["wayland"] }
rand = "0.8"
# Compile low-severity logs out of native builds for performance.
log = { version = "0.4", features = [
@ -17,6 +17,22 @@ tracing = { version = "0.1", features = [
"max_level_debug",
"release_max_level_warn",
] }
hexx = { version = "0.19", features = ["bevy_reflect", "grid"] }
hexlab = { version = "0.6", features = ["bevy", "pathfinding"] }
bevy-inspector-egui = { version = "0.28", optional = true }
bevy_egui = { version = "0.31", optional = true }
thiserror = "2.0"
anyhow = "1"
strum = { version = "0.26", features = ["derive"] }
[dev-dependencies]
claims = "0.8.0"
rayon = "1.10.0"
rstest = "0.24"
rstest_reuse = "0.7"
test-log = { version = "0.2.16", default-features = false, features = [
"trace",
] }
[features]
default = [
@ -27,6 +43,8 @@ dev = [
# Improve compile times for dev builds by linking Bevy as a dynamic library.
"bevy/dynamic_linking",
"bevy/bevy_dev_tools",
"dep:bevy-inspector-egui",
"dep:bevy_egui",
]
dev_native = [
"dev",
@ -45,6 +63,8 @@ dev_native = [
too_many_arguments = "allow"
# Queries that access many components may trigger this lint.
type_complexity = "allow"
nursery = { level = "warn", priority = -1 }
unwrap_used = "warn"
# Compile with Performance Optimizations:

View File

@ -1,131 +0,0 @@
_Brought to you by the Bevy Jam working group._
# Bevy Quickstart
This template is a great way to get started on a new [Bevy](https://bevyengine.org/) game—especially for a game jam!
Start with a [basic project structure](#write-your-game) and [CI / CD](#release-your-game) that can deploy to [itch.io](https://itch.io).
You can [try this template in your web browser!](https://the-bevy-flock.itch.io/bevy-quickstart)
[@ChristopherBiscardi](https://github.com/ChristopherBiscardi) made a video on how to use this template from start to finish:
[<img src="./docs/img/thumbnail.png" width=40% height=40% alt="A video tutorial for bevy_quickstart"/>](https://www.youtube.com/watch?v=ESBRyXClaYc)
## Prerequisites
We assume that you know how to use Bevy already and have seen the [official Quick Start Guide](https://bevyengine.org/learn/quick-start/introduction/).
If you're new to Bevy, the patterns used in this template may look a bit weird at first glance.
See our [Design Document](./docs/design.md) for more information on how we structured the code and why.
## Create a new game
Install [`cargo-generate`](https://github.com/cargo-generate/cargo-generate) and run the following command:
```sh
cargo generate TheBevyFlock/bevy_quickstart --branch cargo-generate
```
Then navigate to the newly generated directory and run the following commands:
```sh
git branch --move main
cargo update
git commit -am 'Initial commit'
```
Then [create a GitHub repository](https://github.com/new) and push your local repository to it.
<details>
<summary>This template can also be set up manually.</summary>
Navigate to the top of [this GitHub repository](https://github.com/TheBevyFlock/bevy_quickstart/) and select `Use this template > Create a new repository`:
![UI demonstration](./docs/img/readme-manual-setup.png)
Clone your new Github repository to a local repository and push a commit with the following changes:
- Delete `LICENSE`, `README`, and `docs/` files.
- Search for and replace instances of `bevy_quickstart` with the name of your project.
- Adjust the `env` variables in [`.github/workflows/release.yaml`](./.github/workflows/release.yaml).
</details>
## Write your game
The best way to get started is to play around with what you find in [`src/demo/`](./src/demo).
This template comes with a basic project structure that you may find useful:
| Path | Description |
| -------------------------------------------------- | ------------------------------------------------------------------ |
| [`src/lib.rs`](./src/lib.rs) | App setup |
| [`src/asset_tracking.rs`](./src/asset_tracking.rs) | A high-level way to load collections of asset handles as resources |
| [`src/audio/`](./src/audio) | Marker components for sound effects and music |
| [`src/demo/`](./src/demo) | Example game mechanics & content (replace with your own code) |
| [`src/dev_tools.rs`](./src/dev_tools.rs) | Dev tools for dev builds (press \` aka backtick to toggle) |
| [`src/screens/`](./src/screens) | Splash screen, title screen, gameplay screen, etc. |
| [`src/theme/`](./src/theme) | Reusable UI widgets & theming |
Feel free to move things around however you want, though.
> [!Tip]
> Be sure to check out the [3rd-party tools](./docs/tooling.md) we recommend!
## Run your game
Running your game locally is very simple:
- Use `cargo run` to run a native dev build.
- Use [`trunk serve`](https://trunkrs.dev/) to run a web dev build.
If you're using [VS Code](https://code.visualstudio.com/), this template comes with a [`.vscode/tasks.json`](./.vscode/tasks.json) file.
<details>
<summary>Run release builds</summary>
- Use `cargo run --profile release-native --no-default-features` to run a native release build.
- Use `trunk serve --release --no-default-features` to run a web release build.
</details>
<details>
<summary>Linux dependencies</summary>
If you are using Linux, make sure you take a look at Bevy's [Linux dependencies](https://github.com/bevyengine/bevy/blob/main/docs/linux_dependencies.md).
Note that this template enables Wayland support, which requires additional dependencies as detailed in the link above.
Wayland is activated by using the `bevy/wayland` feature in the [`Cargo.toml`](./Cargo.toml).
</details>
<details>
<summary>(Optional) Improve your compile times</summary>
[`.cargo/config_fast_builds.toml`](./.cargo/config_fast_builds.toml) contains documentation on how to set up your environment to improve compile times.
After you've fiddled with it, rename it to `.cargo/config.toml` to enable it.
</details>
## Release your game
This template uses [GitHub workflows](https://docs.github.com/en/actions/using-workflows) to run tests and build releases.
See [Workflows](./docs/workflows.md) for more information.
## Known Issues
There are some known issues in Bevy that require some arcane workarounds.
To keep this template simple, we have opted not to include those workarounds.
You can read about them in the [Known Issues](./docs/known-issues.md) document.
## License
The source code in this repository is licensed under any of the following at your option:
- [CC0-1.0 License](./LICENSE-CC0-1.0.txt)
- [MIT License](./LICENSE-MIT.txt)
- [Apache License, Version 2.0](./LICENSE-Apache-2.0.txt)
The CC0 license explicitly does not waive patent rights, but we confirm that we hold no patent rights to anything presented in this repository.
## Credits
The [assets](./assets) in this repository are all 3rd-party. See the [credits screen](./src/screens/credits.rs) for more information.

View File

@ -1,12 +1,41 @@
# 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.
A procedurally generated 3D maze game built with Rust and Bevy game engine.
Navigate through hexagonal maze levels that become progressively more
challenging as you ascend.
[Play on itch.io](https://kristoferssolo.itch.io/maze-ascension)
## Features
- Procedurally generated hexagonal mazes
- Multiple floor levels with increasing difficulty
- 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

View File

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View 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

View File

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

View File

@ -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.

View File

@ -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.

View File

@ -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`:
![A screenshot showing a manually triggered workflow on GitHub Actions](./img/workflow-dispatch-release.png)
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>
![A screenshot showing where to add secrets in the GitHub Actions settings](./img/workflow-secrets.png)
</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".
![A screenshot showing a web build selected in the itch.io uploads](img/workflow-itch-release.png)

33
justfile Normal file
View File

@ -0,0 +1,33 @@
# Default recipe
default:
@just --list
# Run native dev
native-dev:
RUSTC_WRAPPER=sccache RUST_BACKTRACE=full cargo run
# Run native release
native-release:
RUSTC_WRAPPER=sccache cargo run --release --no-default-features
# Run web dev
web-dev:
RUSTC_WRAPPER=sccache RUST_BACKTRACE=full trunk serve
# Run web release
web-release:
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

View File

@ -1,8 +1,7 @@
//! A high-level way to load collections of asset handles as resources.
use std::collections::VecDeque;
use bevy::prelude::*;
use std::collections::VecDeque;
pub(super) fn plugin(app: &mut App) {
app.init_resource::<ResourceHandles>();
@ -51,7 +50,9 @@ fn load_resource_assets(world: &mut World) {
world.resource_scope(|world, mut resource_handles: Mut<ResourceHandles>| {
world.resource_scope(|world, assets: Mut<AssetServer>| {
for _ in 0..resource_handles.waiting.len() {
let (handle, insert_fn) = resource_handles.waiting.pop_front().unwrap();
let Some((handle, insert_fn)) = resource_handles.waiting.pop_front() else {
continue;
};
if assets.is_loaded_with_dependencies(&handle) {
insert_fn(world, &handle);
resource_handles.finished.push(handle);

View File

@ -7,7 +7,7 @@ use bevy::prelude::*;
///
/// ```
/// use bevy::prelude::*;
/// use the_labyrinth_of_echoes::audio::Music;
/// use maze_ascension::audio::Music;
///
/// fn set_music_volume(sink_query: Query<&AudioSink, With<Music>>) {
/// for sink in &sink_query {
@ -25,7 +25,7 @@ pub struct Music;
///
/// ```
/// use bevy::prelude::*;
/// use the_labyrinth_of_echoes::audio::SoundEffect;
/// use maze_ascension::audio::SoundEffect;
///
/// fn set_sound_effect_volume(sink_query: Query<&AudioSink, With<SoundEffect>>) {
/// for sink in &sink_query {

64
src/camera.rs Normal file
View 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;
}
}

26
src/constants.rs Normal file
View File

@ -0,0 +1,26 @@
pub const MOVEMENT_THRESHOLD: f32 = 0.01;
pub const WALL_OVERLAP_MODIFIER: f32 = 1.25;
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;

View File

@ -1,177 +0,0 @@
//! Player sprite animation.
//! This is based on multiple examples and may be very different for your game.
//! - [Sprite flipping](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_flipping.rs)
//! - [Sprite animation](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_animation.rs)
//! - [Timers](https://github.com/bevyengine/bevy/blob/latest/examples/time/timers.rs)
use bevy::prelude::*;
use rand::prelude::*;
use std::time::Duration;
use crate::{
audio::SoundEffect,
demo::{movement::MovementController, player::PlayerAssets},
AppSet,
};
pub(super) fn plugin(app: &mut App) {
// Animate and play sound effects based on controls.
app.register_type::<PlayerAnimation>();
app.add_systems(
Update,
(
update_animation_timer.in_set(AppSet::TickTimers),
(
update_animation_movement,
update_animation_atlas,
trigger_step_sound_effect,
)
.chain()
.run_if(resource_exists::<PlayerAssets>)
.in_set(AppSet::Update),
),
);
}
/// Update the sprite direction and animation state (idling/walking).
fn update_animation_movement(
mut player_query: Query<(&MovementController, &mut Sprite, &mut PlayerAnimation)>,
) {
for (controller, mut sprite, mut animation) in &mut player_query {
let dx = controller.intent.x;
if dx != 0.0 {
sprite.flip_x = dx < 0.0;
}
let animation_state = if controller.intent == Vec2::ZERO {
PlayerAnimationState::Idling
} else {
PlayerAnimationState::Walking
};
animation.update_state(animation_state);
}
}
/// Update the animation timer.
fn update_animation_timer(time: Res<Time>, mut query: Query<&mut PlayerAnimation>) {
for mut animation in &mut query {
animation.update_timer(time.delta());
}
}
/// Update the texture atlas to reflect changes in the animation.
fn update_animation_atlas(mut query: Query<(&PlayerAnimation, &mut TextureAtlas)>) {
for (animation, mut atlas) in &mut query {
if animation.changed() {
atlas.index = animation.get_atlas_index();
}
}
}
/// If the player is moving, play a step sound effect synchronized with the
/// animation.
fn trigger_step_sound_effect(
mut commands: Commands,
player_assets: Res<PlayerAssets>,
mut step_query: Query<&PlayerAnimation>,
) {
for animation in &mut step_query {
if animation.state == PlayerAnimationState::Walking
&& animation.changed()
&& (animation.frame == 2 || animation.frame == 5)
{
let rng = &mut rand::thread_rng();
let random_step = player_assets.steps.choose(rng).unwrap();
commands.spawn((
AudioBundle {
source: random_step.clone(),
settings: PlaybackSettings::DESPAWN,
},
SoundEffect,
));
}
}
}
/// Component that tracks player's animation state.
/// It is tightly bound to the texture atlas we use.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct PlayerAnimation {
timer: Timer,
frame: usize,
state: PlayerAnimationState,
}
#[derive(Reflect, PartialEq)]
pub enum PlayerAnimationState {
Idling,
Walking,
}
impl PlayerAnimation {
/// The number of idle frames.
const IDLE_FRAMES: usize = 2;
/// The duration of each idle frame.
const IDLE_INTERVAL: Duration = Duration::from_millis(500);
/// The number of walking frames.
const WALKING_FRAMES: usize = 6;
/// The duration of each walking frame.
const WALKING_INTERVAL: Duration = Duration::from_millis(50);
fn idling() -> Self {
Self {
timer: Timer::new(Self::IDLE_INTERVAL, TimerMode::Repeating),
frame: 0,
state: PlayerAnimationState::Idling,
}
}
fn walking() -> Self {
Self {
timer: Timer::new(Self::WALKING_INTERVAL, TimerMode::Repeating),
frame: 0,
state: PlayerAnimationState::Walking,
}
}
pub fn new() -> Self {
Self::idling()
}
/// Update animation timers.
pub fn update_timer(&mut self, delta: Duration) {
self.timer.tick(delta);
if !self.timer.finished() {
return;
}
self.frame = (self.frame + 1)
% match self.state {
PlayerAnimationState::Idling => Self::IDLE_FRAMES,
PlayerAnimationState::Walking => Self::WALKING_FRAMES,
};
}
/// Update animation state if it changes.
pub fn update_state(&mut self, state: PlayerAnimationState) {
if self.state != state {
match state {
PlayerAnimationState::Idling => *self = Self::idling(),
PlayerAnimationState::Walking => *self = Self::walking(),
}
}
}
/// Whether animation changed this tick.
pub fn changed(&self) -> bool {
self.timer.finished()
}
/// Return sprite index in the atlas.
pub fn get_atlas_index(&self) -> usize {
match self.state {
PlayerAnimationState::Idling => self.frame,
PlayerAnimationState::Walking => 6 + self.frame,
}
}
}

View File

@ -1,20 +0,0 @@
//! Spawn the main level.
use bevy::{ecs::world::Command, prelude::*};
use crate::demo::player::SpawnPlayer;
pub(super) fn plugin(_app: &mut App) {
// No setup required for this plugin.
// It's still good to have a function here so that we can add some setup
// later if needed.
}
/// A [`Command`] to spawn the level.
/// Functions that accept only `&mut World` as their parameter implement [`Command`].
/// We use this style when a command requires no configuration.
pub fn spawn_level(world: &mut World) {
// The only thing we have in our level is a player,
// but add things like walls etc. here.
SpawnPlayer { max_speed: 400.0 }.apply(world);
}

View File

@ -1,20 +0,0 @@
//! Demo gameplay. All of these modules are only intended for demonstration
//! purposes and should be replaced with your own game logic.
//! Feel free to change the logic found here if you feel like tinkering around
//! to get a feeling for the template.
use bevy::prelude::*;
mod animation;
pub mod level;
mod movement;
pub mod player;
pub(super) fn plugin(app: &mut App) {
app.add_plugins((
animation::plugin,
movement::plugin,
player::plugin,
level::plugin,
));
}

View File

@ -1,84 +0,0 @@
//! Handle player input and translate it into movement through a character
//! controller. A character controller is the collection of systems that govern
//! the movement of characters.
//!
//! In our case, the character controller has the following logic:
//! - Set [`MovementController`] intent based on directional keyboard input.
//! This is done in the `player` module, as it is specific to the player
//! character.
//! - Apply movement based on [`MovementController`] intent and maximum speed.
//! - Wrap the character within the window.
//!
//! Note that the implementation used here is limited for demonstration
//! purposes. If you want to move the player in a smoother way,
//! consider using a [fixed timestep](https://github.com/bevyengine/bevy/blob/main/examples/movement/physics_in_fixed_timestep.rs).
use bevy::{prelude::*, window::PrimaryWindow};
use crate::AppSet;
pub(super) fn plugin(app: &mut App) {
app.register_type::<(MovementController, ScreenWrap)>();
app.add_systems(
Update,
(apply_movement, apply_screen_wrap)
.chain()
.in_set(AppSet::Update),
);
}
/// These are the movement parameters for our character controller.
/// For now, this is only used for a single player, but it could power NPCs or
/// other players as well.
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct MovementController {
/// The direction the character wants to move in.
pub intent: Vec2,
/// Maximum speed in world units per second.
/// 1 world unit = 1 pixel when using the default 2D camera and no physics
/// engine.
pub max_speed: f32,
}
impl Default for MovementController {
fn default() -> Self {
Self {
intent: Vec2::ZERO,
// 400 pixels per second is a nice default, but we can still vary this per character.
max_speed: 400.0,
}
}
}
fn apply_movement(
time: Res<Time>,
mut movement_query: Query<(&MovementController, &mut Transform)>,
) {
for (controller, mut transform) in &mut movement_query {
let velocity = controller.max_speed * controller.intent;
transform.translation += velocity.extend(0.0) * time.delta_seconds();
}
}
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct ScreenWrap;
fn apply_screen_wrap(
window_query: Query<&Window, With<PrimaryWindow>>,
mut wrap_query: Query<&mut Transform, With<ScreenWrap>>,
) {
let Ok(window) = window_query.get_single() else {
return;
};
let size = window.size() + 256.0;
let half_size = size / 2.0;
for mut transform in &mut wrap_query {
let position = transform.translation.xy();
let wrapped = (position + half_size).rem_euclid(size) - half_size;
transform.translation = wrapped.extend(transform.translation.z);
}
}

View File

@ -1,153 +0,0 @@
//! Plugin handling the player character in particular.
//! Note that this is separate from the `movement` module as that could be used
//! for other characters as well.
use bevy::{
ecs::{system::RunSystemOnce as _, world::Command},
prelude::*,
render::texture::{ImageLoaderSettings, ImageSampler},
};
use crate::{
asset_tracking::LoadResource,
demo::{
animation::PlayerAnimation,
movement::{MovementController, ScreenWrap},
},
screens::Screen,
AppSet,
};
pub(super) fn plugin(app: &mut App) {
app.register_type::<Player>();
app.load_resource::<PlayerAssets>();
// Record directional input as movement controls.
app.add_systems(
Update,
record_player_directional_input.in_set(AppSet::RecordInput),
);
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Default, Reflect)]
#[reflect(Component)]
pub struct Player;
/// A command to spawn the player character.
#[derive(Debug)]
pub struct SpawnPlayer {
/// See [`MovementController::max_speed`].
pub max_speed: f32,
}
impl Command for SpawnPlayer {
fn apply(self, world: &mut World) {
world.run_system_once_with(self, spawn_player);
}
}
fn spawn_player(
In(config): In<SpawnPlayer>,
mut commands: Commands,
player_assets: Res<PlayerAssets>,
mut texture_atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
// A texture atlas is a way to split one image with a grid into multiple
// sprites. By attaching it to a [`SpriteBundle`] and providing an index, we
// can specify which section of the image we want to see. We will use this
// to animate our player character. You can learn more about texture atlases in
// this example: https://github.com/bevyengine/bevy/blob/latest/examples/2d/texture_atlas.rs
let layout = TextureAtlasLayout::from_grid(UVec2::splat(32), 6, 2, Some(UVec2::splat(1)), None);
let texture_atlas_layout = texture_atlas_layouts.add(layout);
let player_animation = PlayerAnimation::new();
commands.spawn((
Name::new("Player"),
Player,
SpriteBundle {
texture: player_assets.ducky.clone(),
transform: Transform::from_scale(Vec2::splat(8.0).extend(1.0)),
..Default::default()
},
TextureAtlas {
layout: texture_atlas_layout.clone(),
index: player_animation.get_atlas_index(),
},
MovementController {
max_speed: config.max_speed,
..default()
},
ScreenWrap,
player_animation,
StateScoped(Screen::Gameplay),
));
}
fn record_player_directional_input(
input: Res<ButtonInput<KeyCode>>,
mut controller_query: Query<&mut MovementController, With<Player>>,
) {
// Collect directional input.
let mut intent = Vec2::ZERO;
if input.pressed(KeyCode::KeyW) || input.pressed(KeyCode::ArrowUp) {
intent.y += 1.0;
}
if input.pressed(KeyCode::KeyS) || input.pressed(KeyCode::ArrowDown) {
intent.y -= 1.0;
}
if input.pressed(KeyCode::KeyA) || input.pressed(KeyCode::ArrowLeft) {
intent.x -= 1.0;
}
if input.pressed(KeyCode::KeyD) || input.pressed(KeyCode::ArrowRight) {
intent.x += 1.0;
}
// Normalize so that diagonal movement has the same speed as
// horizontal and vertical movement.
// This should be omitted if the input comes from an analog stick instead.
let intent = intent.normalize_or_zero();
// Apply movement intent to controllers.
for mut controller in &mut controller_query {
controller.intent = intent;
}
}
#[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 ducky: Handle<Image>,
#[dependency]
pub steps: Vec<Handle<AudioSource>>,
}
impl PlayerAssets {
pub const PATH_DUCKY: &'static str = "images/ducky.png";
pub const PATH_STEP_1: &'static str = "audio/sound_effects/step1.ogg";
pub const PATH_STEP_2: &'static str = "audio/sound_effects/step2.ogg";
pub const PATH_STEP_3: &'static str = "audio/sound_effects/step3.ogg";
pub const PATH_STEP_4: &'static str = "audio/sound_effects/step4.ogg";
}
impl FromWorld for PlayerAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
ducky: assets.load_with_settings(
PlayerAssets::PATH_DUCKY,
|settings: &mut ImageLoaderSettings| {
// Use `nearest` image sampling to preserve the pixel art style.
settings.sampler = ImageSampler::nearest();
},
),
steps: vec![
assets.load(PlayerAssets::PATH_STEP_1),
assets.load(PlayerAssets::PATH_STEP_2),
assets.load(PlayerAssets::PATH_STEP_3),
assets.load(PlayerAssets::PATH_STEP_4),
],
}
}
}

View File

@ -1,30 +0,0 @@
//! Development tools for the game. This plugin is only enabled in dev builds.
use bevy::{
dev_tools::{
states::log_transitions,
ui_debug_overlay::{DebugUiPlugin, UiDebugOptions},
},
input::common_conditions::input_just_pressed,
prelude::*,
};
use crate::screens::Screen;
pub(super) fn plugin(app: &mut App) {
// Log `Screen` state transitions.
app.add_systems(Update, log_transitions::<Screen>);
// Toggle the debug overlay for UI.
app.add_plugins(DebugUiPlugin);
app.add_systems(
Update,
toggle_debug_ui.run_if(input_just_pressed(TOGGLE_KEY)),
);
}
const TOGGLE_KEY: KeyCode = KeyCode::Backquote;
fn toggle_debug_ui(mut options: ResMut<UiDebugOptions>) {
options.toggle();
}

32
src/dev_tools/mod.rs Normal file
View File

@ -0,0 +1,32 @@
mod ui;
use crate::screens::Screen;
use bevy::{
dev_tools::{
states::log_transitions,
ui_debug_overlay::{DebugUiPlugin, UiDebugOptions},
},
input::common_conditions::input_just_pressed,
prelude::*,
};
use bevy_egui::EguiPlugin;
use bevy_inspector_egui::quick::WorldInspectorPlugin;
use ui::maze_controls_ui;
pub(super) fn plugin(app: &mut App) {
app.add_systems(Update, log_transitions::<Screen>)
.add_plugins(EguiPlugin)
.add_plugins(WorldInspectorPlugin::new())
.add_plugins(DebugUiPlugin)
.add_systems(Update, maze_controls_ui)
.add_systems(
Update,
toggle_debug_ui.run_if(input_just_pressed(TOGGLE_KEY)),
);
}
const TOGGLE_KEY: KeyCode = KeyCode::Backquote;
fn toggle_debug_ui(mut options: ResMut<UiDebugOptions>) {
options.toggle();
}

View File

@ -0,0 +1,166 @@
use crate::{
floor::components::{CurrentFloor, Floor},
maze::{commands::RespawnMaze, components::MazeConfig, GlobalMazeConfig},
player::commands::RespawnPlayer,
screens::Screen,
};
use bevy::{prelude::*, window::PrimaryWindow};
use bevy_egui::{
egui::{self, emath::Numeric, DragValue, TextEdit, Ui},
EguiContext,
};
use hexx::{Hex, HexOrientation};
use rand::{thread_rng, Rng};
use std::ops::RangeInclusive;
pub fn maze_controls_ui(world: &mut World) {
if let Some(state) = world.get_resource::<State<Screen>>() {
// Check if the current state is NOT Gameplay
if *state.get() != Screen::Gameplay {
return;
}
}
let Ok(egui_context) = world
.query_filtered::<&mut EguiContext, With<PrimaryWindow>>()
.get_single(world)
else {
return;
};
let mut egui_context = egui_context.clone();
let Ok((maze_config, floor)) = world
.query_filtered::<(&MazeConfig, &Floor), With<CurrentFloor>>()
.get_single(world)
else {
return;
};
let mut maze_config = maze_config.clone();
let floor_value = floor.0;
let mut changed = false;
egui::Window::new("Maze Controls").show(egui_context.get_mut(), |ui| {
if let Some(mut global_config) = world.get_resource_mut::<GlobalMazeConfig>() {
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_drag_value_control(ui, "Radius:", &mut maze_config.radius, 1.0, 1..=100);
changed |=
add_drag_value_control(ui, "Height:", &mut global_config.height, 0.5, 1.0..=50.0);
changed |= add_drag_value_control(
ui,
"Hex Size:",
&mut global_config.hex_size,
1.0,
1.0..=100.0,
);
changed |= add_orientation_control(ui, &mut maze_config.layout.orientation);
changed |= add_position_control(ui, "Start Position:", &mut maze_config.start_pos);
changed |= add_position_control(ui, "End Position:", &mut maze_config.end_pos);
// Handle updates
if changed {
maze_config.update(&global_config);
RespawnMaze {
floor: floor_value,
config: maze_config,
}
.apply(world);
RespawnPlayer.apply(world);
}
}
});
}
fn add_drag_value_control<T: Numeric>(
ui: &mut egui::Ui,
label: &str,
value: &mut T,
speed: f64,
range: RangeInclusive<T>,
) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label(label);
let response = ui.add(DragValue::new(value).speed(speed).range(range));
changed = response.changed();
});
changed
}
fn add_position_control(ui: &mut Ui, label: &str, pos: &mut Hex) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label(label);
let response_q = ui.add(DragValue::new(&mut pos.x).speed(1).prefix("q: "));
let response_r = ui.add(DragValue::new(&mut pos.y).speed(1).prefix("r: "));
changed = response_r.changed() || response_q.changed();
});
changed
}
fn add_seed_control(ui: &mut Ui, seed: &mut u64) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label("Seed:");
let mut seed_text = seed.to_string();
let response = ui.add(
TextEdit::singleline(&mut seed_text)
.desired_width(150.0)
.hint_text("Enter seed"),
);
// Parse text input when changed
if response.changed() {
if let Ok(new_seed) = seed_text.parse::<u64>() {
*seed = new_seed;
changed = true;
}
}
// New random seed button
if ui.button("🎲").clicked() {
*seed = thread_rng().gen();
changed = true;
}
// Copy button
if ui.button("📋").clicked() {
ui.output_mut(|o| o.copied_text = seed.to_string());
}
});
changed
}
fn add_orientation_control(ui: &mut Ui, orientation: &mut HexOrientation) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label("Orientation:");
let response = ui.radio_value(orientation, HexOrientation::Flat, "Flat");
changed |= response.changed();
let response = ui.radio_value(orientation, HexOrientation::Pointy, "Pointy");
changed |= response.changed();
});
changed
}

3
src/dev_tools/ui/mod.rs Normal file
View File

@ -0,0 +1,3 @@
mod maze_controls;
pub use maze_controls::maze_controls_ui;

57
src/floor/components.rs Normal file
View File

@ -0,0 +1,57 @@
use bevy::prelude::*;
#[derive(Debug, Reflect, Component, Deref, DerefMut, PartialEq, Eq, PartialOrd, Ord)]
#[reflect(Component)]
pub struct Floor(pub u8);
#[derive(Debug, Reflect, Component)]
#[reflect(Component)]
#[require(Floor)]
pub struct CurrentFloor;
#[derive(Debug, Reflect, Component, Deref, DerefMut)]
#[reflect(Component)]
#[require(Floor)]
pub struct FloorYTarget(pub f32);
impl Default for Floor {
fn default() -> Self {
Self(1)
}
}
impl Floor {
pub const fn increased(&self) -> Self {
Self(self.0.saturating_add(1))
}
pub fn decreased(&self) -> Self {
Self(self.0.saturating_sub(1).max(1))
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case(0, 1)]
#[case(1, 2)]
#[case(254, 255)]
#[case(255, 255)]
fn increase(#[case] input: u8, #[case] expected: u8) {
let floor = Floor(input);
assert_eq!(*floor.increased(), expected);
}
#[rstest]
#[case(0, 1)] // clamps to 1
#[case(1, 1)] // clamps to 1
#[case(2, 1)]
#[case(255, 254)]
fn decrease(#[case] input: u8, #[case] expected: u8) {
let floor = Floor(input);
assert_eq!(*floor.decreased(), expected);
}
}

45
src/floor/events.rs Normal file
View File

@ -0,0 +1,45 @@
use bevy::prelude::*;
use super::components::Floor;
#[derive(Debug, Clone, Copy, Reflect, Event, Default, PartialEq, Eq)]
pub enum TransitionFloor {
#[default]
Ascend,
Descend,
}
impl TransitionFloor {
pub fn into_direction(&self) -> f32 {
self.into()
}
pub const fn opposite(&self) -> Self {
match self {
Self::Ascend => Self::Descend,
Self::Descend => Self::Ascend,
}
}
pub fn next_floor_num(&self, floor: &Floor) -> u8 {
match self {
Self::Ascend => *floor.increased(),
Self::Descend => *floor.decreased(),
}
}
}
impl From<TransitionFloor> for f32 {
fn from(value: TransitionFloor) -> Self {
Self::from(&value)
}
}
impl From<&TransitionFloor> for f32 {
fn from(value: &TransitionFloor) -> Self {
match value {
TransitionFloor::Ascend => -1., // When ascending, floors move down
TransitionFloor::Descend => 1., // When descending, floors move up
}
}
}

14
src/floor/mod.rs Normal file
View File

@ -0,0 +1,14 @@
pub mod components;
pub mod events;
pub mod resources;
mod systems;
use bevy::prelude::*;
use events::TransitionFloor;
use resources::HighestFloor;
pub(super) fn plugin(app: &mut App) {
app.add_event::<TransitionFloor>()
.insert_resource(HighestFloor(1))
.add_plugins(systems::plugin);
}

5
src/floor/resources.rs Normal file
View File

@ -0,0 +1,5 @@
use bevy::prelude::*;
#[derive(Debug, Default, Reflect, Resource, PartialEq, Eq)]
#[reflect(Resource)]
pub struct HighestFloor(pub u8);

View File

@ -0,0 +1,3 @@
use bevy::prelude::*;
pub const fn despawn_floor(mut _commands: Commands) {}

19
src/floor/systems/hide.rs Normal file
View 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
}
}
}

26
src/floor/systems/mod.rs Normal file
View File

@ -0,0 +1,26 @@
mod despawn;
mod hide;
mod movement;
mod spawn;
use crate::screens::Screen;
use bevy::prelude::*;
use despawn::despawn_floor;
use hide::hide_upper_floors;
use movement::{handle_floor_transition_events, move_floors};
use spawn::spawn_floor;
pub(super) fn plugin(app: &mut App) {
app.add_systems(
Update,
(
spawn_floor,
despawn_floor,
handle_floor_transition_events,
move_floors,
hide_upper_floors,
)
.chain()
.run_if(in_state(Screen::Gameplay)),
);
}

View File

@ -0,0 +1,109 @@
use crate::{
constants::{FLOOR_Y_OFFSET, MOVEMENT_THRESHOLD},
floor::{
components::{CurrentFloor, Floor, FloorYTarget},
events::TransitionFloor,
},
maze::components::{HexMaze, MazeConfig},
player::components::{MovementSpeed, Player},
};
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(
mut commands: Commands,
mut maze_query: Query<
(
Entity,
&mut Transform,
&FloorYTarget,
&MazeConfig,
Has<CurrentFloor>,
),
With<FloorYTarget>,
>,
player_query: Query<&MovementSpeed, With<Player>>,
time: Res<Time>,
) {
let speed = player_query.get_single().map_or(100., |s| s.0);
let movement_distance = speed * time.delta_secs();
for (entity, mut transform, movement_state, config, is_current_floor) in maze_query.iter_mut() {
let delta = movement_state.0 - transform.translation.y;
if delta.abs() > MOVEMENT_THRESHOLD {
let movement = delta.signum() * movement_distance.min(delta.abs());
transform.translation.y += movement;
} else {
transform.translation.y = movement_state.0;
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(
mut commands: Commands,
mut maze_query: Query<(Entity, &Transform, &Floor, Option<&FloorYTarget>), With<HexMaze>>,
current_query: Query<(Entity, &Floor), With<CurrentFloor>>,
mut event_reader: EventReader<TransitionFloor>,
) {
let is_moving = maze_query
.iter()
.any(|(_, _, _, movement_state)| movement_state.is_some());
if is_moving {
return;
}
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();
for (entity, transforms, _, movement_state) in maze_query.iter_mut() {
let target_y = (FLOOR_Y_OFFSET as f32).mul_add(direction, transforms.translation.y);
if movement_state.is_none() {
commands.entity(entity).insert(FloorYTarget(target_y));
}
}
update_current_next_floor(&mut commands, current_entity, target_entity);
break;
}
}
fn update_current_next_floor(commands: &mut Commands, current: Entity, target: Entity) {
commands.entity(current).remove::<CurrentFloor>();
commands.entity(target).insert(CurrentFloor);
}

View File

@ -0,0 +1,38 @@
use bevy::prelude::*;
use crate::{
floor::{
components::{CurrentFloor, Floor, FloorYTarget},
events::TransitionFloor,
resources::HighestFloor,
},
maze::{commands::SpawnMaze, components::MazeConfig},
};
pub fn spawn_floor(
mut commands: Commands,
query: Query<(&mut Floor, &MazeConfig), (With<CurrentFloor>, Without<FloorYTarget>)>,
mut event_reader: EventReader<TransitionFloor>,
mut highest_floor: ResMut<HighestFloor>,
) {
let Ok((current_floor, config)) = query.get_single() else {
return;
};
for event in event_reader.read() {
if current_floor.0 == 1 && *event == TransitionFloor::Descend {
info!("Cannot descend below floor 1");
return;
}
let target_floor = event.next_floor_num(current_floor);
highest_floor.0 = highest_floor.0.max(target_floor);
info!("Creating level for floor {}", target_floor);
commands.queue(SpawnMaze {
floor: target_floor,
config: MazeConfig::from_self(config),
});
}
}

26
src/hint/assets.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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());
}

View File

@ -1,16 +1,25 @@
mod asset_tracking;
pub mod asset_tracking;
pub mod audio;
mod demo;
pub mod camera;
pub mod constants;
#[cfg(feature = "dev")]
mod dev_tools;
mod screens;
mod theme;
pub mod dev_tools;
pub mod floor;
pub mod hint;
pub mod maze;
pub mod player;
pub mod screens;
pub mod stats;
pub mod theme;
use bevy::{
asset::AssetMetaCheck,
audio::{AudioPlugin, Volume},
prelude::*,
};
use camera::spawn_camera;
use constants::TITLE;
use theme::{palette::rose_pine, prelude::ColorScheme};
pub struct AppPlugin;
@ -23,7 +32,7 @@ impl Plugin for AppPlugin {
);
// Spawn the main camera.
app.add_systems(Startup, spawn_camera);
app.add_systems(Startup, (spawn_camera, load_background));
// Add Bevy plugins.
app.add_plugins(
@ -37,7 +46,7 @@ impl Plugin for AppPlugin {
})
.set(WindowPlugin {
primary_window: Window {
title: "The Labyrinth Of Echoes".to_string(),
title: TITLE.to_string(),
canvas: Some("#bevy".to_string()),
fit_canvas_to_parent: true,
prevent_default_event_handling: true,
@ -48,7 +57,7 @@ impl Plugin for AppPlugin {
})
.set(AudioPlugin {
global_volume: GlobalVolume {
volume: Volume::new(0.3),
volume: Volume::new(0.2),
},
..default()
}),
@ -57,9 +66,14 @@ impl Plugin for AppPlugin {
// Add other plugins.
app.add_plugins((
asset_tracking::plugin,
demo::plugin,
screens::plugin,
theme::plugin,
maze::plugin,
floor::plugin,
player::plugin,
hint::plugin,
stats::plugin,
camera::plugin,
));
// Enable dev tools for dev builds.
@ -77,20 +91,11 @@ enum AppSet {
TickTimers,
/// Record player input.
RecordInput,
/// Do everything else (consider splitting this into further variants).
/// Do everything else.
Update,
}
fn spawn_camera(mut commands: Commands) {
commands.spawn((
Name::new("Camera"),
Camera2dBundle::default(),
// 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 load_background(mut commands: Commands) {
let colorcheme = rose_pine::RosePineDawn::Base;
commands.insert_resource(ClearColor(colorcheme.to_color()));
}

View File

@ -2,7 +2,7 @@
#![cfg_attr(not(feature = "dev"), windows_subsystem = "windows")]
use bevy::prelude::*;
use the_labyrinth_of_echoes::AppPlugin;
use maze_ascension::AppPlugin;
fn main() -> AppExit {
App::new().add_plugins(AppPlugin).run()

95
src/maze/assets.rs Normal file
View File

@ -0,0 +1,95 @@
//! 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 crate::{
constants::WALL_OVERLAP_MODIFIER,
theme::{palette::rose_pine::RosePineDawn, prelude::ColorScheme},
};
use bevy::{prelude::*, utils::HashMap};
use std::f32::consts::FRAC_PI_2;
use strum::IntoEnumIterator;
const HEX_SIDES: u32 = 6;
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 {
/// Mesh for hexagonal floor tiles
pub hex_mesh: Handle<Mesh>,
/// Mesh for wall segments
pub wall_mesh: Handle<Mesh>,
/// Default material for hexagonal tiles
pub hex_material: Handle<StandardMaterial>,
/// Default material for walls
pub wall_material: Handle<StandardMaterial>,
/// Custom materials mapped to specific colors from the RosePineDawn palette
pub custom_materials: HashMap<RosePineDawn, Handle<StandardMaterial>>,
}
impl MazeAssets {
/// Creates a new instance of MazeAssets with all necessary meshes and materials.
pub fn new(
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
global_config: &GlobalMazeConfig,
) -> Self {
let custom_materials = RosePineDawn::iter()
.map(|color| (color, materials.add(color.to_standart_material())))
.collect();
Self {
hex_mesh: meshes.add(generate_hex_mesh(
global_config.hex_size,
global_config.height,
)),
wall_mesh: meshes.add(generate_square_mesh(
global_config.hex_size + global_config.wall_size() / WALL_OVERLAP_MODIFIER,
global_config.wall_size(),
)),
hex_material: materials.add(white_material()),
wall_material: materials.add(Color::BLACK),
custom_materials,
}
}
}
/// Generates a hexagonal mesh for floor tiles.
fn generate_hex_mesh(radius: f32, depth: f32) -> Mesh {
let hexagon = RegularPolygon {
sides: HEX_SIDES,
circumcircle: Circle::new(radius),
};
let prism_shape = Extrusion::new(hexagon, depth);
let rotation = Quat::from_rotation_x(FRAC_PI_2);
Mesh::from(prism_shape).rotated_by(rotation)
}
/// Generates a square mesh for wall segments.
fn generate_square_mesh(depth: f32, wall_size: f32) -> Mesh {
let square = Rectangle::new(wall_size, wall_size);
let rectangular_prism = Extrusion::new(square, depth);
let rotation = Quat::from_rotation_x(FRAC_PI_2);
Mesh::from(rectangular_prism).rotated_by(rotation)
}
/// Creates a glowing white material for default tile appearance.
pub fn white_material() -> StandardMaterial {
StandardMaterial {
emissive: LinearRgba::new(
WHITE_EMISSION_INTENSITY,
WHITE_EMISSION_INTENSITY,
WHITE_EMISSION_INTENSITY,
WHITE_EMISSION_INTENSITY,
),
..default()
}
}

49
src/maze/commands.rs Normal file
View 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);
}
}

361
src/maze/components.rs Normal file
View File

@ -0,0 +1,361 @@
//! 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 bevy::prelude::*;
use hexlab::Maze;
use hexx::{Hex, HexLayout, HexOrientation};
use rand::{rngs::StdRng, thread_rng, Rng, SeedableRng};
#[derive(Debug, Reflect, Component)]
#[reflect(Component)]
#[require(MazeConfig, Floor, Maze)]
pub struct HexMaze;
#[derive(Debug, Reflect, Component)]
#[reflect(Component)]
pub struct Tile;
#[derive(Debug, Reflect, Component)]
#[reflect(Component)]
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)]
#[reflect(Component)]
pub struct MazeConfig {
/// Radius of the hexagonal maze
pub radius: u16,
/// Starting position in hex coordinates
pub start_pos: Hex,
/// Ending position in hex coordinates
pub end_pos: Hex,
/// Random seed for maze generation
pub seed: u64,
/// Layout configuration for hex-to-world space conversion
pub layout: HexLayout,
}
impl MazeConfig {
/// Creates a new maze configuration with the specified parameters.
fn new(
radius: u16,
orientation: HexOrientation,
seed: Option<u64>,
global_config: &GlobalMazeConfig,
start_pos: Option<Hex>,
) -> Self {
let (seed, mut rng) = setup_rng(seed);
let start_pos = start_pos.unwrap_or_else(|| generate_pos(radius, &mut rng));
// Generate end position ensuring start and end are different
let end_pos = generate_end_pos(radius, start_pos, &mut rng);
let layout = HexLayout {
orientation,
hex_size: Vec2::splat(global_config.hex_size),
..default()
};
Self {
radius,
start_pos,
end_pos,
seed,
layout,
}
}
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) {
self.layout.hex_size = Vec2::splat(global_conig.hex_size);
}
}
impl Default for MazeConfig {
fn default() -> Self {
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 {
let radius = radius as i32;
loop {
// 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
View 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);
}
}

42
src/maze/errors.rs Normal file
View File

@ -0,0 +1,42 @@
use std::num::TryFromIntError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MazeConfigError {
#[error("Failed to convert radius from u32 to i32: {0}")]
RadiusConverions(#[from] TryFromIntError),
#[error("Invalid maze configuration: {0}")]
InvalidConfig(String),
}
#[derive(Debug, Error)]
pub enum MazeError {
#[error("Floor {0} not found")]
FloorNotFound(u8),
#[error("Failed to generate maze with config: {radius}, seed: {seed}")]
GenerationFailed { radius: u16, seed: u64 },
#[error("Invalid tile entity: {0:?}")]
TileNotFound(bevy::prelude::Entity),
#[error("Failed to create maze assets")]
AssetCreationFailed,
#[error("Invalid maze configuration: {0}")]
ConfigurationError(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
#[derive(Debug, Error)]
pub enum RadiusError {
#[error("Radius cannot be negative: {0}")]
NegativeRadius(i32),
}
impl MazeError {
pub fn config_error(msg: impl Into<String>) -> Self {
Self::ConfigurationError(msg.into())
}
pub const fn generation_failed(radius: u16, seed: u64) -> Self {
Self::GenerationFailed { radius, seed }
}
}

20
src/maze/mod.rs Normal file
View File

@ -0,0 +1,20 @@
mod assets;
pub mod commands;
pub mod components;
pub mod coordinates;
pub mod errors;
pub mod resources;
mod systems;
use bevy::prelude::*;
use commands::SpawnMaze;
pub use resources::GlobalMazeConfig;
pub(super) fn plugin(app: &mut App) {
app.init_resource::<GlobalMazeConfig>()
.add_plugins(systems::plugin);
}
pub fn spawn_level_command(world: &mut World) {
SpawnMaze::default().apply(world);
}

29
src/maze/resources.rs Normal file
View File

@ -0,0 +1,29 @@
use bevy::prelude::*;
#[derive(Debug, Reflect, Resource, Clone)]
#[reflect(Resource)]
pub struct GlobalMazeConfig {
pub hex_size: f32,
pub wall_thickness: f32,
pub height: f32,
}
impl GlobalMazeConfig {
pub fn wall_size(&self) -> f32 {
self.hex_size / 6.
}
pub fn wall_offset(&self) -> f32 {
self.hex_size - self.wall_size()
}
}
impl Default for GlobalMazeConfig {
fn default() -> Self {
Self {
hex_size: 6.,
wall_thickness: 1.,
height: 20.,
}
}
}

View 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))
}

View 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),
}
}

14
src/maze/systems/mod.rs Normal file
View File

@ -0,0 +1,14 @@
pub mod common;
pub mod despawn;
pub mod respawn;
pub mod spawn;
mod toggle_pause;
use bevy::prelude::*;
use toggle_pause::toggle_walls;
use crate::screens::Screen;
pub(super) fn plugin(app: &mut App) {
app.add_systems(Update, toggle_walls.run_if(state_changed::<Screen>));
}

View File

@ -0,0 +1,63 @@
//! 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::{
floor::components::Floor,
maze::{assets::MazeAssets, commands::RespawnMaze, errors::MazeError, GlobalMazeConfig},
};
use bevy::prelude::*;
use hexlab::Maze;
use super::{common::generate_maze, spawn::spawn_maze_tiles};
/// 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 meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut maze_query: Query<(Entity, &Floor, &mut Maze)>,
global_config: Res<GlobalMazeConfig>,
) {
let (entity, _, mut maze) = match maze_query
.iter_mut()
.find(|(_, f, _)| f.0 == floor)
.ok_or(MazeError::FloorNotFound(floor))
{
Ok((entity, floor, maze)) => (entity, floor, maze),
Err(e) => {
warn!("Failed to update floor ({floor}). {e}");
return;
}
};
*maze = match generate_maze(&config) {
Ok(generated_maze) => generated_maze,
Err(e) => {
warn!("Failed to update floor ({floor}). {e}");
return;
}
};
commands.entity(entity).despawn_descendants();
let assets = MazeAssets::new(&mut meshes, &mut materials, &global_config);
spawn_maze_tiles(
&mut commands,
entity,
&maze,
&assets,
&config,
&global_config,
);
commands.entity(entity).insert(config.clone());
}

194
src/maze/systems/spawn.rs Normal file
View File

@ -0,0 +1,194 @@
//! Maze spawning and rendering functionality.
//!
//! Module handles the creation and visualization of hexagonal mazes.
use super::common::generate_maze;
use crate::{
constants::FLOOR_Y_OFFSET,
floor::{
components::{CurrentFloor, Floor},
events::TransitionFloor,
},
maze::{
assets::MazeAssets,
commands::SpawnMaze,
components::{HexMaze, MazeConfig, Tile, Wall},
resources::GlobalMazeConfig,
},
screens::GameplayElement,
theme::palette::rose_pine::RosePineDawn,
};
use bevy::prelude::*;
use hexlab::prelude::{Tile as HexTile, *};
use hexx::HexOrientation;
use std::f32::consts::{FRAC_PI_2, FRAC_PI_3, FRAC_PI_6};
/// Spawns a new maze for the specified floor on [`SpawnMaze`] event.
pub fn spawn_maze(
In(SpawnMaze { floor, config }): In<SpawnMaze>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
maze_query: Query<(Entity, &Floor, &Maze)>,
global_config: Res<GlobalMazeConfig>,
mut event_writer: EventWriter<TransitionFloor>,
) {
if maze_query.iter().any(|(_, f, _)| f.0 == floor) {
info!("Floor {} already exists, skipping creation", floor);
return;
}
let maze = match generate_maze(&config) {
Ok(m) => m,
Err(e) => {
error!("Failed to generate maze for floor {floor}: {:?}", e);
return;
}
};
// Calculate vertical offset based on floor number
let y_offset = match floor {
1 => 0, // Ground/Initial floor (floor 1) is at y=0
_ => FLOOR_Y_OFFSET, // Other floors are offset vertically
} as f32;
let entity = commands
.spawn((
Name::new(format!("Floor {}", floor)),
HexMaze,
maze.clone(),
Floor(floor),
config.clone(),
Transform::from_translation(Vec3::ZERO.with_y(y_offset)),
Visibility::Visible,
GameplayElement,
))
.insert_if(CurrentFloor, || floor == 1) // Only floor 1 gets CurrentFloor
.id();
let assets = MazeAssets::new(&mut meshes, &mut materials, &global_config);
spawn_maze_tiles(
&mut commands,
entity,
&maze,
&assets,
&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(
commands: &mut Commands,
parent_entity: Entity,
maze: &Maze,
assets: &MazeAssets,
maze_config: &MazeConfig,
global_config: &GlobalMazeConfig,
) {
commands.entity(parent_entity).with_children(|parent| {
for tile in maze.values() {
spawn_single_hex_tile(parent, assets, tile, maze_config, global_config);
}
});
}
/// Spawns a single hexagonal tile with appropriate transforms and materials
pub(super) fn spawn_single_hex_tile(
parent: &mut ChildBuilder,
assets: &MazeAssets,
tile: &HexTile,
maze_config: &MazeConfig,
global_config: &GlobalMazeConfig,
) {
let world_pos = tile.to_vec3(&maze_config.layout);
let rotation = match maze_config.layout.orientation {
HexOrientation::Pointy => Quat::from_rotation_y(0.0),
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() {
pos if pos == maze_config.start_pos => assets
.custom_materials
.get(&RosePineDawn::Pine)
.cloned()
.unwrap_or_default(),
pos if pos == maze_config.end_pos => assets
.custom_materials
.get(&RosePineDawn::Love)
.cloned()
.unwrap_or_default(),
_ => assets.hex_material.clone(),
};
parent
.spawn((
Name::new(format!("Hex {}", tile)),
Tile,
Mesh3d(assets.hex_mesh.clone()),
MeshMaterial3d(material),
Transform::from_translation(world_pos).with_rotation(rotation),
))
.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(
parent: &mut ChildBuilder,
assets: &MazeAssets,
walls: &Walls,
global_config: &GlobalMazeConfig,
) {
// Base rotation for wall alignment (90 degrees counter-clockwise)
let z_rotation = Quat::from_rotation_z(-FRAC_PI_2);
let y_offset = global_config.height / 2.;
for i in 0..6 {
if !walls.contains(i) {
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;
// 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 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);
// 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 final_rotation = z_rotation * x_rotation;
spawn_single_wall(parent, assets, final_rotation, pos);
}
}
/// Spawns a single wall segment with the specified rotation and position
fn spawn_single_wall(parent: &mut ChildBuilder, assets: &MazeAssets, rotation: Quat, offset: Vec3) {
parent.spawn((
Name::new("Wall"),
Wall,
Mesh3d(assets.wall_mesh.clone()),
MeshMaterial3d(assets.wall_material.clone()),
Transform::from_translation(offset).with_rotation(rotation),
));
}

View 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,
}
}
}

48
src/player/assets.rs Normal file
View File

@ -0,0 +1,48 @@
use crate::theme::{palette::rose_pine::RosePineDawn, prelude::ColorScheme};
use bevy::prelude::*;
pub(super) fn generate_pill_mesh(radius: f32, half_length: f32) -> Mesh {
Mesh::from(Capsule3d {
radius,
half_length,
})
}
pub(super) fn blue_material() -> StandardMaterial {
let color = RosePineDawn::Pine;
StandardMaterial {
base_color: color.to_color(),
emissive: color.to_linear_rgba() * 3.,
..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
View 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);
}
}

25
src/player/components.rs Normal file
View File

@ -0,0 +1,25 @@
use bevy::prelude::*;
use hexx::Hex;
#[derive(Debug, Reflect, Component)]
#[reflect(Component)]
#[require(CurrentPosition, MovementSpeed, MovementTarget)]
pub struct Player;
#[derive(Debug, Reflect, Component, Deref, DerefMut, Default)]
#[reflect(Component)]
pub struct CurrentPosition(pub Hex);
#[derive(Debug, Reflect, Component, Deref, DerefMut)]
#[reflect(Component)]
pub struct MovementSpeed(pub f32);
impl Default for MovementSpeed {
fn default() -> Self {
Self(100.)
}
}
#[derive(Debug, Reflect, Component, Deref, DerefMut, Default)]
#[reflect(Component)]
pub struct MovementTarget(pub Option<Hex>);

20
src/player/mod.rs Normal file
View File

@ -0,0 +1,20 @@
pub mod assets;
pub mod commands;
pub mod components;
mod systems;
use assets::PlayerAssets;
use bevy::{ecs::system::RunSystemOnce, prelude::*};
use components::Player;
use crate::asset_tracking::LoadResource;
pub(super) fn plugin(app: &mut App) {
app.register_type::<Player>()
.load_resource::<PlayerAssets>()
.add_plugins(systems::plugin);
}
pub fn spawn_player_command(world: &mut World) {
let _ = world.run_system_once(systems::spawn::spawn_player);
}

View 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();
}
}

350
src/player/systems/input.rs Normal file
View File

@ -0,0 +1,350 @@
use crate::{
floor::components::{CurrentFloor, FloorYTarget},
maze::components::MazeConfig,
player::components::{CurrentPosition, MovementTarget, Player},
};
use bevy::prelude::*;
use hexlab::prelude::*;
use hexx::{EdgeDirection, HexOrientation};
/// Handles player movement input based on keyboard controls and maze configuration
pub fn player_input(
input: Res<ButtonInput<KeyCode>>,
mut player_query: Query<(&mut MovementTarget, &CurrentPosition), With<Player>>,
maze_query: Query<(&Maze, &MazeConfig, Has<FloorYTarget>), With<CurrentFloor>>,
) {
let Ok((maze, maze_config, has_y_target)) = maze_query.get_single() else {
return;
};
// Disable movement while transitioning floors
if has_y_target {
return;
}
for (mut target_pos, current_pos) in player_query.iter_mut() {
if target_pos.is_some() {
continue;
}
let Some(tile) = maze.get(current_pos) else {
continue;
};
let Ok(key_direction) = KeyDirection::try_from(&*input) else {
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];
}
}
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);
}
}
}
}
/// Represents possible movement directions from keyboard input
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KeyDirection {
Up, // Single press: W
Right, // Single press: D
Down, // Single press: S
Left, // Single press: A
UpRight, // Diagonal: W+D
UpLeft, // Diagonal: W+A
DownRight, // Diagonal: S+D
DownLeft, // Diagonal: S+A
}
impl KeyDirection {
/// Converts key direction to exact logical direction based on hex orientation
const fn exact_direction(&self, orientation: &HexOrientation) -> Option<LogicalDirection> {
match orientation {
HexOrientation::Pointy => match self {
Self::Up => Some(LogicalDirection::PointyNorth),
Self::Down => Some(LogicalDirection::PointySouth),
Self::UpRight => Some(LogicalDirection::PointyNorthEast),
Self::UpLeft => Some(LogicalDirection::PointyNorthWest),
Self::DownRight => Some(LogicalDirection::PointySouthEast),
Self::DownLeft => Some(LogicalDirection::PointySouthWest),
_ => None,
},
HexOrientation::Flat => match self {
Self::Right => Some(LogicalDirection::FlatEast),
Self::Left => Some(LogicalDirection::FlatWest),
Self::UpRight => Some(LogicalDirection::FlatNorthEast),
Self::UpLeft => Some(LogicalDirection::FlatNorthWest),
Self::DownRight => Some(LogicalDirection::FlatSouthEast),
Self::DownLeft => Some(LogicalDirection::FlatSouthWest),
_ => None,
},
}
}
/// 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));
}
}

36
src/player/systems/mod.rs Normal file
View File

@ -0,0 +1,36 @@
pub mod despawn;
mod input;
mod movement;
pub mod respawn;
mod sound_effect;
pub mod spawn;
mod toggle_pause;
mod vertical_transition;
use crate::{screens::Screen, AppSet};
use bevy::prelude::*;
use input::player_input;
use movement::player_movement;
use sound_effect::play_movement_sound;
use toggle_pause::toggle_player;
use vertical_transition::handle_floor_transition;
use super::assets::PlayerAssets;
pub(super) fn plugin(app: &mut App) {
app.add_systems(
Update,
(
player_input.in_set(AppSet::RecordInput),
player_movement,
handle_floor_transition.in_set(AppSet::RecordInput),
(play_movement_sound)
.chain()
.run_if(resource_exists::<PlayerAssets>)
.in_set(AppSet::Update),
)
.chain()
.run_if(in_state(Screen::Gameplay)),
);
app.add_systems(Update, toggle_player.run_if(state_changed::<Screen>));
}

View File

@ -0,0 +1,87 @@
//! Player movement system and related utilities.
//!
//! This module handles smooth player movement between hexagonal tiles,
//! including position interpolation and movement completion detection.
use crate::{
constants::MOVEMENT_THRESHOLD,
floor::components::CurrentFloor,
maze::components::MazeConfig,
player::components::{CurrentPosition, MovementSpeed, MovementTarget, Player},
};
use bevy::prelude::*;
use hexx::Hex;
/// 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>,
mut query: Query<
(
&mut MovementTarget,
&MovementSpeed,
&mut CurrentPosition,
&mut Transform,
),
With<Player>,
>,
maze_config_query: Query<&MazeConfig, With<CurrentFloor>>,
) {
let Ok(maze_config) = maze_config_query.get_single() else {
warn!("Failed to get maze configuration for current floor - cannot move player");
return;
};
for (mut target, speed, mut current_hex, mut transform) in query.iter_mut() {
if let Some(target_hex) = target.0 {
let current_pos = transform.translation;
let target_pos = calculate_target_position(maze_config, target_hex, current_pos.y);
if should_complete_movement(current_pos, target_pos) {
transform.translation = target_pos;
current_hex.0 = target_hex;
target.0 = None;
continue;
}
update_position(
&mut transform,
current_pos,
target_pos,
speed.0,
time.delta_secs(),
);
}
}
}
/// Determines if the movement should be completed based on proximity to target.
fn should_complete_movement(current_pos: Vec3, target_pos: Vec3) -> bool {
(target_pos - current_pos).length() < MOVEMENT_THRESHOLD
}
/// Updates the player's position based on movement parameters.
fn update_position(
transform: &mut Transform,
current_pos: Vec3,
target_pos: Vec3,
speed: f32,
delta_time: f32,
) {
let direction = target_pos - current_pos;
let movement = direction.normalize() * speed * delta_time;
if movement.length() > direction.length() {
transform.translation = target_pos;
return;
}
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 {
let world_pos = maze_config.layout.hex_to_world_pos(target_hex);
Vec3::new(world_pos.x, y, world_pos.y)
}

View 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);
}

View 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,
));
}
}
}

View File

@ -0,0 +1,38 @@
use crate::{
floor::components::CurrentFloor,
maze::{components::MazeConfig, GlobalMazeConfig},
player::{
assets::{blue_material, generate_pill_mesh},
components::{CurrentPosition, Player},
},
screens::GameplayElement,
};
use bevy::prelude::*;
pub fn spawn_player(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
maze_config_query: Query<&MazeConfig, With<CurrentFloor>>,
global_config: Res<GlobalMazeConfig>,
) {
let Ok(maze_config) = maze_config_query.get_single() else {
return;
};
let player_radius = global_config.hex_size * 0.5;
let player_height = player_radius * 3.5;
let y_offset = global_config.height / 2. + player_height / 1.3;
let start_pos = maze_config.layout.hex_to_world_pos(maze_config.start_pos);
commands.spawn((
Name::new("Player"),
Player,
CurrentPosition(maze_config.start_pos),
Mesh3d(meshes.add(generate_pill_mesh(player_radius, player_height / 2.))),
MeshMaterial3d(materials.add(blue_material())),
Transform::from_xyz(start_pos.x, y_offset, start_pos.y),
GameplayElement,
));
}

View 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,
}
}
}

View File

@ -0,0 +1,50 @@
//! 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::{
floor::{
components::{CurrentFloor, Floor},
events::TransitionFloor,
},
maze::components::MazeConfig,
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(
mut player_query: Query<&CurrentPosition, With<Player>>,
maze_query: Query<(&MazeConfig, &Floor), With<CurrentFloor>>,
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 {
warn!("Failed to get maze configuration for current floor - cannot ascend/descend player.");
return;
};
for current_hex in player_query.iter_mut() {
// Check for ascending (at end position)
if current_hex.0 == config.end_pos {
info!("Ascending");
event_writer.send(TransitionFloor::Ascend);
}
// Check for descending (at start position, not on first floor)
if current_hex.0 == config.start_pos && floor.0 != 1 {
info!("Descending");
event_writer.send(TransitionFloor::Descend);
}
}
}

View File

@ -1,73 +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((
AudioBundle {
source: music.music.clone(),
settings: 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();
}
}

View File

@ -1,67 +1,62 @@
//! 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 crate::{
asset_tracking::LoadResource, audio::Music, demo::level::spawn_level as spawn_level_command,
screens::Screen,
};
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Gameplay), spawn_level);
app.init_resource::<GameplayInitialized>();
app.add_systems(
OnEnter(Screen::Gameplay),
(
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::Gameplay), play_gameplay_music);
app.add_systems(OnExit(Screen::Gameplay), stop_music);
app.add_systems(OnEnter(Screen::Title), reset_gameplay_state);
app.add_systems(
Update,
return_to_title_screen
.run_if(in_state(Screen::Gameplay).and_then(input_just_pressed(KeyCode::Escape))),
pause_game.run_if(in_state(Screen::Gameplay).and(input_just_pressed(KeyCode::Escape))),
);
}
fn spawn_level(mut commands: Commands) {
commands.add(spawn_level_command);
fn pause_game(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Pause);
}
#[derive(Resource, Asset, Reflect, Clone)]
pub struct GameplayMusic {
#[dependency]
handle: Handle<AudioSource>,
entity: Option<Entity>,
fn reset_gameplay_state(mut commands: Commands) {
commands.remove_resource::<GameplayInitialized>();
}
impl FromWorld for GameplayMusic {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
handle: assets.load("audio/music/Fluffing A Duck.ogg"),
entity: None,
#[derive(Debug, Default, Reflect, Resource, DerefMut, Deref)]
#[reflect(Resource)]
pub struct GameplayInitialized(bool);
#[derive(Debug, Reflect, Component)]
#[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();
}
}
}
fn play_gameplay_music(mut commands: Commands, mut music: ResMut<GameplayMusic>) {
music.entity = Some(
commands
.spawn((
AudioBundle {
source: music.handle.clone(),
settings: PlaybackSettings::LOOP,
},
Music,
))
.id(),
);
}
fn stop_music(mut commands: Commands, mut music: ResMut<GameplayMusic>) {
if let Some(entity) = music.entity.take() {
commands.entity(entity).despawn_recursive();
}
}
fn return_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title);
}

View File

@ -4,17 +4,18 @@
use bevy::prelude::*;
use crate::{
demo::player::PlayerAssets,
screens::{credits::CreditsMusic, gameplay::GameplayMusic, Screen},
theme::{interaction::InteractionAssets, prelude::*},
hint::assets::HintAssets,
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(
Update,
continue_to_title_screen.run_if(in_state(Screen::Loading).and_then(all_assets_loaded)),
continue_to_title_screen.run_if(in_state(Screen::Loading).and(all_assets_loaded)),
);
}
@ -22,8 +23,8 @@ fn spawn_loading_screen(mut commands: Commands) {
commands
.ui_root()
.insert(StateScoped(Screen::Loading))
.with_children(|children| {
children.label("Loading...").insert(Style {
.with_children(|parent| {
parent.label("Loading...").insert(Node {
justify_content: JustifyContent::Center,
..default()
});
@ -34,14 +35,10 @@ fn continue_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title);
}
fn all_assets_loaded(
const fn all_assets_loaded(
player_assets: Option<Res<PlayerAssets>>,
interaction_assets: Option<Res<InteractionAssets>>,
credits_music: Option<Res<CreditsMusic>>,
gameplay_music: Option<Res<GameplayMusic>>,
hints_assets: Option<Res<HintAssets>>,
) -> bool {
player_assets.is_some()
&& interaction_assets.is_some()
&& credits_music.is_some()
&& gameplay_music.is_some()
player_assets.is_some() && interaction_assets.is_some() && hints_assets.is_some()
}

View File

@ -1,33 +1,35 @@
//! The game's main screen states and transitions between them.
mod credits;
mod gameplay;
mod loading;
mod pause;
mod splash;
mod title;
use bevy::prelude::*;
pub use gameplay::{GameplayElement, GameplayInitialized};
pub(super) fn plugin(app: &mut App) {
app.init_state::<Screen>();
app.enable_state_scoped_entities::<Screen>();
app.add_plugins((
credits::plugin,
gameplay::plugin,
loading::plugin,
splash::plugin,
title::plugin,
pause::plugin,
));
}
/// The game's main screen states.
#[derive(States, Debug, Hash, PartialEq, Eq, Clone, Default)]
pub enum Screen {
#[default]
#[cfg_attr(not(feature = "dev"), default)]
Splash,
#[cfg_attr(feature = "dev", default)]
Loading,
Title,
Credits,
Gameplay,
Pause,
}

57
src/screens/pause.rs Normal file
View 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);
}

View File

@ -1,14 +1,14 @@
//! A splash screen that plays briefly at startup.
//! A splash screen that plays bdelta_secsriefly at startup.
use bevy::{
image::{ImageLoaderSettings, ImageSampler},
input::common_conditions::input_just_pressed,
prelude::*,
render::texture::{ImageLoaderSettings, ImageSampler},
};
use crate::{screens::Screen, theme::prelude::*, AppSet};
pub(super) fn plugin(app: &mut App) {
pub fn plugin(app: &mut App) {
// Spawn splash screen.
app.insert_resource(ClearColor(SPLASH_BACKGROUND_COLOR));
app.add_systems(OnEnter(Screen::Splash), spawn_splash_screen);
@ -40,7 +40,7 @@ pub(super) fn plugin(app: &mut App) {
app.add_systems(
Update,
continue_to_loading_screen
.run_if(input_just_pressed(KeyCode::Escape).and_then(in_state(Screen::Splash))),
.run_if(input_just_pressed(KeyCode::Escape).and(in_state(Screen::Splash))),
);
}
@ -59,24 +59,21 @@ fn spawn_splash_screen(mut commands: Commands, asset_server: Res<AssetServer>) {
.with_children(|children| {
children.spawn((
Name::new("Splash image"),
ImageBundle {
style: Style {
margin: UiRect::all(Val::Auto),
width: Val::Percent(70.0),
..default()
},
image: UiImage::new(asset_server.load_with_settings(
// This should be an embedded asset for instant loading, but that is
// currently [broken on Windows Wasm builds](https://github.com/bevyengine/bevy/issues/14246).
"images/splash.png",
|settings: &mut ImageLoaderSettings| {
// Make an exception for the splash image in case
// `ImagePlugin::default_nearest()` is used for pixel art.
settings.sampler = ImageSampler::linear();
},
)),
Node {
margin: UiRect::all(Val::Auto),
width: Val::Percent(70.0),
..default()
},
ImageNode::new(asset_server.load_with_settings(
// This should be an embedded asset for instant loading, but that is
// currently [broken on Windows Wasm builds](https://github.com/bevyengine/bevy/issues/14246).
"images/splash.png",
|settings: &mut ImageLoaderSettings| {
// Make an exception for the splash image in case
// `ImagePlugin::default_nearest()` is used for pixel art.
settings.sampler = ImageSampler::linear();
},
)),
UiImageFadeInOut {
total_duration: SPLASH_DURATION_SECS,
fade_duration: SPLASH_FADE_DURATION_SECS,
@ -104,17 +101,17 @@ impl UiImageFadeInOut {
let fade = self.fade_duration / self.total_duration;
// Regular trapezoid-shaped graph, flat at the top with alpha = 1.0.
((1.0 - (2.0 * t - 1.0).abs()) / fade).min(1.0)
((1.0 - 2.0f32.mul_add(t, -1.0).abs()) / fade).min(1.0)
}
}
fn tick_fade_in_out(time: Res<Time>, mut animation_query: Query<&mut UiImageFadeInOut>) {
for mut anim in &mut animation_query {
anim.t += time.delta_seconds();
anim.t += time.delta_secs();
}
}
fn apply_fade_in_out(mut animation_query: Query<(&UiImageFadeInOut, &mut UiImage)>) {
fn apply_fade_in_out(mut animation_query: Query<(&UiImageFadeInOut, &mut ImageNode)>) {
for (anim, mut image) in &mut animation_query {
image.color.set_alpha(anim.alpha())
}

View File

@ -12,12 +12,19 @@ fn spawn_title_screen(mut commands: Commands) {
commands
.ui_root()
.insert(StateScoped(Screen::Title))
.with_children(|children| {
children.button("Play").observe(enter_gameplay_screen);
children.button("Credits").observe(enter_credits_screen);
.with_children(|parent| {
parent
.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"))]
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);
}
fn enter_credits_screen(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Credits);
}
#[cfg(not(target_family = "wasm"))]
fn exit_app(_trigger: Trigger<OnPress>, mut app_exit: EventWriter<AppExit>) {
app_exit.send(AppExit::Success);

21
src/stats/components.rs Normal file
View 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
View 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
View 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
View 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)
}

View 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
}

View 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);
}

View 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
View 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)),
);
}

View 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
View 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");
});
}
}

View 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);
});
}

View 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())
);
}

Some files were not shown because too many files have changed in this diff Show More