Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed77d18e1e | |||
| 5dd932a6fe | |||
| d820b19988 | |||
| f08bd72038 | |||
| 4864ecca93 | |||
| 7fa567d522 | |||
| 7a4bcd81f9 | |||
| 5d50daf768 | |||
| e7bdb37093 | |||
| c8e968e76e | |||
| 099d163325 | |||
| 4398620ac8 | |||
| e1fa12b6b9 | |||
| df4dcdf3cb | |||
| 62a91f5765 | |||
| 48a39d4430 | |||
| 88c46d679d | |||
| d01e987b89 | |||
| 5e1e4a546a | |||
| 58276ea8f7 | |||
| d2dd57bcff | |||
| 472a238a1c | |||
| 2bd115a714 | |||
| 5a7c92cd96 | |||
| 3abf8e2331 | |||
| 4d37a547ff | |||
| 95b173c504 | |||
| e9f02e362a | |||
| 0f4899319d | |||
| 69eacd42d5 | |||
| 5bc87e65a8 | |||
| 2341ee664e | |||
| 7ff943e829 | |||
| a698495c06 | |||
| 68096ee108 | |||
| ef9bb50fba | |||
| 77407f7a90 | |||
| b64930ed9e | |||
| 22193243a1 | |||
| 919f063934 | |||
| a1ed564bad | |||
| 2a12ab8cbe | |||
| 9d68276086 | |||
| a224a74d05 | |||
| cbf3f7c835 | |||
| 07f0cafcf8 | |||
| 399db7605c | |||
| fea57af6d1 | |||
| 6685e3e2c9 | |||
| 29b18d0ed0 | |||
| 3d158a4e7c | |||
| cfaf565891 | |||
| 74836df618 | |||
| b509b128bb | |||
| 285d35d87e | |||
| 35e6420e68 | |||
| 3709bfa58d | |||
| 101626cf3d | |||
| f117dd5e1c | |||
| 1c01feee27 | |||
| 603d0646bf | |||
| ecd98ea1e2 | |||
| db121bffa5 | |||
| 58501cf536 | |||
| e096216806 | |||
| afd863a9be | |||
| 34ca2cfee7 | |||
| 2c3a1a2fff | |||
| a4e819b4b6 | |||
| e15c055f06 | |||
| 4145abda19 | |||
| f2f333b8cf | |||
| 9198560978 | |||
| c587371544 | |||
| c4dcedd723 | |||
| f68c68f167 | |||
| ead980b7fe | |||
| e352b45401 | |||
| 90f5443804 | |||
| 9e3538f571 | |||
| 3b5c92e998 | |||
| 4635b0f134 | |||
| 433a3ce5e8 | |||
| 3659ffa1a6 | |||
| 8683f727f2 | |||
| b966d38e94 | |||
| 45511e4d80 | |||
| a76cbdc02b | |||
| 6c07bc0670 | |||
| f8ea1edd87 | |||
| 86bbee8cb8 | |||
| de4989ccfb | |||
| 269686323f | |||
| 4640862402 | |||
| cec07c9069 | |||
| e65790c743 | |||
| 518077e8fd | |||
| 5c14631e53 | |||
| 3751ef3ee3 | |||
| 3770dcd395 | |||
| 72b16dc8bb | |||
| f4aefb00fa | |||
| b89921dcd6 | |||
| 30d6cf5fba | |||
| 6bdfc2f672 | |||
| 4ac52cfb38 | |||
| 0fb3504b81 | |||
| 6f0f8471c5 | |||
| c694281e88 | |||
| 80bc027477 | |||
| 995cc56e28 | |||
| b89556679c | |||
| 3a7f8b6401 | |||
| dfb653898f | |||
| 8ef2db1d48 | |||
| 1a0a859fec | |||
| 9ecb38b442 | |||
| dca6747f83 | |||
| 24b92a24cc | |||
| 0ca94082a9 | |||
| 34f85be4ef | |||
| 41a6059912 | |||
| 42ac091748 | |||
| 468c5bfe4a | |||
| 5eefa234e1 | |||
| 8f454cb2c6 | |||
| 1aa1bd1c41 | |||
| 00c90916e5 | |||
| 07794aa993 | |||
| c471382f6f | |||
| 1a8eeb97b5 | |||
| 623b53c34f | |||
| ec9ac21b8f | |||
| c5f8dede6d | |||
| f16fd51090 | |||
| 0ee94c826a | |||
| f11b701ec3 | |||
| 869d11e810 | |||
| 5929f255a8 | |||
| 590c293dc2 | |||
| 7db07f7e4d | |||
| 846b2326a3 | |||
| 0a64641941 | |||
| 3f0ba6d0d8 | |||
| 70579168a2 |
4
.github/workflows/ci.yaml
vendored
@ -27,9 +27,7 @@ jobs:
|
|||||||
sweep-cache: true
|
sweep-cache: true
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: |
|
||||||
cargo test --locked --workspace --all-features --all-targets
|
cargo test --locked --workspace --no-default-features
|
||||||
# Workaround for https://github.com/rust-lang/cargo/issues/6669
|
|
||||||
cargo test --locked --workspace --all-features --doc
|
|
||||||
# Run clippy lints.
|
# Run clippy lints.
|
||||||
clippy:
|
clippy:
|
||||||
name: Clippy
|
name: Clippy
|
||||||
|
|||||||
14
.github/workflows/release.yaml
vendored
@ -15,14 +15,14 @@ on:
|
|||||||
# Configure the release workflow by editing these values.
|
# Configure the release workflow by editing these values.
|
||||||
env:
|
env:
|
||||||
# The base filename of the binary produced by `cargo build`.
|
# 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.
|
# The path to the assets directory.
|
||||||
assets_path: assets
|
assets_path: assets
|
||||||
# Whether to upload the packages produced by this workflow to a GitHub release.
|
# Whether to upload the packages produced by this workflow to a GitHub release.
|
||||||
upload_to_github: true
|
upload_to_github: true
|
||||||
# The itch.io project to upload to in the format `user-name/project-name`.
|
# 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.
|
# 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 #
|
# ADVANCED #
|
||||||
############
|
############
|
||||||
@ -30,26 +30,26 @@ env:
|
|||||||
# The ID of the app produced by this workflow.
|
# The ID of the app produced by this workflow.
|
||||||
# Applies to macOS releases.
|
# 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
|
# 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.
|
# The base filename of the binary in the package produced by this workflow.
|
||||||
# Applies to Windows, macOS, and Linux releases.
|
# Applies to Windows, macOS, and Linux releases.
|
||||||
# Defaults to `cargo_build_binary_name` if commented out.
|
# 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.
|
# The name of the `.zip` or `.dmg` file produced by this workflow.
|
||||||
# Defaults to `app_binary_name` if commented out.
|
# 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.
|
# The display name of the app produced by this workflow.
|
||||||
# Applies to macOS releases.
|
# Applies to macOS releases.
|
||||||
# Defaults to `app_package_name` if commented out.
|
# 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.
|
# The short display name of the app produced by this workflow.
|
||||||
# Applies to macOS releases.
|
# Applies to macOS releases.
|
||||||
# Must be 15 or fewer characters: https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundlename
|
# Must be 15 or fewer characters: https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundlename
|
||||||
# Defaults to `app_display_name` if commented out.
|
# 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:
|
# 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
|
# https://docs.github.com/en/repositories/working-with-files/managing-large-files/about-storage-and-bandwidth-usage
|
||||||
|
|||||||
2526
Cargo.lock
generated
26
Cargo.toml
@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "the-labyrinth-of-echoes"
|
name = "maze-ascension"
|
||||||
authors = ["Kristofers Solo <dev@kristofers.xyz>"]
|
authors = ["Kristofers Solo <dev@kristofers.xyz>"]
|
||||||
version = "0.0.1"
|
version = "1.1.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bevy = { version = "0.14", features = ["wayland"] }
|
bevy = { version = "0.15", features = ["wayland"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
# Compile low-severity logs out of native builds for performance.
|
# Compile low-severity logs out of native builds for performance.
|
||||||
log = { version = "0.4", features = [
|
log = { version = "0.4", features = [
|
||||||
@ -17,6 +17,22 @@ tracing = { version = "0.1", features = [
|
|||||||
"max_level_debug",
|
"max_level_debug",
|
||||||
"release_max_level_warn",
|
"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]
|
[features]
|
||||||
default = [
|
default = [
|
||||||
@ -27,6 +43,8 @@ dev = [
|
|||||||
# Improve compile times for dev builds by linking Bevy as a dynamic library.
|
# Improve compile times for dev builds by linking Bevy as a dynamic library.
|
||||||
"bevy/dynamic_linking",
|
"bevy/dynamic_linking",
|
||||||
"bevy/bevy_dev_tools",
|
"bevy/bevy_dev_tools",
|
||||||
|
"dep:bevy-inspector-egui",
|
||||||
|
"dep:bevy_egui",
|
||||||
]
|
]
|
||||||
dev_native = [
|
dev_native = [
|
||||||
"dev",
|
"dev",
|
||||||
@ -45,6 +63,8 @@ dev_native = [
|
|||||||
too_many_arguments = "allow"
|
too_many_arguments = "allow"
|
||||||
# Queries that access many components may trigger this lint.
|
# Queries that access many components may trigger this lint.
|
||||||
type_complexity = "allow"
|
type_complexity = "allow"
|
||||||
|
nursery = { level = "warn", priority = -1 }
|
||||||
|
unwrap_used = "warn"
|
||||||
|
|
||||||
|
|
||||||
# Compile with Performance Optimizations:
|
# Compile with Performance Optimizations:
|
||||||
|
|||||||
131
QUICKSTART.md
@ -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`:
|
|
||||||
|
|
||||||

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

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

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

|
|
||||||
33
justfile
Normal 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
|
||||||
@ -1,8 +1,7 @@
|
|||||||
//! A high-level way to load collections of asset handles as resources.
|
//! A high-level way to load collections of asset handles as resources.
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
pub(super) fn plugin(app: &mut App) {
|
||||||
app.init_resource::<ResourceHandles>();
|
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, mut resource_handles: Mut<ResourceHandles>| {
|
||||||
world.resource_scope(|world, assets: Mut<AssetServer>| {
|
world.resource_scope(|world, assets: Mut<AssetServer>| {
|
||||||
for _ in 0..resource_handles.waiting.len() {
|
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) {
|
if assets.is_loaded_with_dependencies(&handle) {
|
||||||
insert_fn(world, &handle);
|
insert_fn(world, &handle);
|
||||||
resource_handles.finished.push(handle);
|
resource_handles.finished.push(handle);
|
||||||
|
|||||||
@ -7,7 +7,7 @@ use bevy::prelude::*;
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// 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>>) {
|
/// fn set_music_volume(sink_query: Query<&AudioSink, With<Music>>) {
|
||||||
/// for sink in &sink_query {
|
/// for sink in &sink_query {
|
||||||
@ -25,7 +25,7 @@ pub struct Music;
|
|||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use bevy::prelude::*;
|
/// 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>>) {
|
/// fn set_sound_effect_volume(sink_query: Query<&AudioSink, With<SoundEffect>>) {
|
||||||
/// for sink in &sink_query {
|
/// for sink in &sink_query {
|
||||||
|
|||||||
64
src/camera.rs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
use bevy::{input::mouse::MouseWheel, prelude::*};
|
||||||
|
|
||||||
|
use crate::constants::{BASE_ZOOM_SPEED, MAX_ZOOM, MIN_ZOOM, SCROLL_MODIFIER};
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.add_systems(Update, camera_zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct MainCamera;
|
||||||
|
|
||||||
|
pub fn spawn_camera(mut commands: Commands) {
|
||||||
|
commands.spawn((
|
||||||
|
Name::new("Camera"),
|
||||||
|
MainCamera,
|
||||||
|
Camera3d::default(),
|
||||||
|
Transform::from_xyz(200., 200., 0.).looking_at(Vec3::ZERO, Vec3::Y),
|
||||||
|
// Render all UI to this camera.
|
||||||
|
// Not strictly necessary since we only use one camera,
|
||||||
|
// but if we don't use this component, our UI will disappear as soon
|
||||||
|
// as we add another camera. This includes indirect ways of adding cameras like using
|
||||||
|
// [ui node outlines](https://bevyengine.org/news/bevy-0-14/#ui-node-outline-gizmos)
|
||||||
|
// for debugging. So it's good to have this here for future-proofing.
|
||||||
|
IsDefaultUiCamera,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn camera_zoom(
|
||||||
|
mut query: Query<&mut Transform, With<MainCamera>>,
|
||||||
|
mut scrool_evr: EventReader<MouseWheel>,
|
||||||
|
keyboard: Res<ButtonInput<KeyCode>>,
|
||||||
|
time: Res<Time>,
|
||||||
|
) {
|
||||||
|
let Ok(mut transform) = query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_distance = transform.translation.length();
|
||||||
|
|
||||||
|
// Calculate zoom speed based on distance
|
||||||
|
let distance_multiplier = (current_distance / MIN_ZOOM).sqrt();
|
||||||
|
let adjusted_zoom_speed = BASE_ZOOM_SPEED * distance_multiplier;
|
||||||
|
|
||||||
|
let mut zoom_delta = 0.0;
|
||||||
|
|
||||||
|
if keyboard.pressed(KeyCode::Equal) || keyboard.pressed(KeyCode::NumpadAdd) {
|
||||||
|
zoom_delta += adjusted_zoom_speed * time.delta_secs() * 25.;
|
||||||
|
}
|
||||||
|
|
||||||
|
if keyboard.pressed(KeyCode::Minus) || keyboard.pressed(KeyCode::NumpadSubtract) {
|
||||||
|
zoom_delta -= adjusted_zoom_speed * time.delta_secs() * 25.;
|
||||||
|
}
|
||||||
|
|
||||||
|
for ev in scrool_evr.read() {
|
||||||
|
zoom_delta += ev.y * adjusted_zoom_speed * SCROLL_MODIFIER;
|
||||||
|
}
|
||||||
|
|
||||||
|
if zoom_delta != 0.0 {
|
||||||
|
let forward = transform.translation.normalize();
|
||||||
|
let new_distance = (current_distance - zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM);
|
||||||
|
transform.translation = forward * new_distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/constants.rs
Normal 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;
|
||||||
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
@ -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();
|
||||||
|
}
|
||||||
166
src/dev_tools/ui/maze_controls.rs
Normal 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
@ -0,0 +1,3 @@
|
|||||||
|
mod maze_controls;
|
||||||
|
|
||||||
|
pub use maze_controls::maze_controls_ui;
|
||||||
57
src/floor/components.rs
Normal 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
@ -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
@ -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
@ -0,0 +1,5 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Reflect, Resource, PartialEq, Eq)]
|
||||||
|
#[reflect(Resource)]
|
||||||
|
pub struct HighestFloor(pub u8);
|
||||||
3
src/floor/systems/despawn.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
pub const fn despawn_floor(mut _commands: Commands) {}
|
||||||
19
src/floor/systems/hide.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::floor::components::{CurrentFloor, Floor};
|
||||||
|
|
||||||
|
pub fn hide_upper_floors(
|
||||||
|
mut query: Query<(&mut Visibility, &Floor)>,
|
||||||
|
current_query: Query<&Floor, With<CurrentFloor>>,
|
||||||
|
) {
|
||||||
|
let Ok(current_floor) = current_query.get_single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (mut visibility, floor) in query.iter_mut() {
|
||||||
|
if floor > current_floor {
|
||||||
|
*visibility = Visibility::Hidden
|
||||||
|
} else {
|
||||||
|
*visibility = Visibility::Visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/floor/systems/mod.rs
Normal 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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/floor/systems/movement.rs
Normal 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);
|
||||||
|
}
|
||||||
38
src/floor/systems/spawn.rs
Normal 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
@ -0,0 +1,26 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Resource, Asset, Reflect, Clone)]
|
||||||
|
#[reflect(Resource)]
|
||||||
|
pub struct HintAssets {
|
||||||
|
#[dependency]
|
||||||
|
pub arrows: Handle<Image>,
|
||||||
|
#[dependency]
|
||||||
|
pub interaction: Handle<Image>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HintAssets {
|
||||||
|
pub const PATH_ARROWS: &str = "images/hints/arrows.png";
|
||||||
|
pub const PATH_INTERACTION: &str = "images/hints/interaction.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromWorld for HintAssets {
|
||||||
|
fn from_world(world: &mut World) -> Self {
|
||||||
|
let assets = world.resource::<AssetServer>();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
arrows: assets.load(Self::PATH_ARROWS),
|
||||||
|
interaction: assets.load(Self::PATH_INTERACTION),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/hint/components.rs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect, Component, PartialEq, Eq)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub enum Hint {
|
||||||
|
Movement,
|
||||||
|
Interaction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct IdleTimer {
|
||||||
|
pub timer: Timer,
|
||||||
|
pub movement_hint_visible: bool,
|
||||||
|
pub interaction_hint_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdleTimer {
|
||||||
|
pub fn hide_all(&mut self) {
|
||||||
|
self.movement_hint_visible = false;
|
||||||
|
self.interaction_hint_visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for IdleTimer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timer: Timer::new(Duration::from_secs(3), TimerMode::Once),
|
||||||
|
movement_hint_visible: false,
|
||||||
|
interaction_hint_visible: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/hint/mod.rs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
pub mod assets;
|
||||||
|
pub mod components;
|
||||||
|
mod systems;
|
||||||
|
|
||||||
|
use bevy::{ecs::system::RunSystemOnce, prelude::*};
|
||||||
|
use components::IdleTimer;
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.register_type::<IdleTimer>()
|
||||||
|
.add_plugins(systems::plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_hint_command(world: &mut World) {
|
||||||
|
let _ = world.run_system_once(systems::spawn::spawn_hints);
|
||||||
|
}
|
||||||
86
src/hint/systems/check.rs
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
use hexx::Hex;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
floor::components::{CurrentFloor, Floor, FloorYTarget},
|
||||||
|
hint::components::{Hint, IdleTimer},
|
||||||
|
maze::components::MazeConfig,
|
||||||
|
player::components::{CurrentPosition, MovementTarget, Player},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn check_player_hints(
|
||||||
|
mut idle_query: Query<&mut IdleTimer>,
|
||||||
|
player_query: Query<(&CurrentPosition, &MovementTarget), With<Player>>,
|
||||||
|
tranitioning: Query<Has<FloorYTarget>>,
|
||||||
|
maze_query: Query<(&MazeConfig, &Floor), With<CurrentFloor>>,
|
||||||
|
mut hint_query: Query<(&mut Visibility, &Hint)>,
|
||||||
|
time: Res<Time>,
|
||||||
|
) {
|
||||||
|
let Ok(mut idle_timer) = idle_query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok((maze_config, floor)) = maze_query.get_single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok((player_pos, movement_target)) = player_query.get_single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_moving = movement_target.is_some() || tranitioning.iter().any(|x| x);
|
||||||
|
|
||||||
|
if is_moving {
|
||||||
|
// Reset timer and hide hints when player moves
|
||||||
|
idle_timer.timer.reset();
|
||||||
|
hide_all_hints(&mut hint_query, &mut idle_timer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick timer when player is idle
|
||||||
|
idle_timer.timer.tick(time.delta());
|
||||||
|
|
||||||
|
if idle_timer.timer.finished() {
|
||||||
|
let on_special_tile = is_on_special_tile(player_pos, maze_config, floor.0);
|
||||||
|
|
||||||
|
if !idle_timer.movement_hint_visible {
|
||||||
|
set_hint_visibility(&mut hint_query, Hint::Movement, true);
|
||||||
|
idle_timer.movement_hint_visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if on_special_tile && !idle_timer.interaction_hint_visible {
|
||||||
|
set_hint_visibility(&mut hint_query, Hint::Interaction, true);
|
||||||
|
idle_timer.interaction_hint_visible = true;
|
||||||
|
} else if !on_special_tile && idle_timer.interaction_hint_visible {
|
||||||
|
set_hint_visibility(&mut hint_query, Hint::Interaction, false);
|
||||||
|
idle_timer.interaction_hint_visible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_all_hints(hint_query: &mut Query<(&mut Visibility, &Hint)>, idle_timer: &mut IdleTimer) {
|
||||||
|
for (mut visibility, _) in hint_query.iter_mut() {
|
||||||
|
*visibility = Visibility::Hidden;
|
||||||
|
}
|
||||||
|
idle_timer.hide_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_hint_visibility(
|
||||||
|
hint_query: &mut Query<(&mut Visibility, &Hint)>,
|
||||||
|
hint: Hint,
|
||||||
|
visible: bool,
|
||||||
|
) {
|
||||||
|
for (mut visibility, hint_type) in hint_query.iter_mut() {
|
||||||
|
if *hint_type == hint {
|
||||||
|
*visibility = if visible {
|
||||||
|
Visibility::Visible
|
||||||
|
} else {
|
||||||
|
Visibility::Hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_on_special_tile(player_pos: &Hex, maze_config: &MazeConfig, floor: u8) -> bool {
|
||||||
|
(*player_pos == maze_config.start_pos && floor != 1) || *player_pos == maze_config.end_pos
|
||||||
|
}
|
||||||
16
src/hint/systems/mod.rs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
mod check;
|
||||||
|
pub mod spawn;
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use check::check_player_hints;
|
||||||
|
|
||||||
|
use super::assets::HintAssets;
|
||||||
|
use crate::{asset_tracking::LoadResource, screens::Screen};
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.load_resource::<HintAssets>();
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
check_player_hints.run_if(in_state(Screen::Gameplay)),
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/hint/systems/spawn.rs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
use bevy::{prelude::*, ui::Val::*};
|
||||||
|
|
||||||
|
use crate::hint::{
|
||||||
|
assets::HintAssets,
|
||||||
|
components::{Hint, IdleTimer},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn spawn_hints(mut commands: Commands, hint_assets: Res<HintAssets>) {
|
||||||
|
commands.spawn((
|
||||||
|
Name::new("Movement hint"),
|
||||||
|
Hint::Movement,
|
||||||
|
Visibility::Hidden,
|
||||||
|
ImageNode {
|
||||||
|
image: hint_assets.arrows.clone(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: Px(20.0),
|
||||||
|
bottom: Px(20.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
commands.spawn((
|
||||||
|
Name::new("Interaction hint"),
|
||||||
|
Hint::Interaction,
|
||||||
|
Visibility::Hidden,
|
||||||
|
ImageNode {
|
||||||
|
image: hint_assets.interaction.clone(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: Px(20.0),
|
||||||
|
bottom: Px(168.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
// Add idle timer
|
||||||
|
commands.spawn(IdleTimer::default());
|
||||||
|
}
|
||||||
49
src/lib.rs
@ -1,16 +1,25 @@
|
|||||||
mod asset_tracking;
|
pub mod asset_tracking;
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
mod demo;
|
pub mod camera;
|
||||||
|
pub mod constants;
|
||||||
#[cfg(feature = "dev")]
|
#[cfg(feature = "dev")]
|
||||||
mod dev_tools;
|
pub mod dev_tools;
|
||||||
mod screens;
|
pub mod floor;
|
||||||
mod theme;
|
pub mod hint;
|
||||||
|
pub mod maze;
|
||||||
|
pub mod player;
|
||||||
|
pub mod screens;
|
||||||
|
pub mod stats;
|
||||||
|
pub mod theme;
|
||||||
|
|
||||||
use bevy::{
|
use bevy::{
|
||||||
asset::AssetMetaCheck,
|
asset::AssetMetaCheck,
|
||||||
audio::{AudioPlugin, Volume},
|
audio::{AudioPlugin, Volume},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
};
|
};
|
||||||
|
use camera::spawn_camera;
|
||||||
|
use constants::TITLE;
|
||||||
|
use theme::{palette::rose_pine, prelude::ColorScheme};
|
||||||
|
|
||||||
pub struct AppPlugin;
|
pub struct AppPlugin;
|
||||||
|
|
||||||
@ -23,7 +32,7 @@ impl Plugin for AppPlugin {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Spawn the main camera.
|
// Spawn the main camera.
|
||||||
app.add_systems(Startup, spawn_camera);
|
app.add_systems(Startup, (spawn_camera, load_background));
|
||||||
|
|
||||||
// Add Bevy plugins.
|
// Add Bevy plugins.
|
||||||
app.add_plugins(
|
app.add_plugins(
|
||||||
@ -37,7 +46,7 @@ impl Plugin for AppPlugin {
|
|||||||
})
|
})
|
||||||
.set(WindowPlugin {
|
.set(WindowPlugin {
|
||||||
primary_window: Window {
|
primary_window: Window {
|
||||||
title: "The Labyrinth Of Echoes".to_string(),
|
title: TITLE.to_string(),
|
||||||
canvas: Some("#bevy".to_string()),
|
canvas: Some("#bevy".to_string()),
|
||||||
fit_canvas_to_parent: true,
|
fit_canvas_to_parent: true,
|
||||||
prevent_default_event_handling: true,
|
prevent_default_event_handling: true,
|
||||||
@ -48,7 +57,7 @@ impl Plugin for AppPlugin {
|
|||||||
})
|
})
|
||||||
.set(AudioPlugin {
|
.set(AudioPlugin {
|
||||||
global_volume: GlobalVolume {
|
global_volume: GlobalVolume {
|
||||||
volume: Volume::new(0.3),
|
volume: Volume::new(0.2),
|
||||||
},
|
},
|
||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
@ -57,9 +66,14 @@ impl Plugin for AppPlugin {
|
|||||||
// Add other plugins.
|
// Add other plugins.
|
||||||
app.add_plugins((
|
app.add_plugins((
|
||||||
asset_tracking::plugin,
|
asset_tracking::plugin,
|
||||||
demo::plugin,
|
|
||||||
screens::plugin,
|
screens::plugin,
|
||||||
theme::plugin,
|
theme::plugin,
|
||||||
|
maze::plugin,
|
||||||
|
floor::plugin,
|
||||||
|
player::plugin,
|
||||||
|
hint::plugin,
|
||||||
|
stats::plugin,
|
||||||
|
camera::plugin,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Enable dev tools for dev builds.
|
// Enable dev tools for dev builds.
|
||||||
@ -77,20 +91,11 @@ enum AppSet {
|
|||||||
TickTimers,
|
TickTimers,
|
||||||
/// Record player input.
|
/// Record player input.
|
||||||
RecordInput,
|
RecordInput,
|
||||||
/// Do everything else (consider splitting this into further variants).
|
/// Do everything else.
|
||||||
Update,
|
Update,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_camera(mut commands: Commands) {
|
fn load_background(mut commands: Commands) {
|
||||||
commands.spawn((
|
let colorcheme = rose_pine::RosePineDawn::Base;
|
||||||
Name::new("Camera"),
|
commands.insert_resource(ClearColor(colorcheme.to_color()));
|
||||||
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,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
#![cfg_attr(not(feature = "dev"), windows_subsystem = "windows")]
|
#![cfg_attr(not(feature = "dev"), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use the_labyrinth_of_echoes::AppPlugin;
|
use maze_ascension::AppPlugin;
|
||||||
|
|
||||||
fn main() -> AppExit {
|
fn main() -> AppExit {
|
||||||
App::new().add_plugins(AppPlugin).run()
|
App::new().add_plugins(AppPlugin).run()
|
||||||
|
|||||||
95
src/maze/assets.rs
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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.,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/maze/systems/common.rs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
//! Common maze generation utilities.
|
||||||
|
use crate::maze::{components::MazeConfig, errors::MazeError};
|
||||||
|
use hexlab::prelude::*;
|
||||||
|
|
||||||
|
/// Generates a new maze based on the provided configuration.
|
||||||
|
///
|
||||||
|
/// This function uses a recursive backtracking algorithm to generate
|
||||||
|
/// a hexagonal maze with the specified parameters.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// - `config` - Configuration parameters for maze generation including radius and seed.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// `Result<Maze, MazeError>` - The generated maze or an error if generation fails.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns `MazeError::GenerationFailed` if:
|
||||||
|
/// - The maze builder fails to create a valid maze
|
||||||
|
/// - The provided radius or seed results in an invalid configuration
|
||||||
|
pub fn generate_maze(config: &MazeConfig) -> Result<Maze, MazeError> {
|
||||||
|
MazeBuilder::new()
|
||||||
|
.with_radius(config.radius)
|
||||||
|
.with_seed(config.seed)
|
||||||
|
.with_generator(GeneratorType::RecursiveBacktracking)
|
||||||
|
.build()
|
||||||
|
.map_err(|_| MazeError::generation_failed(config.radius, config.seed))
|
||||||
|
}
|
||||||
19
src/maze/systems/despawn.rs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
//! Maze despawning functionality.
|
||||||
|
//!
|
||||||
|
//! Module handles the cleanup of maze entities when they need to be removed,
|
||||||
|
//! ensuring proper cleanup of both the maze and all its child entities.
|
||||||
|
use crate::{floor::components::Floor, maze::commands::DespawnMaze};
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
/// Despawns a maze and all its associated entities for a given floor.
|
||||||
|
pub fn despawn_maze(
|
||||||
|
In(DespawnMaze { floor }): In<DespawnMaze>,
|
||||||
|
mut commands: Commands,
|
||||||
|
query: Query<(Entity, &Floor)>,
|
||||||
|
) {
|
||||||
|
match query.iter().find(|(_, f)| f.0 == floor) {
|
||||||
|
Some((entity, _)) => commands.entity(entity).despawn_recursive(),
|
||||||
|
_ => warn!("Floor {} not found for removal", floor),
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/maze/systems/mod.rs
Normal 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>));
|
||||||
|
}
|
||||||
63
src/maze/systems/respawn.rs
Normal 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
@ -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),
|
||||||
|
));
|
||||||
|
}
|
||||||
13
src/maze/systems/toggle_pause.rs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{maze::components::Wall, screens::Screen};
|
||||||
|
|
||||||
|
pub fn toggle_walls(mut query: Query<&mut Visibility, With<Wall>>, state: Res<State<Screen>>) {
|
||||||
|
for mut visibility in query.iter_mut() {
|
||||||
|
*visibility = match *state.get() {
|
||||||
|
Screen::Gameplay => Visibility::Inherited,
|
||||||
|
Screen::Pause => Visibility::Hidden,
|
||||||
|
_ => *visibility,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/player/assets.rs
Normal 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
@ -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
@ -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
@ -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);
|
||||||
|
}
|
||||||
8
src/player/systems/despawn.rs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
use crate::player::components::Player;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
pub fn despawn_players(mut commands: Commands, query: Query<Entity, With<Player>>) {
|
||||||
|
for entity in query.iter() {
|
||||||
|
commands.entity(entity).despawn_recursive();
|
||||||
|
}
|
||||||
|
}
|
||||||
350
src/player/systems/input.rs
Normal 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
@ -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>));
|
||||||
|
}
|
||||||
87
src/player/systems/movement.rs
Normal 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)
|
||||||
|
}
|
||||||
7
src/player/systems/respawn.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
use crate::player::commands::{DespawnPlayer, SpawnPlayer};
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
pub fn respawn_player(mut commands: Commands) {
|
||||||
|
commands.queue(DespawnPlayer);
|
||||||
|
commands.queue(SpawnPlayer);
|
||||||
|
}
|
||||||
31
src/player/systems/sound_effect.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
use crate::{
|
||||||
|
audio::SoundEffect,
|
||||||
|
player::{
|
||||||
|
assets::PlayerAssets,
|
||||||
|
components::{MovementTarget, Player},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
|
|
||||||
|
pub fn play_movement_sound(
|
||||||
|
mut commands: Commands,
|
||||||
|
player_assets: Res<PlayerAssets>,
|
||||||
|
moving_players: Query<&MovementTarget, (Changed<MovementTarget>, With<Player>)>,
|
||||||
|
) {
|
||||||
|
for movement_target in moving_players.iter() {
|
||||||
|
if movement_target.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rng = &mut rand::thread_rng();
|
||||||
|
if let Some(random_step) = player_assets.steps.choose(rng) {
|
||||||
|
commands.spawn((
|
||||||
|
AudioPlayer(random_step.clone()),
|
||||||
|
PlaybackSettings::DESPAWN,
|
||||||
|
SoundEffect,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/player/systems/spawn.rs
Normal 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
13
src/player/systems/toggle_pause.rs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{player::components::Player, screens::Screen};
|
||||||
|
|
||||||
|
pub fn toggle_player(mut query: Query<&mut Visibility, With<Player>>, state: Res<State<Screen>>) {
|
||||||
|
for mut visibility in query.iter_mut() {
|
||||||
|
*visibility = match *state.get() {
|
||||||
|
Screen::Gameplay => Visibility::Visible,
|
||||||
|
Screen::Pause => Visibility::Hidden,
|
||||||
|
_ => *visibility,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/player/systems/vertical_transition.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,67 +1,62 @@
|
|||||||
//! The screen state for the main gameplay.
|
//! The screen state for the main gameplay.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
hint::spawn_hint_command, maze::spawn_level_command, player::spawn_player_command,
|
||||||
|
screens::Screen, stats::spawn_stats_command,
|
||||||
|
};
|
||||||
|
|
||||||
use bevy::{input::common_conditions::input_just_pressed, prelude::*};
|
use bevy::{input::common_conditions::input_just_pressed, prelude::*};
|
||||||
|
|
||||||
use crate::{
|
|
||||||
asset_tracking::LoadResource, audio::Music, demo::level::spawn_level as spawn_level_command,
|
|
||||||
screens::Screen,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
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::Title), reset_gameplay_state);
|
||||||
app.add_systems(OnEnter(Screen::Gameplay), play_gameplay_music);
|
|
||||||
app.add_systems(OnExit(Screen::Gameplay), stop_music);
|
|
||||||
|
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
Update,
|
||||||
return_to_title_screen
|
pause_game.run_if(in_state(Screen::Gameplay).and(input_just_pressed(KeyCode::Escape))),
|
||||||
.run_if(in_state(Screen::Gameplay).and_then(input_just_pressed(KeyCode::Escape))),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_level(mut commands: Commands) {
|
fn pause_game(mut next_screen: ResMut<NextState<Screen>>) {
|
||||||
commands.add(spawn_level_command);
|
next_screen.set(Screen::Pause);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Resource, Asset, Reflect, Clone)]
|
fn reset_gameplay_state(mut commands: Commands) {
|
||||||
pub struct GameplayMusic {
|
commands.remove_resource::<GameplayInitialized>();
|
||||||
#[dependency]
|
|
||||||
handle: Handle<AudioSource>,
|
|
||||||
entity: Option<Entity>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromWorld for GameplayMusic {
|
#[derive(Debug, Default, Reflect, Resource, DerefMut, Deref)]
|
||||||
fn from_world(world: &mut World) -> Self {
|
#[reflect(Resource)]
|
||||||
let assets = world.resource::<AssetServer>();
|
pub struct GameplayInitialized(bool);
|
||||||
Self {
|
|
||||||
handle: assets.load("audio/music/Fluffing A Duck.ogg"),
|
|
||||||
entity: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn play_gameplay_music(mut commands: Commands, mut music: ResMut<GameplayMusic>) {
|
#[derive(Debug, Reflect, Component)]
|
||||||
music.entity = Some(
|
#[reflect(Component)]
|
||||||
commands
|
pub struct GameplayElement;
|
||||||
.spawn((
|
|
||||||
AudioBundle {
|
|
||||||
source: music.handle.clone(),
|
|
||||||
settings: PlaybackSettings::LOOP,
|
|
||||||
},
|
|
||||||
Music,
|
|
||||||
))
|
|
||||||
.id(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stop_music(mut commands: Commands, mut music: ResMut<GameplayMusic>) {
|
fn cleanup_game(
|
||||||
if let Some(entity) = music.entity.take() {
|
mut commands: Commands,
|
||||||
|
query: Query<Entity, With<GameplayElement>>,
|
||||||
|
state: Res<State<Screen>>,
|
||||||
|
) {
|
||||||
|
if !matches!(*state.get(), Screen::Gameplay | Screen::Pause) {
|
||||||
|
for entity in query.iter() {
|
||||||
commands.entity(entity).despawn_recursive();
|
commands.entity(entity).despawn_recursive();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn return_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
|
|
||||||
next_screen.set(Screen::Title);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,17 +4,18 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
demo::player::PlayerAssets,
|
hint::assets::HintAssets,
|
||||||
screens::{credits::CreditsMusic, gameplay::GameplayMusic, Screen},
|
player::assets::PlayerAssets,
|
||||||
theme::{interaction::InteractionAssets, prelude::*},
|
screens::Screen,
|
||||||
|
theme::{assets::InteractionAssets, prelude::*},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
pub fn plugin(app: &mut App) {
|
||||||
app.add_systems(OnEnter(Screen::Loading), spawn_loading_screen);
|
app.add_systems(OnEnter(Screen::Loading), spawn_loading_screen);
|
||||||
|
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
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
|
commands
|
||||||
.ui_root()
|
.ui_root()
|
||||||
.insert(StateScoped(Screen::Loading))
|
.insert(StateScoped(Screen::Loading))
|
||||||
.with_children(|children| {
|
.with_children(|parent| {
|
||||||
children.label("Loading...").insert(Style {
|
parent.label("Loading...").insert(Node {
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
..default()
|
..default()
|
||||||
});
|
});
|
||||||
@ -34,14 +35,10 @@ fn continue_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
|
|||||||
next_screen.set(Screen::Title);
|
next_screen.set(Screen::Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn all_assets_loaded(
|
const fn all_assets_loaded(
|
||||||
player_assets: Option<Res<PlayerAssets>>,
|
player_assets: Option<Res<PlayerAssets>>,
|
||||||
interaction_assets: Option<Res<InteractionAssets>>,
|
interaction_assets: Option<Res<InteractionAssets>>,
|
||||||
credits_music: Option<Res<CreditsMusic>>,
|
hints_assets: Option<Res<HintAssets>>,
|
||||||
gameplay_music: Option<Res<GameplayMusic>>,
|
|
||||||
) -> bool {
|
) -> bool {
|
||||||
player_assets.is_some()
|
player_assets.is_some() && interaction_assets.is_some() && hints_assets.is_some()
|
||||||
&& interaction_assets.is_some()
|
|
||||||
&& credits_music.is_some()
|
|
||||||
&& gameplay_music.is_some()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +1,35 @@
|
|||||||
//! The game's main screen states and transitions between them.
|
//! The game's main screen states and transitions between them.
|
||||||
|
|
||||||
mod credits;
|
|
||||||
mod gameplay;
|
mod gameplay;
|
||||||
mod loading;
|
mod loading;
|
||||||
|
mod pause;
|
||||||
mod splash;
|
mod splash;
|
||||||
mod title;
|
mod title;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
pub use gameplay::{GameplayElement, GameplayInitialized};
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
pub(super) fn plugin(app: &mut App) {
|
||||||
app.init_state::<Screen>();
|
app.init_state::<Screen>();
|
||||||
app.enable_state_scoped_entities::<Screen>();
|
app.enable_state_scoped_entities::<Screen>();
|
||||||
|
|
||||||
app.add_plugins((
|
app.add_plugins((
|
||||||
credits::plugin,
|
|
||||||
gameplay::plugin,
|
gameplay::plugin,
|
||||||
loading::plugin,
|
loading::plugin,
|
||||||
splash::plugin,
|
splash::plugin,
|
||||||
title::plugin,
|
title::plugin,
|
||||||
|
pause::plugin,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The game's main screen states.
|
/// The game's main screen states.
|
||||||
#[derive(States, Debug, Hash, PartialEq, Eq, Clone, Default)]
|
#[derive(States, Debug, Hash, PartialEq, Eq, Clone, Default)]
|
||||||
pub enum Screen {
|
pub enum Screen {
|
||||||
#[default]
|
#[cfg_attr(not(feature = "dev"), default)]
|
||||||
Splash,
|
Splash,
|
||||||
|
#[cfg_attr(feature = "dev", default)]
|
||||||
Loading,
|
Loading,
|
||||||
Title,
|
Title,
|
||||||
Credits,
|
|
||||||
Gameplay,
|
Gameplay,
|
||||||
|
Pause,
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/screens/pause.rs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
use bevy::{input::common_conditions::input_just_pressed, prelude::*};
|
||||||
|
|
||||||
|
use crate::theme::{
|
||||||
|
events::OnPress,
|
||||||
|
palette::rose_pine::RosePineDawn,
|
||||||
|
prelude::ColorScheme,
|
||||||
|
widgets::{Containers, Widgets},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::Screen;
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.add_systems(OnEnter(Screen::Pause), spawn_pause_overlay);
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
return_to_game.run_if(in_state(Screen::Pause).and(input_just_pressed(KeyCode::Escape))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_pause_overlay(mut commands: Commands) {
|
||||||
|
commands
|
||||||
|
.ui_root()
|
||||||
|
.insert((
|
||||||
|
StateScoped(Screen::Pause),
|
||||||
|
BackgroundColor(RosePineDawn::Muted.to_color().with_alpha(0.5)),
|
||||||
|
))
|
||||||
|
.with_children(|parent| {
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
bottom: Val::Px(100.),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|parent| {
|
||||||
|
parent.header("Paused");
|
||||||
|
});
|
||||||
|
|
||||||
|
parent.button("Continue").observe(return_to_game_trigger);
|
||||||
|
parent
|
||||||
|
.button("Exit")
|
||||||
|
.observe(return_to_title_screen_trigger);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn return_to_game_trigger(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
|
||||||
|
next_screen.set(Screen::Gameplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn return_to_title_screen_trigger(
|
||||||
|
_trigger: Trigger<OnPress>,
|
||||||
|
mut next_screen: ResMut<NextState<Screen>>,
|
||||||
|
) {
|
||||||
|
next_screen.set(Screen::Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn return_to_game(mut next_screen: ResMut<NextState<Screen>>) {
|
||||||
|
next_screen.set(Screen::Gameplay);
|
||||||
|
}
|
||||||
@ -1,14 +1,14 @@
|
|||||||
//! A splash screen that plays briefly at startup.
|
//! A splash screen that plays bdelta_secsriefly at startup.
|
||||||
|
|
||||||
use bevy::{
|
use bevy::{
|
||||||
|
image::{ImageLoaderSettings, ImageSampler},
|
||||||
input::common_conditions::input_just_pressed,
|
input::common_conditions::input_just_pressed,
|
||||||
prelude::*,
|
prelude::*,
|
||||||
render::texture::{ImageLoaderSettings, ImageSampler},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{screens::Screen, theme::prelude::*, AppSet};
|
use crate::{screens::Screen, theme::prelude::*, AppSet};
|
||||||
|
|
||||||
pub(super) fn plugin(app: &mut App) {
|
pub fn plugin(app: &mut App) {
|
||||||
// Spawn splash screen.
|
// Spawn splash screen.
|
||||||
app.insert_resource(ClearColor(SPLASH_BACKGROUND_COLOR));
|
app.insert_resource(ClearColor(SPLASH_BACKGROUND_COLOR));
|
||||||
app.add_systems(OnEnter(Screen::Splash), spawn_splash_screen);
|
app.add_systems(OnEnter(Screen::Splash), spawn_splash_screen);
|
||||||
@ -40,7 +40,7 @@ pub(super) fn plugin(app: &mut App) {
|
|||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
Update,
|
||||||
continue_to_loading_screen
|
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,13 +59,12 @@ fn spawn_splash_screen(mut commands: Commands, asset_server: Res<AssetServer>) {
|
|||||||
.with_children(|children| {
|
.with_children(|children| {
|
||||||
children.spawn((
|
children.spawn((
|
||||||
Name::new("Splash image"),
|
Name::new("Splash image"),
|
||||||
ImageBundle {
|
Node {
|
||||||
style: Style {
|
|
||||||
margin: UiRect::all(Val::Auto),
|
margin: UiRect::all(Val::Auto),
|
||||||
width: Val::Percent(70.0),
|
width: Val::Percent(70.0),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
image: UiImage::new(asset_server.load_with_settings(
|
ImageNode::new(asset_server.load_with_settings(
|
||||||
// This should be an embedded asset for instant loading, but that is
|
// 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).
|
// currently [broken on Windows Wasm builds](https://github.com/bevyengine/bevy/issues/14246).
|
||||||
"images/splash.png",
|
"images/splash.png",
|
||||||
@ -75,8 +74,6 @@ fn spawn_splash_screen(mut commands: Commands, asset_server: Res<AssetServer>) {
|
|||||||
settings.sampler = ImageSampler::linear();
|
settings.sampler = ImageSampler::linear();
|
||||||
},
|
},
|
||||||
)),
|
)),
|
||||||
..default()
|
|
||||||
},
|
|
||||||
UiImageFadeInOut {
|
UiImageFadeInOut {
|
||||||
total_duration: SPLASH_DURATION_SECS,
|
total_duration: SPLASH_DURATION_SECS,
|
||||||
fade_duration: SPLASH_FADE_DURATION_SECS,
|
fade_duration: SPLASH_FADE_DURATION_SECS,
|
||||||
@ -104,17 +101,17 @@ impl UiImageFadeInOut {
|
|||||||
let fade = self.fade_duration / self.total_duration;
|
let fade = self.fade_duration / self.total_duration;
|
||||||
|
|
||||||
// Regular trapezoid-shaped graph, flat at the top with alpha = 1.0.
|
// 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>) {
|
fn tick_fade_in_out(time: Res<Time>, mut animation_query: Query<&mut UiImageFadeInOut>) {
|
||||||
for mut anim in &mut animation_query {
|
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 {
|
for (anim, mut image) in &mut animation_query {
|
||||||
image.color.set_alpha(anim.alpha())
|
image.color.set_alpha(anim.alpha())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,12 +12,19 @@ fn spawn_title_screen(mut commands: Commands) {
|
|||||||
commands
|
commands
|
||||||
.ui_root()
|
.ui_root()
|
||||||
.insert(StateScoped(Screen::Title))
|
.insert(StateScoped(Screen::Title))
|
||||||
.with_children(|children| {
|
.with_children(|parent| {
|
||||||
children.button("Play").observe(enter_gameplay_screen);
|
parent
|
||||||
children.button("Credits").observe(enter_credits_screen);
|
.spawn(Node {
|
||||||
|
bottom: Val::Px(70.),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|parent| {
|
||||||
|
parent.header("Maze Ascension");
|
||||||
|
});
|
||||||
|
parent.button("Play").observe(enter_gameplay_screen);
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
children.button("Exit").observe(exit_app);
|
parent.button("Quit").observe(exit_app);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,10 +32,6 @@ fn enter_gameplay_screen(_trigger: Trigger<OnPress>, mut next_screen: ResMut<Nex
|
|||||||
next_screen.set(Screen::Gameplay);
|
next_screen.set(Screen::Gameplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enter_credits_screen(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
|
|
||||||
next_screen.set(Screen::Credits);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_family = "wasm"))]
|
#[cfg(not(target_family = "wasm"))]
|
||||||
fn exit_app(_trigger: Trigger<OnPress>, mut app_exit: EventWriter<AppExit>) {
|
fn exit_app(_trigger: Trigger<OnPress>, mut app_exit: EventWriter<AppExit>) {
|
||||||
app_exit.send(AppExit::Success);
|
app_exit.send(AppExit::Success);
|
||||||
|
|||||||
21
src/stats/components.rs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct FloorDisplay;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct HighestFloorDisplay;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct ScoreDisplay;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct FloorTimerDisplay;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Reflect, Component)]
|
||||||
|
#[reflect(Component)]
|
||||||
|
pub struct TotalTimerDisplay;
|
||||||
22
src/stats/container.rs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
pub trait StatsContainer {
|
||||||
|
fn ui_stats(&mut self) -> EntityCommands;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatsContainer for Commands<'_, '_> {
|
||||||
|
fn ui_stats(&mut self) -> EntityCommands {
|
||||||
|
self.spawn((
|
||||||
|
Name::new("Stats Root"),
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
top: Val::Px(10.),
|
||||||
|
right: Val::Px(10.),
|
||||||
|
row_gap: Val::Px(8.),
|
||||||
|
align_items: AlignItems::End,
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/stats/mod.rs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
pub mod components;
|
||||||
|
pub mod container;
|
||||||
|
pub mod resources;
|
||||||
|
mod systems;
|
||||||
|
|
||||||
|
use bevy::{ecs::system::RunSystemOnce, prelude::*};
|
||||||
|
use resources::{FloorTimer, Score, TotalTimer};
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.init_resource::<Score>()
|
||||||
|
.init_resource::<TotalTimer>()
|
||||||
|
.init_resource::<FloorTimer>()
|
||||||
|
.add_plugins(systems::plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_stats_command(world: &mut World) {
|
||||||
|
let _ = world.run_system_once(systems::spawn::spawn_stats);
|
||||||
|
}
|
||||||
31
src/stats/resources.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Reflect, Resource, Deref, DerefMut)]
|
||||||
|
#[reflect(Resource)]
|
||||||
|
pub struct Score(pub usize);
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect, Resource, Deref, DerefMut)]
|
||||||
|
#[reflect(Resource)]
|
||||||
|
pub struct TotalTimer(pub Timer);
|
||||||
|
|
||||||
|
#[derive(Debug, Reflect, Resource, Deref, DerefMut)]
|
||||||
|
#[reflect(Resource)]
|
||||||
|
pub struct FloorTimer(pub Timer);
|
||||||
|
|
||||||
|
impl Default for TotalTimer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(init_timer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FloorTimer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(init_timer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_timer() -> Timer {
|
||||||
|
Timer::new(Duration::MAX, TimerMode::Once)
|
||||||
|
}
|
||||||
28
src/stats/systems/common.rs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
pub fn format_duration_adaptive(seconds: f32) -> String {
|
||||||
|
let total_millis = (seconds * 1000.0) as u64;
|
||||||
|
let millis = total_millis % 1000;
|
||||||
|
let total_seconds = total_millis / 1000;
|
||||||
|
let seconds = total_seconds % 60;
|
||||||
|
let total_minutes = total_seconds / 60;
|
||||||
|
let minutes = total_minutes % 60;
|
||||||
|
let total_hours = total_minutes / 60;
|
||||||
|
let hours = total_hours % 24;
|
||||||
|
let days = total_hours / 24;
|
||||||
|
|
||||||
|
let mut result = String::new();
|
||||||
|
|
||||||
|
if days > 0 {
|
||||||
|
result.push_str(&format!("{}d ", days));
|
||||||
|
}
|
||||||
|
if hours > 0 || days > 0 {
|
||||||
|
result.push_str(&format!("{:02}:", hours));
|
||||||
|
}
|
||||||
|
if minutes > 0 || hours > 0 || days > 0 {
|
||||||
|
result.push_str(&format!("{:02}:", minutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show at least seconds and milliseconds
|
||||||
|
result.push_str(&format!("{:02}.{:03}", seconds, millis));
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
39
src/stats/systems/floor.rs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
floor::{
|
||||||
|
components::{CurrentFloor, Floor},
|
||||||
|
resources::HighestFloor,
|
||||||
|
},
|
||||||
|
stats::components::{FloorDisplay, HighestFloorDisplay},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn update_floor_display(
|
||||||
|
floor_query: Query<&Floor, With<CurrentFloor>>,
|
||||||
|
mut text_query: Query<&mut Text, With<FloorDisplay>>,
|
||||||
|
) {
|
||||||
|
let Ok(floor) = floor_query.get_single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(mut text) = text_query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
text.0 = format!("Floor: {}", floor.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_highest_floor_display(
|
||||||
|
hightes_floor: Res<HighestFloor>,
|
||||||
|
mut text_query: Query<&mut Text, With<HighestFloorDisplay>>,
|
||||||
|
) {
|
||||||
|
if !hightes_floor.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(mut text) = text_query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
text.0 = format!("Highest Floor: {}", hightes_floor.0);
|
||||||
|
}
|
||||||
33
src/stats/systems/floor_timer.rs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
floor::resources::HighestFloor,
|
||||||
|
stats::{components::FloorTimerDisplay, resources::FloorTimer},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::common::format_duration_adaptive;
|
||||||
|
|
||||||
|
pub fn update_floor_timer(
|
||||||
|
mut floor_timer: ResMut<FloorTimer>,
|
||||||
|
time: Res<Time>,
|
||||||
|
hightes_floor: Res<HighestFloor>,
|
||||||
|
) {
|
||||||
|
floor_timer.tick(time.delta());
|
||||||
|
if hightes_floor.is_changed() {
|
||||||
|
floor_timer.0.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_floor_timer_display(
|
||||||
|
mut text_query: Query<&mut Text, With<FloorTimerDisplay>>,
|
||||||
|
floor_timer: Res<FloorTimer>,
|
||||||
|
) {
|
||||||
|
let Ok(mut text) = text_query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
text.0 = format!(
|
||||||
|
"Floor Timer: {}",
|
||||||
|
format_duration_adaptive(floor_timer.0.elapsed_secs())
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/stats/systems/mod.rs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
mod common;
|
||||||
|
mod floor;
|
||||||
|
mod floor_timer;
|
||||||
|
mod reset;
|
||||||
|
mod score;
|
||||||
|
pub mod spawn;
|
||||||
|
mod total_timer;
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use floor::{update_floor_display, update_highest_floor_display};
|
||||||
|
use floor_timer::{update_floor_timer, update_floor_timer_display};
|
||||||
|
use reset::reset_timers;
|
||||||
|
use score::{update_score, update_score_display};
|
||||||
|
use total_timer::{update_total_timer, update_total_timer_display};
|
||||||
|
|
||||||
|
use crate::screens::{GameplayInitialized, Screen};
|
||||||
|
|
||||||
|
pub(super) fn plugin(app: &mut App) {
|
||||||
|
app.add_systems(
|
||||||
|
OnEnter(Screen::Gameplay),
|
||||||
|
reset_timers.run_if(not(resource_exists::<GameplayInitialized>)),
|
||||||
|
);
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
(
|
||||||
|
update_score.before(update_floor_timer),
|
||||||
|
update_score_display,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
(update_floor_timer, update_floor_timer_display).chain(),
|
||||||
|
(update_total_timer, update_total_timer_display).chain(),
|
||||||
|
update_floor_display,
|
||||||
|
update_highest_floor_display,
|
||||||
|
)
|
||||||
|
.run_if(in_state(Screen::Gameplay)),
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/stats/systems/reset.rs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::stats::resources::{FloorTimer, Score, TotalTimer};
|
||||||
|
|
||||||
|
pub fn reset_timers(
|
||||||
|
mut floor_timer: ResMut<FloorTimer>,
|
||||||
|
mut total_timer: ResMut<TotalTimer>,
|
||||||
|
mut score: ResMut<Score>,
|
||||||
|
) {
|
||||||
|
floor_timer.reset();
|
||||||
|
total_timer.reset();
|
||||||
|
score.0 = 0;
|
||||||
|
}
|
||||||
188
src/stats/systems/score.rs
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
constants::{
|
||||||
|
BASE_FLOOR_SCORE, BASE_PERFECT_TIME, FLOOR_PROGRESSION_MULTIPLIER, MIN_TIME_MULTIPLIER,
|
||||||
|
TIME_BONUS_MULTIPLIER, TIME_INCREASE_FACTOR,
|
||||||
|
},
|
||||||
|
floor::resources::HighestFloor,
|
||||||
|
stats::{
|
||||||
|
components::ScoreDisplay,
|
||||||
|
resources::{FloorTimer, Score},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn update_score(
|
||||||
|
mut score: ResMut<Score>,
|
||||||
|
hightes_floor: Res<HighestFloor>,
|
||||||
|
floor_timer: Res<FloorTimer>,
|
||||||
|
) {
|
||||||
|
if !hightes_floor.is_changed() || hightes_floor.is_added() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
score.0 += calculate_score(
|
||||||
|
hightes_floor.0.saturating_sub(1),
|
||||||
|
floor_timer.elapsed_secs(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_score_display(
|
||||||
|
mut text_query: Query<&mut Text, With<ScoreDisplay>>,
|
||||||
|
score: Res<Score>,
|
||||||
|
) {
|
||||||
|
let Ok(mut text) = text_query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
text.0 = format!("Score: {}", score.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_score(floor_number: u8, completion_time: f32) -> usize {
|
||||||
|
let perfect_time = calculate_perfect_time(floor_number);
|
||||||
|
|
||||||
|
// Floor progression using exponential scaling for better high-floor rewards
|
||||||
|
let floor_multiplier = (1.0 + floor_number as f32).powf(FLOOR_PROGRESSION_MULTIPLIER);
|
||||||
|
let base_score = BASE_FLOOR_SCORE as f32 * floor_multiplier;
|
||||||
|
|
||||||
|
// Time bonus calculation
|
||||||
|
// Perfect time or better gets maximum bonus
|
||||||
|
// Longer times get diminishing returns but never below minimum
|
||||||
|
let time_multiplier = if completion_time <= perfect_time {
|
||||||
|
// Bonus for being faster than perfect time
|
||||||
|
let speed_ratio = perfect_time / completion_time;
|
||||||
|
speed_ratio * TIME_BONUS_MULTIPLIER
|
||||||
|
} else {
|
||||||
|
// Penalty for being slower than perfect time, with smooth degradation
|
||||||
|
let overtime_ratio = completion_time / perfect_time;
|
||||||
|
let time_factor = 1.0 / overtime_ratio;
|
||||||
|
time_factor.max(MIN_TIME_MULTIPLIER) * TIME_BONUS_MULTIPLIER
|
||||||
|
};
|
||||||
|
|
||||||
|
(base_score * time_multiplier) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perfect time increases with floor number
|
||||||
|
fn calculate_perfect_time(floor_number: u8) -> f32 {
|
||||||
|
BASE_PERFECT_TIME * (floor_number as f32 - 1.).mul_add(TIME_INCREASE_FACTOR, 1.)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use claims::*;
|
||||||
|
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
|
||||||
|
use rstest::*;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
fn floors() -> Vec<u8> {
|
||||||
|
(1..=100).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[fixture]
|
||||||
|
fn times() -> Vec<f32> {
|
||||||
|
vec![
|
||||||
|
BASE_PERFECT_TIME * 0.5, // Much faster than perfect
|
||||||
|
BASE_PERFECT_TIME * 0.8, // Faster than perfect
|
||||||
|
BASE_PERFECT_TIME, // Perfect time
|
||||||
|
BASE_PERFECT_TIME * 1.5, // Slower than perfect
|
||||||
|
BASE_PERFECT_TIME * 2.0, // Much slower
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case(1, BASE_PERFECT_TIME)]
|
||||||
|
#[case(2, BASE_PERFECT_TIME * (1.0 + TIME_INCREASE_FACTOR))]
|
||||||
|
#[case(5, BASE_PERFECT_TIME * 4.0f32.mul_add(TIME_INCREASE_FACTOR, 1.))]
|
||||||
|
fn specific_perfect_times(#[case] floor: u8, #[case] expected_time: f32) {
|
||||||
|
let calculated_time = calculate_perfect_time(floor);
|
||||||
|
assert_le!(
|
||||||
|
(calculated_time - expected_time).abs(),
|
||||||
|
0.001,
|
||||||
|
"Perfect time calculation mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn score_progression(floors: Vec<u8>, times: Vec<f32>) {
|
||||||
|
let floor_scores = floors
|
||||||
|
.par_iter()
|
||||||
|
.map(|floor| {
|
||||||
|
let scores = times
|
||||||
|
.par_iter()
|
||||||
|
.map(|&time| (*floor, time, calculate_score(*floor, time)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
(*floor, scores)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for (floor, scores) in floor_scores {
|
||||||
|
scores.windows(2).for_each(|window| {
|
||||||
|
let (_, time1, score1) = window[0];
|
||||||
|
let (_, time2, score2) = window[1];
|
||||||
|
|
||||||
|
if time1 < time2 {
|
||||||
|
assert_gt!(
|
||||||
|
score1,
|
||||||
|
score2,
|
||||||
|
"Floor {}: Faster time ({}) should give higher score than slower time ({})",
|
||||||
|
floor,
|
||||||
|
time1,
|
||||||
|
time2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn perfect_time_progression(floors: Vec<u8>) {
|
||||||
|
let perfect_scores = floors
|
||||||
|
.par_iter()
|
||||||
|
.map(|&floor| {
|
||||||
|
let perfect_time = calculate_perfect_time(floor);
|
||||||
|
(floor, calculate_score(floor, perfect_time))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
perfect_scores.windows(2).for_each(|window| {
|
||||||
|
let (floor1, score1) = window[0];
|
||||||
|
let (floor2, score2) = window[1];
|
||||||
|
assert_gt!(
|
||||||
|
score2,
|
||||||
|
score1,
|
||||||
|
"Floor {} perfect score ({}) should be higher than floor {} perfect score ({})",
|
||||||
|
floor2,
|
||||||
|
score2,
|
||||||
|
floor1,
|
||||||
|
score1
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
fn minimum_score_guarantee(floors: Vec<u8>) {
|
||||||
|
let very_slow_time = BASE_PERFECT_TIME * 10.0;
|
||||||
|
|
||||||
|
// Test minimum scores in parallel
|
||||||
|
let min_scores = floors
|
||||||
|
.par_iter()
|
||||||
|
.map(|&floor| calculate_score(floor, very_slow_time))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// Verify minimum scores
|
||||||
|
min_scores.windows(2).for_each(|window| {
|
||||||
|
assert_gt!(
|
||||||
|
window[1],
|
||||||
|
window[0],
|
||||||
|
"Higher floor should give better minimum score"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify all scores are above zero
|
||||||
|
min_scores.iter().for_each(|&score| {
|
||||||
|
assert_gt!(score, 0, "Score should never be zero");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/stats/systems/spawn.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
screens::GameplayElement,
|
||||||
|
stats::{
|
||||||
|
components::{
|
||||||
|
FloorDisplay, FloorTimerDisplay, HighestFloorDisplay, ScoreDisplay, TotalTimerDisplay,
|
||||||
|
},
|
||||||
|
container::StatsContainer,
|
||||||
|
},
|
||||||
|
theme::widgets::Widgets,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn spawn_stats(mut commands: Commands) {
|
||||||
|
commands
|
||||||
|
.ui_stats()
|
||||||
|
.insert(GameplayElement)
|
||||||
|
.with_children(|parent| {
|
||||||
|
parent.stats("Floor: 1", FloorDisplay);
|
||||||
|
parent.stats("Highest Floor: 1", HighestFloorDisplay);
|
||||||
|
parent.stats("Score: 0", ScoreDisplay);
|
||||||
|
parent.stats("Floor Timer", FloorTimerDisplay);
|
||||||
|
parent.stats("Total Timer", TotalTimerDisplay);
|
||||||
|
});
|
||||||
|
}
|
||||||
23
src/stats/systems/total_timer.rs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::stats::{components::TotalTimerDisplay, resources::TotalTimer};
|
||||||
|
|
||||||
|
use super::common::format_duration_adaptive;
|
||||||
|
|
||||||
|
pub fn update_total_timer(mut total_timer: ResMut<TotalTimer>, time: Res<Time>) {
|
||||||
|
total_timer.tick(time.delta());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_total_timer_display(
|
||||||
|
mut text_query: Query<&mut Text, With<TotalTimerDisplay>>,
|
||||||
|
total_timer: Res<TotalTimer>,
|
||||||
|
) {
|
||||||
|
let Ok(mut text) = text_query.get_single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
text.0 = format!(
|
||||||
|
"Total Timer: {}",
|
||||||
|
format_duration_adaptive(total_timer.0.elapsed_secs())
|
||||||
|
);
|
||||||
|
}
|
||||||