Compare commits

..

39 Commits
v1.0.0 ... main

Author SHA1 Message Date
ed77d18e1e
Merge pull request #42 from kristoferssolo/fix/visibility 2025-01-18 17:34:58 +02:00
5dd932a6fe fix: wall visibility 2025-01-18 17:34:13 +02:00
d820b19988
Merge pull request #41 from kristoferssolo/feature/hide-floors 2025-01-18 16:59:43 +02:00
f08bd72038 feat(floors): hide floors above #18 2025-01-18 16:58:43 +02:00
4864ecca93
Merge pull request #40 from kristoferssolo/feature/pause-screen 2025-01-18 16:41:57 +02:00
7fa567d522 feat: add game entity cleanup 2025-01-18 16:41:10 +02:00
7a4bcd81f9 feat(pause): hide walls and player when paused 2025-01-18 16:41:10 +02:00
5d50daf768 feat(screens): add translucent pause screen #36 2025-01-18 16:41:10 +02:00
e7bdb37093 feat(sceen): add pause screen 2025-01-18 15:52:06 +02:00
c8e968e76e
Merge pull request #39 from kristoferssolo/fix/web-zoom 2025-01-17 15:17:22 +02:00
099d163325 refactor(score): change scoring algorithm 2025-01-17 15:16:40 +02:00
4398620ac8 fix: scroll wheel sensitivity in WASM 2025-01-17 14:27:02 +02:00
e1fa12b6b9
Merge pull request #38 from kristoferssolo/fix/clippy 2025-01-17 13:52:00 +02:00
df4dcdf3cb chore: bump version number 2025-01-17 13:51:30 +02:00
62a91f5765 fix: clippy warnings 2025-01-17 12:41:22 +02:00
48a39d4430
Merge pull request #37 from kristoferssolo/feature/camera 2025-01-17 12:12:51 +02:00
88c46d679d feat(camera): add camera controls #34 2025-01-17 12:09:55 +02:00
d01e987b89
Merge pull request #35 from kristoferssolo/feture/score 2025-01-17 00:58:07 +02:00
5e1e4a546a refactor(stats): make score a resource 2025-01-17 00:56:26 +02:00
58276ea8f7 feat(score): add score calculator 2025-01-17 00:41:16 +02:00
d2dd57bcff feat: add game stats 2025-01-16 23:26:37 +02:00
472a238a1c chore: update hexlab version 2025-01-16 21:48:56 +02:00
2bd115a714
Merge pull request #32 from kristoferssolo/revert/speed 2025-01-10 19:28:13 +02:00
5a7c92cd96 revert: player speed 2025-01-10 19:27:50 +02:00
3abf8e2331
Merge pull request #31 from kristoferssolo/fix/floor-position-overlap 2025-01-10 19:22:43 +02:00
4d37a547ff fix(floor): issue #29 2025-01-10 19:19:13 +02:00
95b173c504 fix: typo 2025-01-09 21:13:31 +02:00
e9f02e362a feat(maze): add Coordinates trait 2025-01-08 18:27:07 +02:00
0f4899319d refactor(themes): separate into files 2025-01-08 17:33:15 +02:00
69eacd42d5
Merge pull request #28 from kristoferssolo/fix/asset-loading 2025-01-06 16:44:43 +02:00
5bc87e65a8 CI: remove nextest 2025-01-06 16:44:25 +02:00
2341ee664e fix(assets): wait for assets load 2025-01-06 16:42:48 +02:00
7ff943e829 style: format web files 2025-01-06 12:55:01 +02:00
a698495c06
Merge pull request #27 from kristoferssolo/refactor 2025-01-06 12:43:20 +02:00
68096ee108 chore: bump version number 2025-01-06 12:42:42 +02:00
ef9bb50fba refactor(player): remove triggers 2025-01-06 11:42:18 +02:00
77407f7a90 refactor(maze): remove triggers and observers 2025-01-06 11:37:37 +02:00
b64930ed9e
Merge pull request #26 from kristoferssolo/readme 2025-01-05 23:40:25 +02:00
22193243a1 chore: update readme 2025-01-05 23:39:57 +02:00
71 changed files with 1485 additions and 480 deletions

View File

@ -21,17 +21,13 @@ jobs:
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Install dependencies - name: Install dependencies
run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: Install cargo-nextest
run: cargo install cargo-nextest
- name: Populate target directory from cache - name: Populate target directory from cache
uses: Leafwing-Studios/cargo-cache@v2 uses: Leafwing-Studios/cargo-cache@v2
with: with:
sweep-cache: true sweep-cache: true
- name: Run tests - name: Run tests
run: | run: |
cargo nextest run --locked --workspace --no-default-features --all-targets cargo test --locked --workspace --no-default-features
# Run doctests separately since nextest doesn't support them
cargo test --doc --locked --workspace --no-default-features
# Run clippy lints. # Run clippy lints.
clippy: clippy:
name: Clippy name: Clippy

68
Cargo.lock generated
View File

@ -1409,7 +1409,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"regex", "regex",
"rustc-hash", "rustc-hash 1.1.0",
"shlex", "shlex",
"syn", "syn",
] ]
@ -1614,6 +1614,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "claims"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bba18ee93d577a8428902687bcc2b6b45a56b1981a1f6d779731c86cc4c5db18"
[[package]] [[package]]
name = "clang-sys" name = "clang-sys"
version = "1.8.1" version = "1.8.1"
@ -1807,7 +1813,7 @@ dependencies = [
"log", "log",
"rangemap", "rangemap",
"rayon", "rayon",
"rustc-hash", "rustc-hash 1.1.0",
"rustybuzz", "rustybuzz",
"self_cell", "self_cell",
"swash", "swash",
@ -1919,6 +1925,18 @@ version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
[[package]]
name = "deprecate-until"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a3767f826efbbe5a5ae093920b58b43b01734202be697e1354914e862e8e704"
dependencies = [
"proc-macro2",
"quote",
"semver",
"syn",
]
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "1.0.0" version = "1.0.0"
@ -2638,15 +2656,16 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]] [[package]]
name = "hexlab" name = "hexlab"
version = "0.5.3" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d2fbc6c41965686841aa5ea0e1af448730d0902274e49251c7d1fb7c78fffb9" checksum = "e247b21d6885136e4a910e1e6fac755d8dc56112c40ebf1062582f0644d62c62"
dependencies = [ dependencies = [
"bevy", "bevy",
"bevy_reflect", "bevy_reflect",
"bevy_utils", "bevy_utils",
"glam", "glam",
"hexx", "hexx",
"pathfinding",
"rand", "rand",
"thiserror 2.0.6", "thiserror 2.0.6",
] ]
@ -2888,6 +2907,15 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "integer-sqrt"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "276ec31bcb4a9ee45f58bec6f9ec700ae4cf4f4f8f2fa7e06cb406bd5ffdd770"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "io-kit-sys" name = "io-kit-sys"
version = "0.4.1" version = "0.4.1"
@ -3129,16 +3157,18 @@ dependencies = [
[[package]] [[package]]
name = "maze-ascension" name = "maze-ascension"
version = "1.0.0" version = "1.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bevy", "bevy",
"bevy-inspector-egui", "bevy-inspector-egui",
"bevy_egui", "bevy_egui",
"claims",
"hexlab", "hexlab",
"hexx", "hexx",
"log", "log",
"rand", "rand",
"rayon",
"rstest", "rstest",
"rstest_reuse", "rstest_reuse",
"strum", "strum",
@ -3220,7 +3250,7 @@ dependencies = [
"indexmap", "indexmap",
"log", "log",
"pp-rs", "pp-rs",
"rustc-hash", "rustc-hash 1.1.0",
"spirv", "spirv",
"termcolor", "termcolor",
"thiserror 1.0.69", "thiserror 1.0.69",
@ -3241,7 +3271,7 @@ dependencies = [
"once_cell", "once_cell",
"regex", "regex",
"regex-syntax 0.8.5", "regex-syntax 0.8.5",
"rustc-hash", "rustc-hash 1.1.0",
"thiserror 1.0.69", "thiserror 1.0.69",
"tracing", "tracing",
"unicode-ident", "unicode-ident",
@ -3755,6 +3785,20 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathfinding"
version = "4.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "301ad6aa19104eeb9af172b3d6a4ab8a5ea26234890baf2fcb1cbbc3f05f674b"
dependencies = [
"deprecate-until",
"indexmap",
"integer-sqrt",
"num-traits",
"rustc-hash 2.1.0",
"thiserror 2.0.6",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.1"
@ -4176,6 +4220,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@ -5216,7 +5266,7 @@ dependencies = [
"parking_lot", "parking_lot",
"profiling", "profiling",
"raw-window-handle", "raw-window-handle",
"rustc-hash", "rustc-hash 1.1.0",
"smallvec", "smallvec",
"thiserror 1.0.69", "thiserror 1.0.69",
"wgpu-hal", "wgpu-hal",
@ -5258,7 +5308,7 @@ dependencies = [
"range-alloc", "range-alloc",
"raw-window-handle", "raw-window-handle",
"renderdoc-sys", "renderdoc-sys",
"rustc-hash", "rustc-hash 1.1.0",
"smallvec", "smallvec",
"thiserror 1.0.69", "thiserror 1.0.69",
"wasm-bindgen", "wasm-bindgen",

View File

@ -1,7 +1,7 @@
[package] [package]
name = "maze-ascension" name = "maze-ascension"
authors = ["Kristofers Solo <dev@kristofers.xyz>"] authors = ["Kristofers Solo <dev@kristofers.xyz>"]
version = "1.0.0" version = "1.1.3"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
@ -18,7 +18,7 @@ tracing = { version = "0.1", features = [
"release_max_level_warn", "release_max_level_warn",
] } ] }
hexx = { version = "0.19", features = ["bevy_reflect", "grid"] } hexx = { version = "0.19", features = ["bevy_reflect", "grid"] }
hexlab = { version = "0.5", features = ["bevy"] } hexlab = { version = "0.6", features = ["bevy", "pathfinding"] }
bevy-inspector-egui = { version = "0.28", optional = true } bevy-inspector-egui = { version = "0.28", optional = true }
bevy_egui = { version = "0.31", optional = true } bevy_egui = { version = "0.31", optional = true }
thiserror = "2.0" thiserror = "2.0"
@ -26,6 +26,8 @@ anyhow = "1"
strum = { version = "0.26", features = ["derive"] } strum = { version = "0.26", features = ["derive"] }
[dev-dependencies] [dev-dependencies]
claims = "0.8.0"
rayon = "1.10.0"
rstest = "0.24" rstest = "0.24"
rstest_reuse = "0.7" rstest_reuse = "0.7"
test-log = { version = "0.2.16", default-features = false, features = [ test-log = { version = "0.2.16", default-features = false, features = [

View File

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

View File

@ -1,11 +0,0 @@
* Maze Ascension: The Labyrinth of Echoes
"Maze Ascension: The Labyrinth of Echoes" is a minimalist maze exploration
game built using the Bevy engine. The game features simple visuals with
hexagonal tiles forming the maze structure on a white background with black
borders, and a stickman-style player character. Players navigate through
multiple levels of increasing difficulty, progressing vertically as they
climb up through levels. The game includes power-ups and abilities hidden
throughout the maze, and later introduces the ability to move between levels
freely. This unique blend of puzzle-solving, exploration, and vertical
progression offers a fresh twist on traditional maze gameplay, presented in
an accessible and clean visual style.

View File

@ -8,11 +8,11 @@ native-dev:
# Run native release # Run native release
native-release: native-release:
RUSTC_WRAPPER=sccache cargo run --release --no-default-features RUSTC_WRAPPER=sccache cargo run --release --no-default-features
# Run web dev # Run web dev
web-dev: web-dev:
RUST_BACKTRACE=full trunk serve RUSTC_WRAPPER=sccache RUST_BACKTRACE=full trunk serve
# Run web release # Run web release
web-release: web-release:
@ -20,8 +20,8 @@ web-release:
# Run tests # Run tests
test: test:
RUSTC_WRAPPER=sccache RUST_BACKTRACE=full cargo test --doc --locked --workspace --no-default-features RUSTC_WRAPPER=sccache cargo test --doc --locked --workspace --no-default-features
RUSTC_WRAPPER=sccache RUST_BACKTRACE=full cargo nextest run --no-default-features --all-targets RUSTC_WRAPPER=sccache cargo nextest run --no-default-features --all-targets
# Run CI localy # Run CI localy
ci: ci:

64
src/camera.rs Normal file
View File

@ -0,0 +1,64 @@
use bevy::{input::mouse::MouseWheel, prelude::*};
use crate::constants::{BASE_ZOOM_SPEED, MAX_ZOOM, MIN_ZOOM, SCROLL_MODIFIER};
pub(super) fn plugin(app: &mut App) {
app.add_systems(Update, camera_zoom);
}
#[derive(Debug, Reflect, Component)]
#[reflect(Component)]
pub struct MainCamera;
pub fn spawn_camera(mut commands: Commands) {
commands.spawn((
Name::new("Camera"),
MainCamera,
Camera3d::default(),
Transform::from_xyz(200., 200., 0.).looking_at(Vec3::ZERO, Vec3::Y),
// Render all UI to this camera.
// Not strictly necessary since we only use one camera,
// but if we don't use this component, our UI will disappear as soon
// as we add another camera. This includes indirect ways of adding cameras like using
// [ui node outlines](https://bevyengine.org/news/bevy-0-14/#ui-node-outline-gizmos)
// for debugging. So it's good to have this here for future-proofing.
IsDefaultUiCamera,
));
}
fn camera_zoom(
mut query: Query<&mut Transform, With<MainCamera>>,
mut scrool_evr: EventReader<MouseWheel>,
keyboard: Res<ButtonInput<KeyCode>>,
time: Res<Time>,
) {
let Ok(mut transform) = query.get_single_mut() else {
return;
};
let current_distance = transform.translation.length();
// Calculate zoom speed based on distance
let distance_multiplier = (current_distance / MIN_ZOOM).sqrt();
let adjusted_zoom_speed = BASE_ZOOM_SPEED * distance_multiplier;
let mut zoom_delta = 0.0;
if keyboard.pressed(KeyCode::Equal) || keyboard.pressed(KeyCode::NumpadAdd) {
zoom_delta += adjusted_zoom_speed * time.delta_secs() * 25.;
}
if keyboard.pressed(KeyCode::Minus) || keyboard.pressed(KeyCode::NumpadSubtract) {
zoom_delta -= adjusted_zoom_speed * time.delta_secs() * 25.;
}
for ev in scrool_evr.read() {
zoom_delta += ev.y * adjusted_zoom_speed * SCROLL_MODIFIER;
}
if zoom_delta != 0.0 {
let forward = transform.translation.normalize();
let new_distance = (current_distance - zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM);
transform.translation = forward * new_distance;
}
}

View File

@ -3,3 +3,24 @@ pub const WALL_OVERLAP_MODIFIER: f32 = 1.25;
pub const FLOOR_Y_OFFSET: u8 = 200; pub const FLOOR_Y_OFFSET: u8 = 200;
pub const MOVEMENT_COOLDOWN: f32 = 1.0; // one second cooldown pub const MOVEMENT_COOLDOWN: f32 = 1.0; // one second cooldown
pub const TITLE: &str = "Maze Ascension: The Labyrinth of Echoes"; pub const TITLE: &str = "Maze Ascension: The Labyrinth of Echoes";
// Base score constants
pub const BASE_FLOOR_SCORE: usize = 100;
// Floor progression constants
pub const FLOOR_PROGRESSION_MULTIPLIER: f32 = 1.2;
pub const MIN_TIME_MULTIPLIER: f32 = 0.2; // Minimum score multiplier for time
pub const TIME_BONUS_MULTIPLIER: f32 = 1.5;
// Time scaling constants
pub const BASE_PERFECT_TIME: f32 = 10.0; // Base time for floor 1
pub const TIME_INCREASE_FACTOR: f32 = 0.15; // Each floor adds 15% more time
// Constants for camera control
pub const BASE_ZOOM_SPEED: f32 = 10.0;
#[cfg(not(target_family = "wasm"))]
pub const SCROLL_MODIFIER: f32 = 1.;
#[cfg(target_family = "wasm")]
pub const SCROLL_MODIFIER: f32 = 0.01;
pub const MIN_ZOOM: f32 = 50.0;
pub const MAX_ZOOM: f32 = 2500.0;

View File

@ -1,7 +1,7 @@
use crate::{ use crate::{
floor::components::{CurrentFloor, Floor}, floor::components::{CurrentFloor, Floor},
maze::{components::MazeConfig, events::RespawnMaze, GlobalMazeConfig}, maze::{commands::RespawnMaze, components::MazeConfig, GlobalMazeConfig},
player::events::RespawnPlayer, player::commands::RespawnPlayer,
screens::Screen, screens::Screen,
}; };
use bevy::{prelude::*, window::PrimaryWindow}; use bevy::{prelude::*, window::PrimaryWindow};
@ -72,11 +72,12 @@ pub fn maze_controls_ui(world: &mut World) {
// Handle updates // Handle updates
if changed { if changed {
maze_config.update(&global_config); maze_config.update(&global_config);
world.trigger(RespawnMaze { RespawnMaze {
floor: floor_value, floor: floor_value,
config: maze_config, config: maze_config,
}); }
world.trigger(RespawnPlayer); .apply(world);
RespawnPlayer.apply(world);
} }
} }
}); });

View File

@ -1,6 +1,6 @@
use bevy::prelude::*; use bevy::prelude::*;
#[derive(Debug, Reflect, Component, Deref, DerefMut)] #[derive(Debug, Reflect, Component, Deref, DerefMut, PartialEq, Eq, PartialOrd, Ord)]
#[reflect(Component)] #[reflect(Component)]
pub struct Floor(pub u8); pub struct Floor(pub u8);

19
src/floor/systems/hide.rs Normal file
View File

@ -0,0 +1,19 @@
use bevy::prelude::*;
use crate::floor::components::{CurrentFloor, Floor};
pub fn hide_upper_floors(
mut query: Query<(&mut Visibility, &Floor)>,
current_query: Query<&Floor, With<CurrentFloor>>,
) {
let Ok(current_floor) = current_query.get_single() else {
return;
};
for (mut visibility, floor) in query.iter_mut() {
if floor > current_floor {
*visibility = Visibility::Hidden
} else {
*visibility = Visibility::Visible
}
}
}

View File

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

View File

@ -4,7 +4,7 @@ use crate::{
components::{CurrentFloor, Floor, FloorYTarget}, components::{CurrentFloor, Floor, FloorYTarget},
events::TransitionFloor, events::TransitionFloor,
}, },
maze::components::HexMaze, maze::components::{HexMaze, MazeConfig},
player::components::{MovementSpeed, Player}, player::components::{MovementSpeed, Player},
}; };
@ -18,13 +18,22 @@ use bevy::prelude::*;
/// - Removes FloorYTarget component when floor reaches destination /// - Removes FloorYTarget component when floor reaches destination
pub fn move_floors( pub fn move_floors(
mut commands: Commands, mut commands: Commands,
mut maze_query: Query<(Entity, &mut Transform, &FloorYTarget), With<FloorYTarget>>, mut maze_query: Query<
(
Entity,
&mut Transform,
&FloorYTarget,
&MazeConfig,
Has<CurrentFloor>,
),
With<FloorYTarget>,
>,
player_query: Query<&MovementSpeed, With<Player>>, player_query: Query<&MovementSpeed, With<Player>>,
time: Res<Time>, time: Res<Time>,
) { ) {
let speed = player_query.get_single().map_or(100., |s| s.0); let speed = player_query.get_single().map_or(100., |s| s.0);
let movement_distance = speed * time.delta_secs(); let movement_distance = speed * time.delta_secs();
for (entity, mut transform, movement_state) in maze_query.iter_mut() { for (entity, mut transform, movement_state, config, is_current_floor) in maze_query.iter_mut() {
let delta = movement_state.0 - transform.translation.y; let delta = movement_state.0 - transform.translation.y;
if delta.abs() > MOVEMENT_THRESHOLD { if delta.abs() > MOVEMENT_THRESHOLD {
let movement = delta.signum() * movement_distance.min(delta.abs()); let movement = delta.signum() * movement_distance.min(delta.abs());
@ -32,6 +41,13 @@ pub fn move_floors(
} else { } else {
transform.translation.y = movement_state.0; transform.translation.y = movement_state.0;
commands.entity(entity).remove::<FloorYTarget>(); commands.entity(entity).remove::<FloorYTarget>();
if is_current_floor {
info!("Current floor seed: {}", config.seed);
info!(
"Start pos: (q={}, r={}). End pos: (q={}, r={})",
config.start_pos.x, config.start_pos.y, config.end_pos.x, config.end_pos.y,
);
}
} }
} }
} }
@ -58,7 +74,7 @@ pub fn handle_floor_transition_events(
} }
for event in event_reader.read() { for event in event_reader.read() {
let Some((current_entity, current_floor)) = current_query.get_single().ok() else { let Ok((current_entity, current_floor)) = current_query.get_single() else {
continue; continue;
}; };

View File

@ -6,10 +6,10 @@ use crate::{
events::TransitionFloor, events::TransitionFloor,
resources::HighestFloor, resources::HighestFloor,
}, },
maze::{components::MazeConfig, events::SpawnMaze}, maze::{commands::SpawnMaze, components::MazeConfig},
}; };
pub(super) fn spawn_floor( pub fn spawn_floor(
mut commands: Commands, mut commands: Commands,
query: Query<(&mut Floor, &MazeConfig), (With<CurrentFloor>, Without<FloorYTarget>)>, query: Query<(&mut Floor, &MazeConfig), (With<CurrentFloor>, Without<FloorYTarget>)>,
mut event_reader: EventReader<TransitionFloor>, mut event_reader: EventReader<TransitionFloor>,
@ -21,7 +21,7 @@ pub(super) fn spawn_floor(
for event in event_reader.read() { for event in event_reader.read() {
if current_floor.0 == 1 && *event == TransitionFloor::Descend { if current_floor.0 == 1 && *event == TransitionFloor::Descend {
warn!("Cannot descend below floor 1"); info!("Cannot descend below floor 1");
return; return;
} }
@ -30,13 +30,9 @@ pub(super) fn spawn_floor(
info!("Creating level for floor {}", target_floor); info!("Creating level for floor {}", target_floor);
commands.trigger(SpawnMaze { commands.queue(SpawnMaze {
floor: target_floor, floor: target_floor,
config: MazeConfig { config: MazeConfig::from_self(config),
start_pos: config.end_pos,
radius: config.radius + 1,
..default()
},
}); });
} }
} }

View File

@ -3,11 +3,13 @@ pub mod components;
mod systems; mod systems;
use bevy::{ecs::system::RunSystemOnce, prelude::*}; use bevy::{ecs::system::RunSystemOnce, prelude::*};
use components::IdleTimer;
pub(super) fn plugin(app: &mut App) { pub(super) fn plugin(app: &mut App) {
app.add_plugins(systems::plugin); app.register_type::<IdleTimer>()
.add_plugins(systems::plugin);
} }
pub fn spawn_hint_command(world: &mut World) { pub fn spawn_hint_command(world: &mut World) {
let _ = world.run_system_once(systems::setup::setup); let _ = world.run_system_once(systems::spawn::spawn_hints);
} }

View File

@ -1,5 +1,5 @@
mod check; mod check;
pub mod setup; pub mod spawn;
use bevy::prelude::*; use bevy::prelude::*;
use check::check_player_hints; use check::check_player_hints;

View File

@ -5,7 +5,7 @@ use crate::hint::{
components::{Hint, IdleTimer}, components::{Hint, IdleTimer},
}; };
pub fn setup(mut commands: Commands, hint_assets: Res<HintAssets>) { pub fn spawn_hints(mut commands: Commands, hint_assets: Res<HintAssets>) {
commands.spawn(( commands.spawn((
Name::new("Movement hint"), Name::new("Movement hint"),
Hint::Movement, Hint::Movement,

View File

@ -1,5 +1,6 @@
pub mod asset_tracking; pub mod asset_tracking;
pub mod audio; pub mod audio;
pub mod camera;
pub mod constants; pub mod constants;
#[cfg(feature = "dev")] #[cfg(feature = "dev")]
pub mod dev_tools; pub mod dev_tools;
@ -8,6 +9,7 @@ pub mod hint;
pub mod maze; pub mod maze;
pub mod player; pub mod player;
pub mod screens; pub mod screens;
pub mod stats;
pub mod theme; pub mod theme;
use bevy::{ use bevy::{
@ -15,6 +17,7 @@ use bevy::{
audio::{AudioPlugin, Volume}, audio::{AudioPlugin, Volume},
prelude::*, prelude::*,
}; };
use camera::spawn_camera;
use constants::TITLE; use constants::TITLE;
use theme::{palette::rose_pine, prelude::ColorScheme}; use theme::{palette::rose_pine, prelude::ColorScheme};
@ -69,6 +72,8 @@ impl Plugin for AppPlugin {
floor::plugin, floor::plugin,
player::plugin, player::plugin,
hint::plugin, hint::plugin,
stats::plugin,
camera::plugin,
)); ));
// Enable dev tools for dev builds. // Enable dev tools for dev builds.
@ -90,21 +95,6 @@ enum AppSet {
Update, Update,
} }
fn spawn_camera(mut commands: Commands) {
commands.spawn((
Name::new("Camera"),
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 load_background(mut commands: Commands) { fn load_background(mut commands: Commands) {
let colorcheme = rose_pine::RosePineDawn::Base; let colorcheme = rose_pine::RosePineDawn::Base;
commands.insert_resource(ClearColor(colorcheme.to_color())); commands.insert_resource(ClearColor(colorcheme.to_color()));

View File

@ -37,8 +37,8 @@ pub struct MazeAssets {
impl MazeAssets { impl MazeAssets {
/// Creates a new instance of MazeAssets with all necessary meshes and materials. /// Creates a new instance of MazeAssets with all necessary meshes and materials.
pub fn new( pub fn new(
meshes: &mut ResMut<Assets<Mesh>>, meshes: &mut Assets<Mesh>,
materials: &mut ResMut<Assets<StandardMaterial>>, materials: &mut Assets<StandardMaterial>,
global_config: &GlobalMazeConfig, global_config: &GlobalMazeConfig,
) -> Self { ) -> Self {
let custom_materials = RosePineDawn::iter() let custom_materials = RosePineDawn::iter()

49
src/maze/commands.rs Normal file
View File

@ -0,0 +1,49 @@
use super::{
components::MazeConfig,
systems::{despawn::despawn_maze, respawn::respawn_maze, spawn::spawn_maze},
};
use bevy::{ecs::system::RunSystemOnce, prelude::*};
#[derive(Debug, Reflect)]
pub struct SpawnMaze {
pub floor: u8,
pub config: MazeConfig,
}
#[derive(Debug, Reflect)]
pub struct RespawnMaze {
pub floor: u8,
pub config: MazeConfig,
}
#[derive(Debug, Reflect)]
pub struct DespawnMaze {
pub floor: u8,
}
impl Default for SpawnMaze {
fn default() -> Self {
Self {
floor: 1,
config: MazeConfig::default(),
}
}
}
impl Command for SpawnMaze {
fn apply(self, world: &mut World) {
let _ = world.run_system_once_with(self, spawn_maze);
}
}
impl Command for RespawnMaze {
fn apply(self, world: &mut World) {
let _ = world.run_system_once_with(self, respawn_maze);
}
}
impl Command for DespawnMaze {
fn apply(self, world: &mut World) {
let _ = world.run_system_once_with(self, despawn_maze);
}
}

View File

@ -3,7 +3,7 @@
//! Module defines the core components and configuration structures used //! Module defines the core components and configuration structures used
//! for maze generation and rendering, including hexagonal maze layouts, //! for maze generation and rendering, including hexagonal maze layouts,
//! tiles, walls, and maze configuration. //! tiles, walls, and maze configuration.
use super::GlobalMazeConfig; use super::{coordinates::is_within_radius, GlobalMazeConfig};
use crate::floor::components::Floor; use crate::floor::components::Floor;
use bevy::prelude::*; use bevy::prelude::*;
@ -49,31 +49,19 @@ impl MazeConfig {
radius: u16, radius: u16,
orientation: HexOrientation, orientation: HexOrientation,
seed: Option<u64>, seed: Option<u64>,
global_conig: &GlobalMazeConfig, global_config: &GlobalMazeConfig,
start_pos: Option<Hex>, start_pos: Option<Hex>,
) -> Self { ) -> Self {
let seed = seed.unwrap_or_else(|| thread_rng().gen()); let (seed, mut rng) = setup_rng(seed);
let mut rng = StdRng::seed_from_u64(seed);
let start_pos = start_pos.unwrap_or_else(|| generate_pos(radius, &mut rng)); let start_pos = start_pos.unwrap_or_else(|| generate_pos(radius, &mut rng));
// Generate end position ensuring start and end are different // Generate end position ensuring start and end are different
let mut end_pos; let end_pos = generate_end_pos(radius, start_pos, &mut rng);
loop {
end_pos = generate_pos(radius, &mut rng);
if start_pos != end_pos {
break;
}
}
info!(
"Start pos: (q={}, r={}). End pos: (q={}, r={})",
start_pos.x, start_pos.y, end_pos.x, end_pos.y
);
let layout = HexLayout { let layout = HexLayout {
orientation, orientation,
hex_size: Vec2::splat(global_conig.hex_size), hex_size: Vec2::splat(global_config.hex_size),
..default() ..default()
}; };
@ -86,6 +74,21 @@ impl MazeConfig {
} }
} }
pub fn from_self(config: &Self) -> Self {
let start_pos = config.end_pos;
let (seed, mut rng) = setup_rng(None);
let end_pos = generate_end_pos(config.radius, start_pos, &mut rng);
Self {
radius: config.radius + 1,
start_pos,
end_pos,
seed,
layout: config.layout.clone(),
}
}
/// Updates the maze configuration with new global settings. /// Updates the maze configuration with new global settings.
pub fn update(&mut self, global_conig: &GlobalMazeConfig) { pub fn update(&mut self, global_conig: &GlobalMazeConfig) {
self.layout.hex_size = Vec2::splat(global_conig.hex_size); self.layout.hex_size = Vec2::splat(global_conig.hex_size);
@ -104,21 +107,38 @@ impl Default for MazeConfig {
} }
} }
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. /// Generates a random position within a hexagonal radius.
/// ///
/// # Returns /// # Returns
/// A valid Hex coordinate within the specified radius /// A valid Hex coordinate within the specified radius
fn generate_pos<R: Rng>(radius: u16, rng: &mut R) -> Hex { fn generate_pos<R: Rng>(radius: u16, rng: &mut R) -> Hex {
let radius = radius as i32; let radius = radius as i32;
loop {
let q = rng.gen_range(-radius..=radius);
let r = rng.gen_range(-radius..=radius);
let s = -q - r; // Calculate third coordinate (axial coordinates: q + r + s = 0)
// Check if the position is within the hexagonal radius loop {
// Using the formula: max(abs(q), abs(r), abs(s)) <= radius // Generate coordinates using cube coordinate bounds
if q.abs().max(r.abs()).max(s.abs()) <= radius { let q = rng.gen_range(-radius..=radius);
return Hex::new(q, r); 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);
}
} }
} }
} }
@ -126,20 +146,9 @@ fn generate_pos<R: Rng>(radius: u16, rng: &mut R) -> Hex {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use claims::*;
use rstest::*; use rstest::*;
fn is_within_radius(hex: Hex, radius: u16) -> bool {
let q = hex.x;
let r = hex.y;
let s = -q - r;
q.abs().max(r.abs()).max(s.abs()) <= radius as i32
}
#[fixture]
fn test_radius() -> Vec<u16> {
vec![1, 2, 5, 8]
}
#[rstest] #[rstest]
#[case(1)] #[case(1)]
#[case(2)] #[case(2)]
@ -156,18 +165,8 @@ mod tests {
assert_eq!(config.seed, 12345); assert_eq!(config.seed, 12345);
assert_eq!(config.layout.orientation, orientation); assert_eq!(config.layout.orientation, orientation);
assert!( assert_ok!(is_within_radius(radius, &config.start_pos),);
is_within_radius(config.start_pos, radius), assert_ok!(is_within_radius(radius, &config.end_pos));
"Start pos {:?} outside radius {}",
config.start_pos,
radius
);
assert!(
is_within_radius(config.end_pos, radius),
"End pos {:?} outside radius {}",
config.end_pos,
radius
);
assert_ne!(config.start_pos, config.end_pos); assert_ne!(config.start_pos, config.end_pos);
} }
@ -178,13 +177,13 @@ mod tests {
let config = MazeConfig::default(); let config = MazeConfig::default();
let radius = config.radius; let radius = config.radius;
assert!(is_within_radius(config.start_pos, radius)); assert_ok!(is_within_radius(radius, &config.start_pos));
assert!(is_within_radius(config.end_pos, radius)); assert_ok!(is_within_radius(radius, &config.end_pos));
assert_ne!(config.start_pos, config.end_pos); assert_ne!(config.start_pos, config.end_pos);
} }
} }
#[rstest] #[test]
fn maze_config_default_with_seeds() { fn maze_config_default_with_seeds() {
let test_seeds = [ let test_seeds = [
None, None,
@ -206,8 +205,8 @@ mod tests {
assert_eq!(config.radius, 8); assert_eq!(config.radius, 8);
assert_eq!(config.layout.orientation, HexOrientation::Flat); assert_eq!(config.layout.orientation, HexOrientation::Flat);
assert!(is_within_radius(config.start_pos, 8)); assert_ok!(is_within_radius(8, &config.start_pos));
assert!(is_within_radius(config.end_pos, 8)); assert_ok!(is_within_radius(8, &config.end_pos));
assert_ne!(config.start_pos, config.end_pos); assert_ne!(config.start_pos, config.end_pos);
} }
} }
@ -238,12 +237,7 @@ mod tests {
for _ in 0..10 { for _ in 0..10 {
let pos = generate_pos(radius, &mut rng); let pos = generate_pos(radius, &mut rng);
assert!( assert_ok!(is_within_radius(radius, &pos),);
is_within_radius(pos, radius),
"Position {:?} outside radius {}",
pos,
radius
);
} }
} }
@ -317,4 +311,51 @@ mod tests {
assert_eq!(config.layout.hex_size.x, 0.0); assert_eq!(config.layout.hex_size.x, 0.0);
assert_eq!(config.layout.hex_size.y, 0.0); assert_eq!(config.layout.hex_size.y, 0.0);
} }
#[test]
fn basic_generation() {
let mut rng = thread_rng();
let radius = 2;
let hex = generate_pos(radius, &mut rng);
// Test that generated position is within radius
assert_ok!(is_within_radius(radius as i32, &(hex.x, hex.y)));
}
#[rstest]
#[case(1)]
#[case(2)]
#[case(3)]
#[case(6)]
fn multiple_radii(#[case] radius: u16) {
let mut rng = thread_rng();
// Generate multiple points for each radius
for _ in 0..100 {
let hex = generate_pos(radius, &mut rng);
assert_ok!(is_within_radius(radius, &hex));
}
}
#[test]
fn zero_radius() {
let mut rng = thread_rng();
let hex = generate_pos(0, &mut rng);
// With radius 0, only (0,0) should be possible
assert_eq!(hex.x, 0);
assert_eq!(hex.y, 0);
}
#[test]
fn large_radius() {
let mut rng = thread_rng();
let radius = 100;
let iterations = 100;
for _ in 0..iterations {
let hex = generate_pos(radius, &mut rng);
assert_ok!(is_within_radius(radius, &hex));
}
}
} }

139
src/maze/coordinates.rs Normal file
View File

@ -0,0 +1,139 @@
use hexx::Hex;
use super::errors::RadiusError;
pub trait Coordinates {
fn get_coords(&self) -> (i32, i32);
}
impl Coordinates for (i32, i32) {
fn get_coords(&self) -> (i32, i32) {
*self
}
}
impl Coordinates for Hex {
fn get_coords(&self) -> (i32, i32) {
(self.x, self.y)
}
}
pub fn is_within_radius<R, C>(radius: R, coords: &C) -> Result<bool, RadiusError>
where
R: Into<i32>,
C: Coordinates,
{
let radius = radius.into();
if radius < 0 {
return Err(RadiusError::NegativeRadius(radius));
}
let (q, r) = coords.get_coords();
let s = -q - r; // Calculate third axial coordinate (q + r + s = 0)
Ok(q.abs().max(r.abs()).max(s.abs()) <= radius)
}
#[cfg(test)]
mod tests {
use super::*;
use claims::*;
use rstest::*;
#[rstest]
// Original test cases
#[case(0, (0, 0), true)] // Center point
#[case(1, (1, 0), true)] // Point at radius 1
#[case(1, (2, 0), false)] // Point outside radius 1
#[case(2, (2, 0), true)] // East
#[case(2, (0, 2), true)] // Southeast
#[case(2, (-2, 2), true)] // Southwest
#[case(2, (-2, 0), true)] // West
#[case(2, (0, -2), true)] // Northwest
#[case(2, (2, -2), true)] // Northeast
#[case(2, (3, 0), false)] // Just outside radius 2
// Large radius test cases
#[case(6, (6, 0), true)] // East at radius 6
#[case(6, (0, 6), true)] // Southeast at radius 6
#[case(6, (-6, 6), true)] // Southwest at radius 6
#[case(6, (-6, 0), true)] // West at radius 6
#[case(6, (0, -6), true)] // Northwest at radius 6
#[case(6, (6, -6), true)] // Northeast at radius 6
#[case(6, (7, 0), false)] // Just outside radius 6 east
#[case(6, (4, 4), false)] // Outside radius 6 diagonal
#[case(6, (5, 5), false)] // Outside radius 6 diagonal
// Edge cases with large radius
#[case(6, (6, -3), true)] // Complex position within radius 6
#[case(6, (-3, 6), true)] // Complex position within radius 6
#[case(6, (3, -6), true)] // Complex position within radius 6
#[case(6, (7, -7), false)] // Outside radius 6 corner
fn valid_radius_tuple(#[case] radius: i32, #[case] pos: (i32, i32), #[case] expected: bool) {
let result = is_within_radius(radius, &pos);
assert_ok_eq!(result, expected);
}
#[rstest]
// Large radius test cases for Hex struct
#[case(6, (6, 0), true)] // East at radius 6
#[case(6, (0, 6), true)] // Southeast at radius 6
#[case(6, (-6, 6), true)] // Southwest at radius 6
#[case(6, (-6, 0), true)] // West at radius 6
#[case(6, (0, -6), true)] // Northwest at radius 6
#[case(6, (6, -6), true)] // Northeast at radius 6
#[case(6, (4, 4), false)] // Outside radius 6 diagonal
#[case(6, (5, 5), false)] // Outside radius 6 diagonal
fn valid_radius_hex(#[case] radius: i32, #[case] pos: (i32, i32), #[case] expected: bool) {
let hex = Hex::from(pos);
let result = is_within_radius(radius, &hex);
assert_ok_eq!(result, expected);
}
#[rstest]
#[case(-1)]
#[case(-2)]
#[case(-5)]
fn negative_radius(#[case] radius: i32) {
let result = is_within_radius(radius, &(0, 0));
assert_err!(&result);
}
#[test]
fn boundary_points() {
let radius = 3;
// Test points exactly on the boundary of radius 3
assert_ok_eq!(is_within_radius(radius, &(3, 0)), true); // East boundary
assert_ok_eq!(is_within_radius(radius, &(0, 3)), true); // Southeast boundary
assert_ok_eq!(is_within_radius(radius, &(-3, 3)), true); // Southwest boundary
assert_ok_eq!(is_within_radius(radius, &(-3, 0)), true); // West boundary
assert_ok_eq!(is_within_radius(radius, &(0, -3)), true); // Northwest boundary
assert_ok_eq!(is_within_radius(radius, &(3, -3)), true); // Northeast boundary
}
#[test]
fn large_boundary_points() {
let radius = 6;
// Test points exactly on the boundary of radius 6
assert_ok_eq!(is_within_radius(radius, &(6, 0)), true); // East boundary
assert_ok_eq!(is_within_radius(radius, &(0, 6)), true); // Southeast boundary
assert_ok_eq!(is_within_radius(radius, &(-6, 6)), true); // Southwest boundary
assert_ok_eq!(is_within_radius(radius, &(-6, 0)), true); // West boundary
assert_ok_eq!(is_within_radius(radius, &(0, -6)), true); // Northwest boundary
assert_ok_eq!(is_within_radius(radius, &(6, -6)), true); // Northeast boundary
// Test points just outside the boundary
assert_ok_eq!(is_within_radius(radius, &(7, 0)), false); // Just outside east
assert_ok_eq!(is_within_radius(radius, &(0, 7)), false); // Just outside southeast
assert_ok_eq!(is_within_radius(radius, &(-7, 7)), false); // Just outside southwest
}
#[test]
fn different_coordinate_types() {
// Test with tuple coordinates
assert_ok_eq!(is_within_radius(2, &(1, 1)), true);
// Test with Hex struct
let hex = Hex { x: 1, y: 1 };
assert_ok_eq!(is_within_radius(2, &hex), true);
}
}

View File

@ -25,7 +25,11 @@ pub enum MazeError {
Other(#[from] anyhow::Error), Other(#[from] anyhow::Error),
} }
pub type MazeResult<T> = Result<T, MazeError>; #[derive(Debug, Error)]
pub enum RadiusError {
#[error("Radius cannot be negative: {0}")]
NegativeRadius(i32),
}
impl MazeError { impl MazeError {
pub fn config_error(msg: impl Into<String>) -> Self { pub fn config_error(msg: impl Into<String>) -> Self {

View File

@ -1,28 +0,0 @@
use super::components::MazeConfig;
use bevy::prelude::*;
#[derive(Debug, Reflect, Event)]
pub struct SpawnMaze {
pub floor: u8,
pub config: MazeConfig,
}
#[derive(Debug, Reflect, Event)]
pub struct RespawnMaze {
pub floor: u8,
pub config: MazeConfig,
}
#[derive(Debug, Reflect, Event)]
pub struct DespawnMaze {
pub floor: u8,
}
impl Default for SpawnMaze {
fn default() -> Self {
Self {
floor: 1,
config: MazeConfig::default(),
}
}
}

View File

@ -1,25 +1,20 @@
mod assets; mod assets;
pub mod commands;
pub mod components; pub mod components;
pub mod coordinates;
pub mod errors; pub mod errors;
pub mod events;
pub mod resources; pub mod resources;
mod systems; mod systems;
mod triggers;
use bevy::{ecs::system::RunSystemOnce, prelude::*}; use bevy::prelude::*;
use components::HexMaze; use commands::SpawnMaze;
use events::{DespawnMaze, RespawnMaze, SpawnMaze};
pub use resources::GlobalMazeConfig; pub use resources::GlobalMazeConfig;
pub(super) fn plugin(app: &mut App) { pub(super) fn plugin(app: &mut App) {
app.init_resource::<GlobalMazeConfig>() app.init_resource::<GlobalMazeConfig>()
.add_event::<SpawnMaze>() .add_plugins(systems::plugin);
.add_event::<RespawnMaze>()
.add_event::<DespawnMaze>()
.register_type::<HexMaze>()
.add_plugins((systems::plugin, triggers::plugin));
} }
pub fn spawn_level_command(world: &mut World) { pub fn spawn_level_command(world: &mut World) {
let _ = world.run_system_once(systems::setup::setup); SpawnMaze::default().apply(world);
} }

View File

@ -2,18 +2,17 @@
//! //!
//! Module handles the cleanup of maze entities when they need to be removed, //! 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. //! ensuring proper cleanup of both the maze and all its child entities.
use crate::{floor::components::Floor, maze::events::DespawnMaze}; use crate::{floor::components::Floor, maze::commands::DespawnMaze};
use bevy::prelude::*; use bevy::prelude::*;
/// Despawns a maze and all its associated entities for a given floor. /// Despawns a maze and all its associated entities for a given floor.
pub fn despawn_maze( pub fn despawn_maze(
trigger: Trigger<DespawnMaze>, In(DespawnMaze { floor }): In<DespawnMaze>,
mut commands: Commands, mut commands: Commands,
query: Query<(Entity, &Floor)>, query: Query<(Entity, &Floor)>,
) { ) {
let DespawnMaze { floor } = trigger.event(); match query.iter().find(|(_, f)| f.0 == floor) {
match query.iter().find(|(_, f)| f.0 == *floor) {
Some((entity, _)) => commands.entity(entity).despawn_recursive(), Some((entity, _)) => commands.entity(entity).despawn_recursive(),
_ => warn!("Floor {} not found for removal", floor), _ => warn!("Floor {} not found for removal", floor),
} }

View File

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

View File

@ -3,14 +3,15 @@
//! Module provides the ability to regenerate mazes for existing floors, //! Module provides the ability to regenerate mazes for existing floors,
//! maintaining the same floor entity but replacing its internal maze structure. //! maintaining the same floor entity but replacing its internal maze structure.
use super::{common::generate_maze, spawn::spawn_maze_tiles};
use crate::{ use crate::{
floor::components::Floor, floor::components::Floor,
maze::{assets::MazeAssets, errors::MazeError, events::RespawnMaze, GlobalMazeConfig}, maze::{assets::MazeAssets, commands::RespawnMaze, errors::MazeError, GlobalMazeConfig},
}; };
use bevy::prelude::*; use bevy::prelude::*;
use hexlab::Maze; use hexlab::Maze;
use super::{common::generate_maze, spawn::spawn_maze_tiles};
/// Respawns a maze for an existing floor with a new configuration. /// Respawns a maze for an existing floor with a new configuration.
/// ///
/// # Behavior: /// # Behavior:
@ -20,19 +21,17 @@ use hexlab::Maze;
/// - Spawns new maze tiles /// - Spawns new maze tiles
/// - Updates the floor's configuration /// - Updates the floor's configuration
pub fn respawn_maze( pub fn respawn_maze(
trigger: Trigger<RespawnMaze>, In(RespawnMaze { floor, config }): In<RespawnMaze>,
mut commands: Commands, mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>, mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>, mut materials: ResMut<Assets<StandardMaterial>>,
mut maze_query: Query<(Entity, &Floor, &mut Maze)>, mut maze_query: Query<(Entity, &Floor, &mut Maze)>,
global_config: Res<GlobalMazeConfig>, global_config: Res<GlobalMazeConfig>,
) { ) {
let RespawnMaze { floor, config } = trigger.event();
let (entity, _, mut maze) = match maze_query let (entity, _, mut maze) = match maze_query
.iter_mut() .iter_mut()
.find(|(_, f, _)| f.0 == *floor) .find(|(_, f, _)| f.0 == floor)
.ok_or(MazeError::FloorNotFound(*floor)) .ok_or(MazeError::FloorNotFound(floor))
{ {
Ok((entity, floor, maze)) => (entity, floor, maze), Ok((entity, floor, maze)) => (entity, floor, maze),
Err(e) => { Err(e) => {
@ -41,7 +40,7 @@ pub fn respawn_maze(
} }
}; };
*maze = match generate_maze(config) { *maze = match generate_maze(&config) {
Ok(generated_maze) => generated_maze, Ok(generated_maze) => generated_maze,
Err(e) => { Err(e) => {
warn!("Failed to update floor ({floor}). {e}"); warn!("Failed to update floor ({floor}). {e}");
@ -56,7 +55,7 @@ pub fn respawn_maze(
entity, entity,
&maze, &maze,
&assets, &assets,
config, &config,
&global_config, &global_config,
); );

View File

@ -1,6 +0,0 @@
use crate::maze::events::SpawnMaze;
use bevy::prelude::*;
pub fn setup(mut commands: Commands) {
commands.trigger(SpawnMaze::default());
}

View File

@ -11,11 +11,11 @@ use crate::{
}, },
maze::{ maze::{
assets::MazeAssets, assets::MazeAssets,
commands::SpawnMaze,
components::{HexMaze, MazeConfig, Tile, Wall}, components::{HexMaze, MazeConfig, Tile, Wall},
events::SpawnMaze,
resources::GlobalMazeConfig, resources::GlobalMazeConfig,
}, },
screens::Screen, screens::GameplayElement,
theme::palette::rose_pine::RosePineDawn, theme::palette::rose_pine::RosePineDawn,
}; };
@ -26,7 +26,7 @@ use std::f32::consts::{FRAC_PI_2, FRAC_PI_3, FRAC_PI_6};
/// Spawns a new maze for the specified floor on [`SpawnMaze`] event. /// Spawns a new maze for the specified floor on [`SpawnMaze`] event.
pub fn spawn_maze( pub fn spawn_maze(
trigger: Trigger<SpawnMaze>, In(SpawnMaze { floor, config }): In<SpawnMaze>,
mut commands: Commands, mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>, mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>, mut materials: ResMut<Assets<StandardMaterial>>,
@ -34,14 +34,12 @@ pub fn spawn_maze(
global_config: Res<GlobalMazeConfig>, global_config: Res<GlobalMazeConfig>,
mut event_writer: EventWriter<TransitionFloor>, mut event_writer: EventWriter<TransitionFloor>,
) { ) {
let SpawnMaze { floor, config } = trigger.event(); if maze_query.iter().any(|(_, f, _)| f.0 == floor) {
info!("Floor {} already exists, skipping creation", floor);
if maze_query.iter().any(|(_, f, _)| f.0 == *floor) {
warn!("Floor {} already exists, skipping creation", floor);
return; return;
} }
let maze = match generate_maze(config) { let maze = match generate_maze(&config) {
Ok(m) => m, Ok(m) => m,
Err(e) => { Err(e) => {
error!("Failed to generate maze for floor {floor}: {:?}", e); error!("Failed to generate maze for floor {floor}: {:?}", e);
@ -50,7 +48,7 @@ pub fn spawn_maze(
}; };
// Calculate vertical offset based on floor number // Calculate vertical offset based on floor number
let y_offset = match *floor { let y_offset = match floor {
1 => 0, // Ground/Initial floor (floor 1) is at y=0 1 => 0, // Ground/Initial floor (floor 1) is at y=0
_ => FLOOR_Y_OFFSET, // Other floors are offset vertically _ => FLOOR_Y_OFFSET, // Other floors are offset vertically
} as f32; } as f32;
@ -60,27 +58,28 @@ pub fn spawn_maze(
Name::new(format!("Floor {}", floor)), Name::new(format!("Floor {}", floor)),
HexMaze, HexMaze,
maze.clone(), maze.clone(),
Floor(*floor), Floor(floor),
config.clone(), config.clone(),
Transform::from_translation(Vec3::ZERO.with_y(y_offset)), Transform::from_translation(Vec3::ZERO.with_y(y_offset)),
Visibility::Visible, Visibility::Visible,
StateScoped(Screen::Gameplay), GameplayElement,
)) ))
.insert_if(CurrentFloor, || *floor == 1) // Only floor 1 gets CurrentFloor .insert_if(CurrentFloor, || floor == 1) // Only floor 1 gets CurrentFloor
.id(); .id();
let assets = MazeAssets::new(&mut meshes, &mut materials, &global_config); let assets = MazeAssets::new(&mut meshes, &mut materials, &global_config);
spawn_maze_tiles( spawn_maze_tiles(
&mut commands, &mut commands,
entity, entity,
&maze, &maze,
&assets, &assets,
config, &config,
&global_config, &global_config,
); );
// TODO: find a better way to handle double event indirection // TODO: find a better way to handle double event indirection
if *floor != 1 { if floor != 1 {
event_writer.send(TransitionFloor::Ascend); event_writer.send(TransitionFloor::Ascend);
} }
} }

View File

@ -0,0 +1,13 @@
use bevy::prelude::*;
use crate::{maze::components::Wall, screens::Screen};
pub fn toggle_walls(mut query: Query<&mut Visibility, With<Wall>>, state: Res<State<Screen>>) {
for mut visibility in query.iter_mut() {
*visibility = match *state.get() {
Screen::Gameplay => Visibility::Inherited,
Screen::Pause => Visibility::Hidden,
_ => *visibility,
}
}
}

View File

@ -1,15 +0,0 @@
pub mod common;
mod despawn;
mod respawn;
mod spawn;
use bevy::prelude::*;
use despawn::despawn_maze;
use respawn::respawn_maze;
use spawn::spawn_maze;
pub(super) fn plugin(app: &mut App) {
app.add_observer(spawn_maze)
.add_observer(respawn_maze)
.add_observer(despawn_maze);
}

30
src/player/commands.rs Normal file
View File

@ -0,0 +1,30 @@
use bevy::{ecs::system::RunSystemOnce, prelude::*};
use super::systems::{despawn::despawn_players, respawn::respawn_player, spawn::spawn_player};
#[derive(Debug, Reflect)]
pub struct SpawnPlayer;
#[derive(Debug, Reflect)]
pub struct RespawnPlayer;
#[derive(Debug, Reflect)]
pub struct DespawnPlayer;
impl Command for SpawnPlayer {
fn apply(self, world: &mut World) {
let _ = world.run_system_once(spawn_player);
}
}
impl Command for RespawnPlayer {
fn apply(self, world: &mut World) {
let _ = world.run_system_once(respawn_player);
}
}
impl Command for DespawnPlayer {
fn apply(self, world: &mut World) {
let _ = world.run_system_once(despawn_players);
}
}

View File

@ -1,10 +0,0 @@
use bevy::prelude::*;
#[derive(Debug, Event)]
pub struct SpawnPlayer;
#[derive(Debug, Event)]
pub struct RespawnPlayer;
#[derive(Debug, Event)]
pub struct DespawnPlayer;

View File

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

View File

@ -0,0 +1,8 @@
use crate::player::components::Player;
use bevy::prelude::*;
pub fn despawn_players(mut commands: Commands, query: Query<Entity, With<Player>>) {
for entity in query.iter() {
commands.entity(entity).despawn_recursive();
}
}

View File

@ -1,7 +1,10 @@
pub mod despawn;
mod input; mod input;
mod movement; mod movement;
pub mod setup; pub mod respawn;
mod sound_effect; mod sound_effect;
pub mod spawn;
mod toggle_pause;
mod vertical_transition; mod vertical_transition;
use crate::{screens::Screen, AppSet}; use crate::{screens::Screen, AppSet};
@ -9,6 +12,7 @@ use bevy::prelude::*;
use input::player_input; use input::player_input;
use movement::player_movement; use movement::player_movement;
use sound_effect::play_movement_sound; use sound_effect::play_movement_sound;
use toggle_pause::toggle_player;
use vertical_transition::handle_floor_transition; use vertical_transition::handle_floor_transition;
use super::assets::PlayerAssets; use super::assets::PlayerAssets;
@ -28,4 +32,5 @@ pub(super) fn plugin(app: &mut App) {
.chain() .chain()
.run_if(in_state(Screen::Gameplay)), .run_if(in_state(Screen::Gameplay)),
); );
app.add_systems(Update, toggle_player.run_if(state_changed::<Screen>));
} }

View File

@ -0,0 +1,7 @@
use crate::player::commands::{DespawnPlayer, SpawnPlayer};
use bevy::prelude::*;
pub fn respawn_player(mut commands: Commands) {
commands.queue(DespawnPlayer);
commands.queue(SpawnPlayer);
}

View File

@ -1,6 +0,0 @@
use crate::player::events::SpawnPlayer;
use bevy::prelude::*;
pub fn setup(mut commands: Commands) {
commands.trigger(SpawnPlayer);
}

View File

@ -4,14 +4,12 @@ use crate::{
player::{ player::{
assets::{blue_material, generate_pill_mesh}, assets::{blue_material, generate_pill_mesh},
components::{CurrentPosition, Player}, components::{CurrentPosition, Player},
events::SpawnPlayer,
}, },
screens::Screen, screens::GameplayElement,
}; };
use bevy::prelude::*; use bevy::prelude::*;
pub(super) fn spawn_player( pub fn spawn_player(
_trigger: Trigger<SpawnPlayer>,
mut commands: Commands, mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>, mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>, mut materials: ResMut<Assets<StandardMaterial>>,
@ -35,6 +33,6 @@ pub(super) fn spawn_player(
Mesh3d(meshes.add(generate_pill_mesh(player_radius, player_height / 2.))), Mesh3d(meshes.add(generate_pill_mesh(player_radius, player_height / 2.))),
MeshMaterial3d(materials.add(blue_material())), MeshMaterial3d(materials.add(blue_material())),
Transform::from_xyz(start_pos.x, y_offset, start_pos.y), Transform::from_xyz(start_pos.x, y_offset, start_pos.y),
StateScoped(Screen::Gameplay), GameplayElement,
)); ));
} }

View File

@ -0,0 +1,13 @@
use bevy::prelude::*;
use crate::{player::components::Player, screens::Screen};
pub fn toggle_player(mut query: Query<&mut Visibility, With<Player>>, state: Res<State<Screen>>) {
for mut visibility in query.iter_mut() {
*visibility = match *state.get() {
Screen::Gameplay => Visibility::Visible,
Screen::Pause => Visibility::Hidden,
_ => *visibility,
}
}
}

View File

@ -1,12 +0,0 @@
use crate::player::{components::Player, events::DespawnPlayer};
use bevy::prelude::*;
pub(super) fn despawn_players(
_trigger: Trigger<DespawnPlayer>,
mut commands: Commands,
query: Query<Entity, With<Player>>,
) {
for entity in query.iter() {
commands.entity(entity).despawn_recursive();
}
}

View File

@ -1,14 +0,0 @@
mod despawn;
mod respawn;
mod spawn;
use bevy::prelude::*;
use despawn::despawn_players;
use respawn::respawn_player;
use spawn::spawn_player;
pub(super) fn plugin(app: &mut App) {
app.add_observer(spawn_player)
.add_observer(respawn_player)
.add_observer(despawn_players);
}

View File

@ -1,7 +0,0 @@
use crate::player::events::{DespawnPlayer, RespawnPlayer, SpawnPlayer};
use bevy::prelude::*;
pub(super) fn respawn_player(_trigger: Trigger<RespawnPlayer>, mut commands: Commands) {
commands.trigger(DespawnPlayer);
commands.trigger(SpawnPlayer);
}

View File

@ -1,29 +1,62 @@
//! The screen state for the main gameplay. //! The screen state for the main gameplay.
use crate::player::spawn_player_command; use crate::{
use crate::screens::Screen; hint::spawn_hint_command, maze::spawn_level_command, player::spawn_player_command,
use crate::{hint::spawn_hint_command, maze::spawn_level_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::*};
pub(super) fn plugin(app: &mut App) { pub(super) fn plugin(app: &mut App) {
app.init_resource::<GameplayInitialized>();
app.add_systems( app.add_systems(
OnEnter(Screen::Gameplay), OnEnter(Screen::Gameplay),
( (
spawn_level_command, spawn_level_command,
spawn_player_command, spawn_player_command,
spawn_hint_command, spawn_hint_command,
spawn_stats_command,
) )
.chain(), .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.add_systems(OnEnter(Screen::Title), reset_gameplay_state);
app.add_systems( app.add_systems(
Update, Update,
return_to_title_screen pause_game.run_if(in_state(Screen::Gameplay).and(input_just_pressed(KeyCode::Escape))),
.run_if(in_state(Screen::Gameplay).and(input_just_pressed(KeyCode::Escape))),
); );
} }
fn return_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) { fn pause_game(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title); next_screen.set(Screen::Pause);
}
fn reset_gameplay_state(mut commands: Commands) {
commands.remove_resource::<GameplayInitialized>();
}
#[derive(Debug, Default, Reflect, Resource, DerefMut, Deref)]
#[reflect(Resource)]
pub struct GameplayInitialized(bool);
#[derive(Debug, Reflect, Component)]
#[reflect(Component)]
pub struct GameplayElement;
fn cleanup_game(
mut commands: Commands,
query: Query<Entity, With<GameplayElement>>,
state: Res<State<Screen>>,
) {
if !matches!(*state.get(), Screen::Gameplay | Screen::Pause) {
for entity in query.iter() {
commands.entity(entity).despawn_recursive();
}
}
} }

View File

@ -4,8 +4,10 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::{ use crate::{
hint::assets::HintAssets,
player::assets::PlayerAssets,
screens::Screen, screens::Screen,
theme::{interaction::InteractionAssets, prelude::*}, theme::{assets::InteractionAssets, prelude::*},
}; };
pub fn plugin(app: &mut App) { pub fn plugin(app: &mut App) {
@ -33,6 +35,10 @@ fn continue_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title); next_screen.set(Screen::Title);
} }
const fn all_assets_loaded(interaction_assets: Option<Res<InteractionAssets>>) -> bool { const fn all_assets_loaded(
interaction_assets.is_some() player_assets: Option<Res<PlayerAssets>>,
interaction_assets: Option<Res<InteractionAssets>>,
hints_assets: Option<Res<HintAssets>>,
) -> bool {
player_assets.is_some() && interaction_assets.is_some() && hints_assets.is_some()
} }

View File

@ -2,10 +2,12 @@
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>();
@ -16,6 +18,7 @@ pub(super) fn plugin(app: &mut App) {
loading::plugin, loading::plugin,
splash::plugin, splash::plugin,
title::plugin, title::plugin,
pause::plugin,
)); ));
} }
@ -28,4 +31,5 @@ pub enum Screen {
Loading, Loading,
Title, Title,
Gameplay, Gameplay,
Pause,
} }

57
src/screens/pause.rs Normal file
View File

@ -0,0 +1,57 @@
use bevy::{input::common_conditions::input_just_pressed, prelude::*};
use crate::theme::{
events::OnPress,
palette::rose_pine::RosePineDawn,
prelude::ColorScheme,
widgets::{Containers, Widgets},
};
use super::Screen;
pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Pause), spawn_pause_overlay);
app.add_systems(
Update,
return_to_game.run_if(in_state(Screen::Pause).and(input_just_pressed(KeyCode::Escape))),
);
}
fn spawn_pause_overlay(mut commands: Commands) {
commands
.ui_root()
.insert((
StateScoped(Screen::Pause),
BackgroundColor(RosePineDawn::Muted.to_color().with_alpha(0.5)),
))
.with_children(|parent| {
parent
.spawn(Node {
bottom: Val::Px(100.),
..default()
})
.with_children(|parent| {
parent.header("Paused");
});
parent.button("Continue").observe(return_to_game_trigger);
parent
.button("Exit")
.observe(return_to_title_screen_trigger);
});
}
fn return_to_game_trigger(_trigger: Trigger<OnPress>, mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Gameplay);
}
fn return_to_title_screen_trigger(
_trigger: Trigger<OnPress>,
mut next_screen: ResMut<NextState<Screen>>,
) {
next_screen.set(Screen::Title);
}
fn return_to_game(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Gameplay);
}

21
src/stats/components.rs Normal file
View File

@ -0,0 +1,21 @@
use bevy::prelude::*;
#[derive(Debug, Clone, Reflect, Component)]
#[reflect(Component)]
pub struct FloorDisplay;
#[derive(Debug, Clone, Reflect, Component)]
#[reflect(Component)]
pub struct HighestFloorDisplay;
#[derive(Debug, Clone, Reflect, Component)]
#[reflect(Component)]
pub struct ScoreDisplay;
#[derive(Debug, Clone, Reflect, Component)]
#[reflect(Component)]
pub struct FloorTimerDisplay;
#[derive(Debug, Clone, Reflect, Component)]
#[reflect(Component)]
pub struct TotalTimerDisplay;

22
src/stats/container.rs Normal file
View File

@ -0,0 +1,22 @@
use bevy::prelude::*;
pub trait StatsContainer {
fn ui_stats(&mut self) -> EntityCommands;
}
impl StatsContainer for Commands<'_, '_> {
fn ui_stats(&mut self) -> EntityCommands {
self.spawn((
Name::new("Stats Root"),
Node {
position_type: PositionType::Absolute,
top: Val::Px(10.),
right: Val::Px(10.),
row_gap: Val::Px(8.),
align_items: AlignItems::End,
flex_direction: FlexDirection::Column,
..default()
},
))
}
}

18
src/stats/mod.rs Normal file
View File

@ -0,0 +1,18 @@
pub mod components;
pub mod container;
pub mod resources;
mod systems;
use bevy::{ecs::system::RunSystemOnce, prelude::*};
use resources::{FloorTimer, Score, TotalTimer};
pub(super) fn plugin(app: &mut App) {
app.init_resource::<Score>()
.init_resource::<TotalTimer>()
.init_resource::<FloorTimer>()
.add_plugins(systems::plugin);
}
pub fn spawn_stats_command(world: &mut World) {
let _ = world.run_system_once(systems::spawn::spawn_stats);
}

31
src/stats/resources.rs Normal file
View File

@ -0,0 +1,31 @@
use std::time::Duration;
use bevy::prelude::*;
#[derive(Debug, Default, Reflect, Resource, Deref, DerefMut)]
#[reflect(Resource)]
pub struct Score(pub usize);
#[derive(Debug, Reflect, Resource, Deref, DerefMut)]
#[reflect(Resource)]
pub struct TotalTimer(pub Timer);
#[derive(Debug, Reflect, Resource, Deref, DerefMut)]
#[reflect(Resource)]
pub struct FloorTimer(pub Timer);
impl Default for TotalTimer {
fn default() -> Self {
Self(init_timer())
}
}
impl Default for FloorTimer {
fn default() -> Self {
Self(init_timer())
}
}
fn init_timer() -> Timer {
Timer::new(Duration::MAX, TimerMode::Once)
}

View File

@ -0,0 +1,28 @@
pub fn format_duration_adaptive(seconds: f32) -> String {
let total_millis = (seconds * 1000.0) as u64;
let millis = total_millis % 1000;
let total_seconds = total_millis / 1000;
let seconds = total_seconds % 60;
let total_minutes = total_seconds / 60;
let minutes = total_minutes % 60;
let total_hours = total_minutes / 60;
let hours = total_hours % 24;
let days = total_hours / 24;
let mut result = String::new();
if days > 0 {
result.push_str(&format!("{}d ", days));
}
if hours > 0 || days > 0 {
result.push_str(&format!("{:02}:", hours));
}
if minutes > 0 || hours > 0 || days > 0 {
result.push_str(&format!("{:02}:", minutes));
}
// Always show at least seconds and milliseconds
result.push_str(&format!("{:02}.{:03}", seconds, millis));
result
}

View File

@ -0,0 +1,39 @@
use bevy::prelude::*;
use crate::{
floor::{
components::{CurrentFloor, Floor},
resources::HighestFloor,
},
stats::components::{FloorDisplay, HighestFloorDisplay},
};
pub fn update_floor_display(
floor_query: Query<&Floor, With<CurrentFloor>>,
mut text_query: Query<&mut Text, With<FloorDisplay>>,
) {
let Ok(floor) = floor_query.get_single() else {
return;
};
let Ok(mut text) = text_query.get_single_mut() else {
return;
};
text.0 = format!("Floor: {}", floor.0);
}
pub fn update_highest_floor_display(
hightes_floor: Res<HighestFloor>,
mut text_query: Query<&mut Text, With<HighestFloorDisplay>>,
) {
if !hightes_floor.is_changed() {
return;
}
let Ok(mut text) = text_query.get_single_mut() else {
return;
};
text.0 = format!("Highest Floor: {}", hightes_floor.0);
}

View File

@ -0,0 +1,33 @@
use bevy::prelude::*;
use crate::{
floor::resources::HighestFloor,
stats::{components::FloorTimerDisplay, resources::FloorTimer},
};
use super::common::format_duration_adaptive;
pub fn update_floor_timer(
mut floor_timer: ResMut<FloorTimer>,
time: Res<Time>,
hightes_floor: Res<HighestFloor>,
) {
floor_timer.tick(time.delta());
if hightes_floor.is_changed() {
floor_timer.0.reset();
}
}
pub fn update_floor_timer_display(
mut text_query: Query<&mut Text, With<FloorTimerDisplay>>,
floor_timer: Res<FloorTimer>,
) {
let Ok(mut text) = text_query.get_single_mut() else {
return;
};
text.0 = format!(
"Floor Timer: {}",
format_duration_adaptive(floor_timer.0.elapsed_secs())
);
}

38
src/stats/systems/mod.rs Normal file
View File

@ -0,0 +1,38 @@
mod common;
mod floor;
mod floor_timer;
mod reset;
mod score;
pub mod spawn;
mod total_timer;
use bevy::prelude::*;
use floor::{update_floor_display, update_highest_floor_display};
use floor_timer::{update_floor_timer, update_floor_timer_display};
use reset::reset_timers;
use score::{update_score, update_score_display};
use total_timer::{update_total_timer, update_total_timer_display};
use crate::screens::{GameplayInitialized, Screen};
pub(super) fn plugin(app: &mut App) {
app.add_systems(
OnEnter(Screen::Gameplay),
reset_timers.run_if(not(resource_exists::<GameplayInitialized>)),
);
app.add_systems(
Update,
(
(
update_score.before(update_floor_timer),
update_score_display,
)
.chain(),
(update_floor_timer, update_floor_timer_display).chain(),
(update_total_timer, update_total_timer_display).chain(),
update_floor_display,
update_highest_floor_display,
)
.run_if(in_state(Screen::Gameplay)),
);
}

View File

@ -0,0 +1,13 @@
use bevy::prelude::*;
use crate::stats::resources::{FloorTimer, Score, TotalTimer};
pub fn reset_timers(
mut floor_timer: ResMut<FloorTimer>,
mut total_timer: ResMut<TotalTimer>,
mut score: ResMut<Score>,
) {
floor_timer.reset();
total_timer.reset();
score.0 = 0;
}

188
src/stats/systems/score.rs Normal file
View File

@ -0,0 +1,188 @@
use bevy::prelude::*;
use crate::{
constants::{
BASE_FLOOR_SCORE, BASE_PERFECT_TIME, FLOOR_PROGRESSION_MULTIPLIER, MIN_TIME_MULTIPLIER,
TIME_BONUS_MULTIPLIER, TIME_INCREASE_FACTOR,
},
floor::resources::HighestFloor,
stats::{
components::ScoreDisplay,
resources::{FloorTimer, Score},
},
};
pub fn update_score(
mut score: ResMut<Score>,
hightes_floor: Res<HighestFloor>,
floor_timer: Res<FloorTimer>,
) {
if !hightes_floor.is_changed() || hightes_floor.is_added() {
return;
}
score.0 += calculate_score(
hightes_floor.0.saturating_sub(1),
floor_timer.elapsed_secs(),
);
}
pub fn update_score_display(
mut text_query: Query<&mut Text, With<ScoreDisplay>>,
score: Res<Score>,
) {
let Ok(mut text) = text_query.get_single_mut() else {
return;
};
text.0 = format!("Score: {}", score.0);
}
fn calculate_score(floor_number: u8, completion_time: f32) -> usize {
let perfect_time = calculate_perfect_time(floor_number);
// Floor progression using exponential scaling for better high-floor rewards
let floor_multiplier = (1.0 + floor_number as f32).powf(FLOOR_PROGRESSION_MULTIPLIER);
let base_score = BASE_FLOOR_SCORE as f32 * floor_multiplier;
// Time bonus calculation
// Perfect time or better gets maximum bonus
// Longer times get diminishing returns but never below minimum
let time_multiplier = if completion_time <= perfect_time {
// Bonus for being faster than perfect time
let speed_ratio = perfect_time / completion_time;
speed_ratio * TIME_BONUS_MULTIPLIER
} else {
// Penalty for being slower than perfect time, with smooth degradation
let overtime_ratio = completion_time / perfect_time;
let time_factor = 1.0 / overtime_ratio;
time_factor.max(MIN_TIME_MULTIPLIER) * TIME_BONUS_MULTIPLIER
};
(base_score * time_multiplier) as usize
}
/// Perfect time increases with floor number
fn calculate_perfect_time(floor_number: u8) -> f32 {
BASE_PERFECT_TIME * (floor_number as f32 - 1.).mul_add(TIME_INCREASE_FACTOR, 1.)
}
#[cfg(test)]
mod tests {
use claims::*;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use rstest::*;
use super::*;
#[fixture]
fn floors() -> Vec<u8> {
(1..=100).collect()
}
#[fixture]
fn times() -> Vec<f32> {
vec![
BASE_PERFECT_TIME * 0.5, // Much faster than perfect
BASE_PERFECT_TIME * 0.8, // Faster than perfect
BASE_PERFECT_TIME, // Perfect time
BASE_PERFECT_TIME * 1.5, // Slower than perfect
BASE_PERFECT_TIME * 2.0, // Much slower
]
}
#[rstest]
#[case(1, BASE_PERFECT_TIME)]
#[case(2, BASE_PERFECT_TIME * (1.0 + TIME_INCREASE_FACTOR))]
#[case(5, BASE_PERFECT_TIME * 4.0f32.mul_add(TIME_INCREASE_FACTOR, 1.))]
fn specific_perfect_times(#[case] floor: u8, #[case] expected_time: f32) {
let calculated_time = calculate_perfect_time(floor);
assert_le!(
(calculated_time - expected_time).abs(),
0.001,
"Perfect time calculation mismatch"
);
}
#[rstest]
fn score_progression(floors: Vec<u8>, times: Vec<f32>) {
let floor_scores = floors
.par_iter()
.map(|floor| {
let scores = times
.par_iter()
.map(|&time| (*floor, time, calculate_score(*floor, time)))
.collect::<Vec<_>>();
(*floor, scores)
})
.collect::<Vec<_>>();
for (floor, scores) in floor_scores {
scores.windows(2).for_each(|window| {
let (_, time1, score1) = window[0];
let (_, time2, score2) = window[1];
if time1 < time2 {
assert_gt!(
score1,
score2,
"Floor {}: Faster time ({}) should give higher score than slower time ({})",
floor,
time1,
time2
);
}
})
}
}
#[rstest]
fn perfect_time_progression(floors: Vec<u8>) {
let perfect_scores = floors
.par_iter()
.map(|&floor| {
let perfect_time = calculate_perfect_time(floor);
(floor, calculate_score(floor, perfect_time))
})
.collect::<Vec<_>>();
perfect_scores.windows(2).for_each(|window| {
let (floor1, score1) = window[0];
let (floor2, score2) = window[1];
assert_gt!(
score2,
score1,
"Floor {} perfect score ({}) should be higher than floor {} perfect score ({})",
floor2,
score2,
floor1,
score1
);
})
}
#[rstest]
fn minimum_score_guarantee(floors: Vec<u8>) {
let very_slow_time = BASE_PERFECT_TIME * 10.0;
// Test minimum scores in parallel
let min_scores = floors
.par_iter()
.map(|&floor| calculate_score(floor, very_slow_time))
.collect::<Vec<_>>();
// Verify minimum scores
min_scores.windows(2).for_each(|window| {
assert_gt!(
window[1],
window[0],
"Higher floor should give better minimum score"
);
});
// Verify all scores are above zero
min_scores.iter().for_each(|&score| {
assert_gt!(score, 0, "Score should never be zero");
});
}
}

View File

@ -0,0 +1,25 @@
use bevy::prelude::*;
use crate::{
screens::GameplayElement,
stats::{
components::{
FloorDisplay, FloorTimerDisplay, HighestFloorDisplay, ScoreDisplay, TotalTimerDisplay,
},
container::StatsContainer,
},
theme::widgets::Widgets,
};
pub fn spawn_stats(mut commands: Commands) {
commands
.ui_stats()
.insert(GameplayElement)
.with_children(|parent| {
parent.stats("Floor: 1", FloorDisplay);
parent.stats("Highest Floor: 1", HighestFloorDisplay);
parent.stats("Score: 0", ScoreDisplay);
parent.stats("Floor Timer", FloorTimerDisplay);
parent.stats("Total Timer", TotalTimerDisplay);
});
}

View File

@ -0,0 +1,23 @@
use bevy::prelude::*;
use crate::stats::{components::TotalTimerDisplay, resources::TotalTimer};
use super::common::format_duration_adaptive;
pub fn update_total_timer(mut total_timer: ResMut<TotalTimer>, time: Res<Time>) {
total_timer.tick(time.delta());
}
pub fn update_total_timer_display(
mut text_query: Query<&mut Text, With<TotalTimerDisplay>>,
total_timer: Res<TotalTimer>,
) {
let Ok(mut text) = text_query.get_single_mut() else {
return;
};
text.0 = format!(
"Total Timer: {}",
format_duration_adaptive(total_timer.0.elapsed_secs())
);
}

24
src/theme/assets.rs Normal file
View File

@ -0,0 +1,24 @@
use bevy::prelude::*;
#[derive(Resource, Asset, Reflect, Clone)]
pub struct InteractionAssets {
#[dependency]
pub(super) hover: Handle<AudioSource>,
#[dependency]
pub(super) press: Handle<AudioSource>,
}
impl InteractionAssets {
pub const PATH_BUTTON_HOVER: &'static str = "audio/sound_effects/button_hover.ogg";
pub const PATH_BUTTON_PRESS: &'static str = "audio/sound_effects/button_press.ogg";
}
impl FromWorld for InteractionAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
hover: assets.load(Self::PATH_BUTTON_HOVER),
press: assets.load(Self::PATH_BUTTON_PRESS),
}
}
}

16
src/theme/components.rs Normal file
View File

@ -0,0 +1,16 @@
use bevy::prelude::*;
/// Palette for widget interactions. Add this to an entity that supports
/// [`Interaction`]s, such as a button, to change its [`BackgroundColor`] based
/// on the current interaction state.
#[derive(Component, Debug, Reflect)]
#[reflect(Component)]
pub struct InteractionPalette {
pub none: Color,
pub hovered: Color,
pub pressed: Color,
}
#[derive(Debug, Reflect, Component)]
#[reflect(Component)]
pub struct UrlLink(pub String);

6
src/theme/events.rs Normal file
View File

@ -0,0 +1,6 @@
use bevy::prelude::*;
/// Event triggered on a UI entity when the [`Interaction`] component on the same entity changes to
/// [`Interaction::Pressed`]. Observe this event to detect e.g. button presses.
#[derive(Event)]
pub struct OnPress;

View File

@ -1,102 +0,0 @@
use bevy::prelude::*;
use crate::{asset_tracking::LoadResource, audio::SoundEffect};
pub(super) fn plugin(app: &mut App) {
app.register_type::<InteractionPalette>();
app.load_resource::<InteractionAssets>();
app.add_systems(
Update,
(
trigger_on_press,
apply_interaction_palette,
trigger_interaction_sound_effect,
)
.run_if(resource_exists::<InteractionAssets>),
);
}
/// Palette for widget interactions. Add this to an entity that supports
/// [`Interaction`]s, such as a button, to change its [`BackgroundColor`] based
/// on the current interaction state.
#[derive(Component, Debug, Reflect)]
#[reflect(Component)]
pub struct InteractionPalette {
pub none: Color,
pub hovered: Color,
pub pressed: Color,
}
/// Event triggered on a UI entity when the [`Interaction`] component on the same entity changes to
/// [`Interaction::Pressed`]. Observe this event to detect e.g. button presses.
#[derive(Event)]
pub struct OnPress;
fn trigger_on_press(
interaction_query: Query<(Entity, &Interaction), Changed<Interaction>>,
mut commands: Commands,
) {
for (entity, interaction) in &interaction_query {
if matches!(interaction, Interaction::Pressed) {
commands.trigger_targets(OnPress, entity);
}
}
}
fn apply_interaction_palette(
mut palette_query: Query<
(&Interaction, &InteractionPalette, &mut BackgroundColor),
Changed<Interaction>,
>,
) {
for (interaction, palette, mut background) in &mut palette_query {
*background = match interaction {
Interaction::None => palette.none,
Interaction::Hovered => palette.hovered,
Interaction::Pressed => palette.pressed,
}
.into();
}
}
#[derive(Resource, Asset, Reflect, Clone)]
pub struct InteractionAssets {
#[dependency]
hover: Handle<AudioSource>,
#[dependency]
press: Handle<AudioSource>,
}
impl InteractionAssets {
pub const PATH_BUTTON_HOVER: &'static str = "audio/sound_effects/button_hover.ogg";
pub const PATH_BUTTON_PRESS: &'static str = "audio/sound_effects/button_press.ogg";
}
impl FromWorld for InteractionAssets {
fn from_world(world: &mut World) -> Self {
let assets = world.resource::<AssetServer>();
Self {
hover: assets.load(Self::PATH_BUTTON_HOVER),
press: assets.load(Self::PATH_BUTTON_PRESS),
}
}
}
fn trigger_interaction_sound_effect(
interaction_query: Query<&Interaction, Changed<Interaction>>,
interaction_assets: Res<InteractionAssets>,
mut commands: Commands,
) {
for interaction in &interaction_query {
let source = match interaction {
Interaction::Hovered => interaction_assets.hover.clone(),
Interaction::Pressed => interaction_assets.press.clone(),
_ => continue,
};
commands.spawn((
AudioPlayer::<AudioSource>(source),
PlaybackSettings::DESPAWN,
SoundEffect,
));
}
}

View File

@ -2,23 +2,33 @@
// Unused utilities may trigger this lints undesirably. // Unused utilities may trigger this lints undesirably.
pub mod assets;
mod colorscheme; mod colorscheme;
pub mod interaction; pub mod components;
pub mod events;
pub mod palette; pub mod palette;
mod widgets; mod systems;
pub mod widgets;
#[allow(unused_imports)] #[allow(unused_imports)]
pub mod prelude { pub mod prelude {
pub use super::{ pub use super::{
colorscheme::{ColorScheme, ColorSchemeWrapper}, colorscheme::{ColorScheme, ColorSchemeWrapper},
interaction::{InteractionPalette, OnPress}, components::{InteractionPalette, UrlLink},
events::OnPress,
palette as ui_palette, palette as ui_palette,
widgets::{Containers as _, Widgets as _}, widgets::{Containers as _, Widgets as _},
}; };
} }
use assets::InteractionAssets;
use bevy::prelude::*; use bevy::prelude::*;
use prelude::InteractionPalette;
use crate::asset_tracking::LoadResource;
pub(super) fn plugin(app: &mut App) { pub(super) fn plugin(app: &mut App) {
app.add_plugins(interaction::plugin); app.register_type::<InteractionPalette>();
app.load_resource::<InteractionAssets>();
app.add_plugins(systems::plugin);
} }

View File

@ -0,0 +1,52 @@
use bevy::prelude::*;
use crate::{
audio::SoundEffect,
theme::{assets::InteractionAssets, events::OnPress, prelude::InteractionPalette},
};
pub fn trigger_on_press(
interaction_query: Query<(Entity, &Interaction), Changed<Interaction>>,
mut commands: Commands,
) {
for (entity, interaction) in &interaction_query {
if matches!(interaction, Interaction::Pressed) {
commands.trigger_targets(OnPress, entity);
}
}
}
pub fn apply_interaction_palette(
mut palette_query: Query<
(&Interaction, &InteractionPalette, &mut BackgroundColor),
Changed<Interaction>,
>,
) {
for (interaction, palette, mut background) in &mut palette_query {
*background = match interaction {
Interaction::None => palette.none,
Interaction::Hovered => palette.hovered,
Interaction::Pressed => palette.pressed,
}
.into();
}
}
pub fn trigger_interaction_sound_effect(
interaction_query: Query<&Interaction, Changed<Interaction>>,
interaction_assets: Res<InteractionAssets>,
mut commands: Commands,
) {
for interaction in &interaction_query {
let source = match interaction {
Interaction::Hovered => interaction_assets.hover.clone(),
Interaction::Pressed => interaction_assets.press.clone(),
_ => continue,
};
commands.spawn((
AudioPlayer::<AudioSource>(source),
PlaybackSettings::DESPAWN,
SoundEffect,
));
}
}

18
src/theme/systems/mod.rs Normal file
View File

@ -0,0 +1,18 @@
mod button;
use bevy::prelude::*;
use button::{apply_interaction_palette, trigger_interaction_sound_effect, trigger_on_press};
use super::assets::InteractionAssets;
pub(super) fn plugin(app: &mut App) {
app.add_systems(
Update,
(
trigger_on_press,
apply_interaction_palette,
trigger_interaction_sound_effect,
)
.run_if(resource_exists::<InteractionAssets>),
);
}

View File

@ -1,10 +1,13 @@
//! Helper traits for creating common widgets. //! Helper traits for creating common widgets.
use bevy::{ecs::system::EntityCommands, prelude::*, ui::Val::*}; use bevy::{
ecs::system::EntityCommands, prelude::*, ui::Val::*, window::SystemCursorIcon,
winit::cursor::CursorIcon,
};
use rose_pine::RosePineDawn; use rose_pine::RosePineDawn;
use super::prelude::ColorScheme; use super::prelude::{ColorScheme, InteractionPalette};
use crate::theme::{interaction::InteractionPalette, palette::*}; use crate::theme::palette::*;
/// An extension trait for spawning UI widgets. /// An extension trait for spawning UI widgets.
pub trait Widgets { pub trait Widgets {
@ -16,6 +19,8 @@ pub trait Widgets {
/// Spawn a simple text label. /// Spawn a simple text label.
fn label(&mut self, text: impl Into<String>) -> EntityCommands; fn label(&mut self, text: impl Into<String>) -> EntityCommands;
fn stats(&mut self, text: impl Into<String>, bundle: impl Bundle) -> EntityCommands;
} }
impl<T: SpawnUi> Widgets for T { impl<T: SpawnUi> Widgets for T {
@ -35,6 +40,7 @@ impl<T: SpawnUi> Widgets for T {
border: UiRect::all(Px(4.)), border: UiRect::all(Px(4.)),
..default() ..default()
}, },
CursorIcon::System(SystemCursorIcon::Pointer),
BorderRadius::all(Px(8.)), BorderRadius::all(Px(8.)),
BorderColor(RosePineDawn::Text.to_color()), BorderColor(RosePineDawn::Text.to_color()),
InteractionPalette { InteractionPalette {
@ -103,6 +109,21 @@ impl<T: SpawnUi> Widgets for T {
)); ));
entity entity
} }
fn stats(&mut self, text: impl Into<String>, bundle: impl Bundle) -> EntityCommands {
let text = text.into();
let entity = self.spawn_ui((
Name::new(text.clone()),
Text(text),
TextFont {
font_size: 24.0,
..default()
},
bundle,
TextColor(RosePineDawn::Text.to_color()),
));
entity
}
} }
/// An extension trait for spawning UI containers. /// An extension trait for spawning UI containers.

View File

@ -1,38 +1,36 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" /> <head>
<meta name="description" <meta charset="utf-8" />
content="Maze exploration game built using the Bevy engine"> <meta name="description" content="Maze exploration game built using the Bevy engine">
<meta name="keywords" content="game, bevy"> <meta name="keywords" content="game, bevy">
<title>Maze Ascension: The Labyrinth of Echoes</title> <title>Maze Ascension: The Labyrinth of Echoes</title>
<link data-trunk rel="copy-dir" href="../assets" /> <link data-trunk rel="copy-dir" href="../assets" />
<link data-trunk rel="inline" href="style.css" /> <link data-trunk rel="inline" href="style.css" />
<link data-trunk rel="inline" type="module" href="restart-audio-context.js" /> <link data-trunk rel="inline" type="module" href="restart-audio-context.js" />
<link data-trunk <link data-trunk rel="rust" data-cargo-no-default-features data-wasm-opt="s" href="../" />
rel="rust" </head>
data-cargo-no-default-features
data-wasm-opt="s" <body>
href="../" /> <div id="game" class="center">
</head> <div id="loading-screen" class="center">
<body> <span class="spinner"></span>
<div id="game" class="center">
<div id="loading-screen" class="center">
<span class="spinner"></span>
</div>
<canvas id="bevy"> Javascript and canvas support is required </canvas>
</div> </div>
<script type="module"> <canvas id="bevy"> Javascript and canvas support is required </canvas>
// Hide loading screen when the game starts. </div>
const loading_screen = document.getElementById("loading-screen"); <script type="module">
const bevy = document.getElementById("bevy"); // Hide loading screen when the game starts.
const observer = new MutationObserver(() => { const loading_screen = document.getElementById("loading-screen");
if (bevy.height > 1) { const bevy = document.getElementById("bevy");
loading_screen.style.display = "none"; const observer = new MutationObserver(() => {
observer.disconnect(); if (bevy.height > 1) {
} loading_screen.style.display = "none";
}); observer.disconnect();
observer.observe(bevy, { attributeFilter: ["height"] }); }
</script> });
</body> observer.observe(bevy, {attributeFilter: ["height"]});
</script>
</body>
</html> </html>

View File

@ -1,56 +1,56 @@
:root { :root {
/* Consider adjusting this color to match your splash screen! */ /* Consider adjusting this color to match your splash screen! */
--loading-screen-bg-color: #282828; --loading-screen-bg-color: #282828;
} }
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
border: 0; border: 0;
} }
html, html,
body { body {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.center { .center {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
} }
#loading-screen { #loading-screen {
background-color: var(--loading-screen-bg-color); background-color: var(--loading-screen-bg-color);
} }
.spinner { .spinner {
width: 128px; width: 128px;
height: 128px; height: 128px;
border: 64px solid transparent; border: 64px solid transparent;
border-bottom-color: #ececec; border-bottom-color: #ececec;
border-right-color: #b2b2b2; border-right-color: #b2b2b2;
border-top-color: #787878; border-top-color: #787878;
border-radius: 50%; border-radius: 50%;
box-sizing: border-box; box-sizing: border-box;
animation: spin 1.2s linear infinite; animation: spin 1.2s linear infinite;
} }
@keyframes spin { @keyframes spin {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
} }
100% { 100% {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
#bevy { #bevy {
/* Hide Bevy app before it loads */ /* Hide Bevy app before it loads */
height: 0; height: 0;
} }