mirror of
https://github.com/kristoferssolo/maze-ascension.git
synced 2025-12-31 13:42:36 +00:00
Compare commits
33 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 |
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@ -21,17 +21,13 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install dependencies
|
||||
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
|
||||
uses: Leafwing-Studios/cargo-cache@v2
|
||||
with:
|
||||
sweep-cache: true
|
||||
- name: Run tests
|
||||
run: |
|
||||
cargo nextest run --locked --workspace --no-default-features --all-targets
|
||||
# Run doctests separately since nextest doesn't support them
|
||||
cargo test --doc --locked --workspace --no-default-features
|
||||
cargo test --locked --workspace --no-default-features
|
||||
# Run clippy lints.
|
||||
clippy:
|
||||
name: Clippy
|
||||
|
||||
68
Cargo.lock
generated
68
Cargo.lock
generated
@ -1409,7 +1409,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"shlex",
|
||||
"syn",
|
||||
]
|
||||
@ -1614,6 +1614,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "claims"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bba18ee93d577a8428902687bcc2b6b45a56b1981a1f6d779731c86cc4c5db18"
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
@ -1807,7 +1813,7 @@ dependencies = [
|
||||
"log",
|
||||
"rangemap",
|
||||
"rayon",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"rustybuzz",
|
||||
"self_cell",
|
||||
"swash",
|
||||
@ -1919,6 +1925,18 @@ version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "derive_more"
|
||||
version = "1.0.0"
|
||||
@ -2638,15 +2656,16 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
||||
|
||||
[[package]]
|
||||
name = "hexlab"
|
||||
version = "0.5.3"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d2fbc6c41965686841aa5ea0e1af448730d0902274e49251c7d1fb7c78fffb9"
|
||||
checksum = "e247b21d6885136e4a910e1e6fac755d8dc56112c40ebf1062582f0644d62c62"
|
||||
dependencies = [
|
||||
"bevy",
|
||||
"bevy_reflect",
|
||||
"bevy_utils",
|
||||
"glam",
|
||||
"hexx",
|
||||
"pathfinding",
|
||||
"rand",
|
||||
"thiserror 2.0.6",
|
||||
]
|
||||
@ -2888,6 +2907,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "io-kit-sys"
|
||||
version = "0.4.1"
|
||||
@ -3129,16 +3157,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "maze-ascension"
|
||||
version = "1.0.1"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bevy",
|
||||
"bevy-inspector-egui",
|
||||
"bevy_egui",
|
||||
"claims",
|
||||
"hexlab",
|
||||
"hexx",
|
||||
"log",
|
||||
"rand",
|
||||
"rayon",
|
||||
"rstest",
|
||||
"rstest_reuse",
|
||||
"strum",
|
||||
@ -3220,7 +3250,7 @@ dependencies = [
|
||||
"indexmap",
|
||||
"log",
|
||||
"pp-rs",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"termcolor",
|
||||
"thiserror 1.0.69",
|
||||
@ -3241,7 +3271,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"regex",
|
||||
"regex-syntax 0.8.5",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"unicode-ident",
|
||||
@ -3755,6 +3785,20 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.1"
|
||||
@ -4176,6 +4220,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@ -5216,7 +5266,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
"wgpu-hal",
|
||||
@ -5258,7 +5308,7 @@ dependencies = [
|
||||
"range-alloc",
|
||||
"raw-window-handle",
|
||||
"renderdoc-sys",
|
||||
"rustc-hash",
|
||||
"rustc-hash 1.1.0",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
"wasm-bindgen",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "maze-ascension"
|
||||
authors = ["Kristofers Solo <dev@kristofers.xyz>"]
|
||||
version = "1.0.1"
|
||||
version = "1.1.3"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@ -18,7 +18,7 @@ tracing = { version = "0.1", features = [
|
||||
"release_max_level_warn",
|
||||
] }
|
||||
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_egui = { version = "0.31", optional = true }
|
||||
thiserror = "2.0"
|
||||
@ -26,6 +26,8 @@ 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 = [
|
||||
|
||||
6
justfile
6
justfile
@ -12,7 +12,7 @@ native-release:
|
||||
|
||||
# Run web dev
|
||||
web-dev:
|
||||
RUST_BACKTRACE=full trunk serve
|
||||
RUSTC_WRAPPER=sccache RUST_BACKTRACE=full trunk serve
|
||||
|
||||
# Run web release
|
||||
web-release:
|
||||
@ -20,8 +20,8 @@ web-release:
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
RUSTC_WRAPPER=sccache RUST_BACKTRACE=full 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 test --doc --locked --workspace --no-default-features
|
||||
RUSTC_WRAPPER=sccache cargo nextest run --no-default-features --all-targets
|
||||
|
||||
# Run CI localy
|
||||
ci:
|
||||
|
||||
64
src/camera.rs
Normal file
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;
|
||||
}
|
||||
}
|
||||
@ -3,3 +3,24 @@ 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,6 +1,6 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Debug, Reflect, Component, Deref, DerefMut)]
|
||||
#[derive(Debug, Reflect, Component, Deref, DerefMut, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[reflect(Component)]
|
||||
pub struct Floor(pub u8);
|
||||
|
||||
|
||||
19
src/floor/systems/hide.rs
Normal file
19
src/floor/systems/hide.rs
Normal file
@ -0,0 +1,19 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::floor::components::{CurrentFloor, Floor};
|
||||
|
||||
pub fn hide_upper_floors(
|
||||
mut query: Query<(&mut Visibility, &Floor)>,
|
||||
current_query: Query<&Floor, With<CurrentFloor>>,
|
||||
) {
|
||||
let Ok(current_floor) = current_query.get_single() else {
|
||||
return;
|
||||
};
|
||||
for (mut visibility, floor) in query.iter_mut() {
|
||||
if floor > current_floor {
|
||||
*visibility = Visibility::Hidden
|
||||
} else {
|
||||
*visibility = Visibility::Visible
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,12 @@
|
||||
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;
|
||||
|
||||
@ -16,6 +18,7 @@ pub(super) fn plugin(app: &mut App) {
|
||||
despawn_floor,
|
||||
handle_floor_transition_events,
|
||||
move_floors,
|
||||
hide_upper_floors,
|
||||
)
|
||||
.chain()
|
||||
.run_if(in_state(Screen::Gameplay)),
|
||||
|
||||
@ -4,7 +4,7 @@ use crate::{
|
||||
components::{CurrentFloor, Floor, FloorYTarget},
|
||||
events::TransitionFloor,
|
||||
},
|
||||
maze::components::HexMaze,
|
||||
maze::components::{HexMaze, MazeConfig},
|
||||
player::components::{MovementSpeed, Player},
|
||||
};
|
||||
|
||||
@ -18,13 +18,22 @@ use bevy::prelude::*;
|
||||
/// - Removes FloorYTarget component when floor reaches destination
|
||||
pub fn move_floors(
|
||||
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>>,
|
||||
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) 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;
|
||||
if delta.abs() > MOVEMENT_THRESHOLD {
|
||||
let movement = delta.signum() * movement_distance.min(delta.abs());
|
||||
@ -32,6 +41,13 @@ pub fn move_floors(
|
||||
} 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -58,7 +74,7 @@ pub fn handle_floor_transition_events(
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@ -32,11 +32,7 @@ pub fn spawn_floor(
|
||||
|
||||
commands.queue(SpawnMaze {
|
||||
floor: target_floor,
|
||||
config: MazeConfig {
|
||||
start_pos: config.end_pos,
|
||||
radius: config.radius + 1,
|
||||
..default()
|
||||
},
|
||||
config: MazeConfig::from_self(config),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,11 @@ pub mod components;
|
||||
mod systems;
|
||||
|
||||
use bevy::{ecs::system::RunSystemOnce, prelude::*};
|
||||
use components::IdleTimer;
|
||||
|
||||
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) {
|
||||
|
||||
20
src/lib.rs
20
src/lib.rs
@ -1,5 +1,6 @@
|
||||
pub mod asset_tracking;
|
||||
pub mod audio;
|
||||
pub mod camera;
|
||||
pub mod constants;
|
||||
#[cfg(feature = "dev")]
|
||||
pub mod dev_tools;
|
||||
@ -8,6 +9,7 @@ pub mod hint;
|
||||
pub mod maze;
|
||||
pub mod player;
|
||||
pub mod screens;
|
||||
pub mod stats;
|
||||
pub mod theme;
|
||||
|
||||
use bevy::{
|
||||
@ -15,6 +17,7 @@ use bevy::{
|
||||
audio::{AudioPlugin, Volume},
|
||||
prelude::*,
|
||||
};
|
||||
use camera::spawn_camera;
|
||||
use constants::TITLE;
|
||||
use theme::{palette::rose_pine, prelude::ColorScheme};
|
||||
|
||||
@ -69,6 +72,8 @@ impl Plugin for AppPlugin {
|
||||
floor::plugin,
|
||||
player::plugin,
|
||||
hint::plugin,
|
||||
stats::plugin,
|
||||
camera::plugin,
|
||||
));
|
||||
|
||||
// Enable dev tools for dev builds.
|
||||
@ -90,21 +95,6 @@ enum AppSet {
|
||||
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) {
|
||||
let colorcheme = rose_pine::RosePineDawn::Base;
|
||||
commands.insert_resource(ClearColor(colorcheme.to_color()));
|
||||
|
||||
@ -37,8 +37,8 @@ pub struct MazeAssets {
|
||||
impl MazeAssets {
|
||||
/// Creates a new instance of MazeAssets with all necessary meshes and materials.
|
||||
pub fn new(
|
||||
meshes: &mut ResMut<Assets<Mesh>>,
|
||||
materials: &mut ResMut<Assets<StandardMaterial>>,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
global_config: &GlobalMazeConfig,
|
||||
) -> Self {
|
||||
let custom_materials = RosePineDawn::iter()
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
//! 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::GlobalMazeConfig;
|
||||
use super::{coordinates::is_within_radius, GlobalMazeConfig};
|
||||
use crate::floor::components::Floor;
|
||||
|
||||
use bevy::prelude::*;
|
||||
@ -49,31 +49,19 @@ impl MazeConfig {
|
||||
radius: u16,
|
||||
orientation: HexOrientation,
|
||||
seed: Option<u64>,
|
||||
global_conig: &GlobalMazeConfig,
|
||||
global_config: &GlobalMazeConfig,
|
||||
start_pos: Option<Hex>,
|
||||
) -> Self {
|
||||
let seed = seed.unwrap_or_else(|| thread_rng().gen());
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
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 mut end_pos;
|
||||
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 end_pos = generate_end_pos(radius, start_pos, &mut rng);
|
||||
|
||||
let layout = HexLayout {
|
||||
orientation,
|
||||
hex_size: Vec2::splat(global_conig.hex_size),
|
||||
hex_size: Vec2::splat(global_config.hex_size),
|
||||
..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.
|
||||
pub fn update(&mut self, global_conig: &GlobalMazeConfig) {
|
||||
self.layout.hex_size = Vec2::splat(global_conig.hex_size);
|
||||
@ -104,42 +107,48 @@ 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.
|
||||
///
|
||||
/// # 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 {
|
||||
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
|
||||
// Using the formula: max(abs(q), abs(r), abs(s)) <= radius
|
||||
if q.abs().max(r.abs()).max(s.abs()) <= radius {
|
||||
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::*;
|
||||
|
||||
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]
|
||||
#[case(1)]
|
||||
#[case(2)]
|
||||
@ -156,18 +165,8 @@ mod tests {
|
||||
assert_eq!(config.seed, 12345);
|
||||
assert_eq!(config.layout.orientation, orientation);
|
||||
|
||||
assert!(
|
||||
is_within_radius(config.start_pos, radius),
|
||||
"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_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);
|
||||
}
|
||||
|
||||
@ -178,13 +177,13 @@ mod tests {
|
||||
let config = MazeConfig::default();
|
||||
let radius = config.radius;
|
||||
|
||||
assert!(is_within_radius(config.start_pos, radius));
|
||||
assert!(is_within_radius(config.end_pos, 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);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[test]
|
||||
fn maze_config_default_with_seeds() {
|
||||
let test_seeds = [
|
||||
None,
|
||||
@ -206,8 +205,8 @@ mod tests {
|
||||
|
||||
assert_eq!(config.radius, 8);
|
||||
assert_eq!(config.layout.orientation, HexOrientation::Flat);
|
||||
assert!(is_within_radius(config.start_pos, 8));
|
||||
assert!(is_within_radius(config.end_pos, 8));
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -238,12 +237,7 @@ mod tests {
|
||||
|
||||
for _ in 0..10 {
|
||||
let pos = generate_pos(radius, &mut rng);
|
||||
assert!(
|
||||
is_within_radius(pos, radius),
|
||||
"Position {:?} outside radius {}",
|
||||
pos,
|
||||
radius
|
||||
);
|
||||
assert_ok!(is_within_radius(radius, &pos),);
|
||||
}
|
||||
}
|
||||
|
||||
@ -317,4 +311,51 @@ mod tests {
|
||||
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
139
src/maze/coordinates.rs
Normal file
@ -0,0 +1,139 @@
|
||||
use hexx::Hex;
|
||||
|
||||
use super::errors::RadiusError;
|
||||
|
||||
pub trait Coordinates {
|
||||
fn get_coords(&self) -> (i32, i32);
|
||||
}
|
||||
|
||||
impl Coordinates for (i32, i32) {
|
||||
fn get_coords(&self) -> (i32, i32) {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl Coordinates for Hex {
|
||||
fn get_coords(&self) -> (i32, i32) {
|
||||
(self.x, self.y)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_within_radius<R, C>(radius: R, coords: &C) -> Result<bool, RadiusError>
|
||||
where
|
||||
R: Into<i32>,
|
||||
C: Coordinates,
|
||||
{
|
||||
let radius = radius.into();
|
||||
|
||||
if radius < 0 {
|
||||
return Err(RadiusError::NegativeRadius(radius));
|
||||
}
|
||||
|
||||
let (q, r) = coords.get_coords();
|
||||
let s = -q - r; // Calculate third axial coordinate (q + r + s = 0)
|
||||
|
||||
Ok(q.abs().max(r.abs()).max(s.abs()) <= radius)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use claims::*;
|
||||
use rstest::*;
|
||||
|
||||
#[rstest]
|
||||
// Original test cases
|
||||
#[case(0, (0, 0), true)] // Center point
|
||||
#[case(1, (1, 0), true)] // Point at radius 1
|
||||
#[case(1, (2, 0), false)] // Point outside radius 1
|
||||
#[case(2, (2, 0), true)] // East
|
||||
#[case(2, (0, 2), true)] // Southeast
|
||||
#[case(2, (-2, 2), true)] // Southwest
|
||||
#[case(2, (-2, 0), true)] // West
|
||||
#[case(2, (0, -2), true)] // Northwest
|
||||
#[case(2, (2, -2), true)] // Northeast
|
||||
#[case(2, (3, 0), false)] // Just outside radius 2
|
||||
// Large radius test cases
|
||||
#[case(6, (6, 0), true)] // East at radius 6
|
||||
#[case(6, (0, 6), true)] // Southeast at radius 6
|
||||
#[case(6, (-6, 6), true)] // Southwest at radius 6
|
||||
#[case(6, (-6, 0), true)] // West at radius 6
|
||||
#[case(6, (0, -6), true)] // Northwest at radius 6
|
||||
#[case(6, (6, -6), true)] // Northeast at radius 6
|
||||
#[case(6, (7, 0), false)] // Just outside radius 6 east
|
||||
#[case(6, (4, 4), false)] // Outside radius 6 diagonal
|
||||
#[case(6, (5, 5), false)] // Outside radius 6 diagonal
|
||||
// Edge cases with large radius
|
||||
#[case(6, (6, -3), true)] // Complex position within radius 6
|
||||
#[case(6, (-3, 6), true)] // Complex position within radius 6
|
||||
#[case(6, (3, -6), true)] // Complex position within radius 6
|
||||
#[case(6, (7, -7), false)] // Outside radius 6 corner
|
||||
fn valid_radius_tuple(#[case] radius: i32, #[case] pos: (i32, i32), #[case] expected: bool) {
|
||||
let result = is_within_radius(radius, &pos);
|
||||
assert_ok_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
// Large radius test cases for Hex struct
|
||||
#[case(6, (6, 0), true)] // East at radius 6
|
||||
#[case(6, (0, 6), true)] // Southeast at radius 6
|
||||
#[case(6, (-6, 6), true)] // Southwest at radius 6
|
||||
#[case(6, (-6, 0), true)] // West at radius 6
|
||||
#[case(6, (0, -6), true)] // Northwest at radius 6
|
||||
#[case(6, (6, -6), true)] // Northeast at radius 6
|
||||
#[case(6, (4, 4), false)] // Outside radius 6 diagonal
|
||||
#[case(6, (5, 5), false)] // Outside radius 6 diagonal
|
||||
fn valid_radius_hex(#[case] radius: i32, #[case] pos: (i32, i32), #[case] expected: bool) {
|
||||
let hex = Hex::from(pos);
|
||||
let result = is_within_radius(radius, &hex);
|
||||
assert_ok_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case(-1)]
|
||||
#[case(-2)]
|
||||
#[case(-5)]
|
||||
fn negative_radius(#[case] radius: i32) {
|
||||
let result = is_within_radius(radius, &(0, 0));
|
||||
assert_err!(&result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_points() {
|
||||
let radius = 3;
|
||||
// Test points exactly on the boundary of radius 3
|
||||
assert_ok_eq!(is_within_radius(radius, &(3, 0)), true); // East boundary
|
||||
assert_ok_eq!(is_within_radius(radius, &(0, 3)), true); // Southeast boundary
|
||||
assert_ok_eq!(is_within_radius(radius, &(-3, 3)), true); // Southwest boundary
|
||||
assert_ok_eq!(is_within_radius(radius, &(-3, 0)), true); // West boundary
|
||||
assert_ok_eq!(is_within_radius(radius, &(0, -3)), true); // Northwest boundary
|
||||
assert_ok_eq!(is_within_radius(radius, &(3, -3)), true); // Northeast boundary
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_boundary_points() {
|
||||
let radius = 6;
|
||||
// Test points exactly on the boundary of radius 6
|
||||
assert_ok_eq!(is_within_radius(radius, &(6, 0)), true); // East boundary
|
||||
assert_ok_eq!(is_within_radius(radius, &(0, 6)), true); // Southeast boundary
|
||||
assert_ok_eq!(is_within_radius(radius, &(-6, 6)), true); // Southwest boundary
|
||||
assert_ok_eq!(is_within_radius(radius, &(-6, 0)), true); // West boundary
|
||||
assert_ok_eq!(is_within_radius(radius, &(0, -6)), true); // Northwest boundary
|
||||
assert_ok_eq!(is_within_radius(radius, &(6, -6)), true); // Northeast boundary
|
||||
|
||||
// Test points just outside the boundary
|
||||
assert_ok_eq!(is_within_radius(radius, &(7, 0)), false); // Just outside east
|
||||
assert_ok_eq!(is_within_radius(radius, &(0, 7)), false); // Just outside southeast
|
||||
assert_ok_eq!(is_within_radius(radius, &(-7, 7)), false); // Just outside southwest
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_coordinate_types() {
|
||||
// Test with tuple coordinates
|
||||
assert_ok_eq!(is_within_radius(2, &(1, 1)), true);
|
||||
|
||||
// Test with Hex struct
|
||||
let hex = Hex { x: 1, y: 1 };
|
||||
assert_ok_eq!(is_within_radius(2, &hex), true);
|
||||
}
|
||||
}
|
||||
@ -25,7 +25,11 @@ pub enum MazeError {
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub type MazeResult<T> = Result<T, MazeError>;
|
||||
#[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 {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
mod assets;
|
||||
pub mod commands;
|
||||
pub mod components;
|
||||
pub mod coordinates;
|
||||
pub mod errors;
|
||||
pub mod resources;
|
||||
mod systems;
|
||||
|
||||
@ -2,7 +2,13 @@ pub mod common;
|
||||
pub mod despawn;
|
||||
pub mod respawn;
|
||||
pub mod spawn;
|
||||
mod toggle_pause;
|
||||
|
||||
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>));
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ use crate::{
|
||||
components::{HexMaze, MazeConfig, Tile, Wall},
|
||||
resources::GlobalMazeConfig,
|
||||
},
|
||||
screens::Screen,
|
||||
screens::GameplayElement,
|
||||
theme::palette::rose_pine::RosePineDawn,
|
||||
};
|
||||
|
||||
@ -62,12 +62,13 @@ pub fn spawn_maze(
|
||||
config.clone(),
|
||||
Transform::from_translation(Vec3::ZERO.with_y(y_offset)),
|
||||
Visibility::Visible,
|
||||
StateScoped(Screen::Gameplay),
|
||||
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,
|
||||
|
||||
13
src/maze/systems/toggle_pause.rs
Normal file
13
src/maze/systems/toggle_pause.rs
Normal file
@ -0,0 +1,13 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::{maze::components::Wall, screens::Screen};
|
||||
|
||||
pub fn toggle_walls(mut query: Query<&mut Visibility, With<Wall>>, state: Res<State<Screen>>) {
|
||||
for mut visibility in query.iter_mut() {
|
||||
*visibility = match *state.get() {
|
||||
Screen::Gameplay => Visibility::Inherited,
|
||||
Screen::Pause => Visibility::Hidden,
|
||||
_ => *visibility,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
mod assets;
|
||||
pub mod assets;
|
||||
pub mod commands;
|
||||
pub mod components;
|
||||
mod systems;
|
||||
|
||||
@ -4,6 +4,7 @@ mod movement;
|
||||
pub mod respawn;
|
||||
mod sound_effect;
|
||||
pub mod spawn;
|
||||
mod toggle_pause;
|
||||
mod vertical_transition;
|
||||
|
||||
use crate::{screens::Screen, AppSet};
|
||||
@ -11,6 +12,7 @@ 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;
|
||||
@ -30,4 +32,5 @@ pub(super) fn plugin(app: &mut App) {
|
||||
.chain()
|
||||
.run_if(in_state(Screen::Gameplay)),
|
||||
);
|
||||
app.add_systems(Update, toggle_player.run_if(state_changed::<Screen>));
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ use crate::{
|
||||
assets::{blue_material, generate_pill_mesh},
|
||||
components::{CurrentPosition, Player},
|
||||
},
|
||||
screens::Screen,
|
||||
screens::GameplayElement,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
@ -33,6 +33,6 @@ pub fn spawn_player(
|
||||
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),
|
||||
StateScoped(Screen::Gameplay),
|
||||
GameplayElement,
|
||||
));
|
||||
}
|
||||
|
||||
13
src/player/systems/toggle_pause.rs
Normal file
13
src/player/systems/toggle_pause.rs
Normal file
@ -0,0 +1,13 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::{player::components::Player, screens::Screen};
|
||||
|
||||
pub fn toggle_player(mut query: Query<&mut Visibility, With<Player>>, state: Res<State<Screen>>) {
|
||||
for mut visibility in query.iter_mut() {
|
||||
*visibility = match *state.get() {
|
||||
Screen::Gameplay => Visibility::Visible,
|
||||
Screen::Pause => Visibility::Hidden,
|
||||
_ => *visibility,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,29 +1,62 @@
|
||||
//! The screen state for the main gameplay.
|
||||
|
||||
use crate::player::spawn_player_command;
|
||||
use crate::screens::Screen;
|
||||
use crate::{hint::spawn_hint_command, maze::spawn_level_command};
|
||||
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::*};
|
||||
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.init_resource::<GameplayInitialized>();
|
||||
app.add_systems(
|
||||
OnEnter(Screen::Gameplay),
|
||||
(
|
||||
spawn_level_command,
|
||||
spawn_player_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(
|
||||
Update,
|
||||
return_to_title_screen
|
||||
.run_if(in_state(Screen::Gameplay).and(input_just_pressed(KeyCode::Escape))),
|
||||
pause_game.run_if(in_state(Screen::Gameplay).and(input_just_pressed(KeyCode::Escape))),
|
||||
);
|
||||
}
|
||||
|
||||
fn return_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
|
||||
next_screen.set(Screen::Title);
|
||||
fn pause_game(mut next_screen: ResMut<NextState<Screen>>) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,10 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::{
|
||||
hint::assets::HintAssets,
|
||||
player::assets::PlayerAssets,
|
||||
screens::Screen,
|
||||
theme::{interaction::InteractionAssets, prelude::*},
|
||||
theme::{assets::InteractionAssets, prelude::*},
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const fn all_assets_loaded(interaction_assets: Option<Res<InteractionAssets>>) -> bool {
|
||||
interaction_assets.is_some()
|
||||
const fn all_assets_loaded(
|
||||
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()
|
||||
}
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
mod gameplay;
|
||||
mod loading;
|
||||
mod pause;
|
||||
mod splash;
|
||||
mod title;
|
||||
|
||||
use bevy::prelude::*;
|
||||
pub use gameplay::{GameplayElement, GameplayInitialized};
|
||||
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.init_state::<Screen>();
|
||||
@ -16,6 +18,7 @@ pub(super) fn plugin(app: &mut App) {
|
||||
loading::plugin,
|
||||
splash::plugin,
|
||||
title::plugin,
|
||||
pause::plugin,
|
||||
));
|
||||
}
|
||||
|
||||
@ -28,4 +31,5 @@ pub enum Screen {
|
||||
Loading,
|
||||
Title,
|
||||
Gameplay,
|
||||
Pause,
|
||||
}
|
||||
|
||||
57
src/screens/pause.rs
Normal file
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);
|
||||
}
|
||||
21
src/stats/components.rs
Normal file
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
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
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
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
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
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
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
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
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
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
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
23
src/stats/systems/total_timer.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::stats::{components::TotalTimerDisplay, resources::TotalTimer};
|
||||
|
||||
use super::common::format_duration_adaptive;
|
||||
|
||||
pub fn update_total_timer(mut total_timer: ResMut<TotalTimer>, time: Res<Time>) {
|
||||
total_timer.tick(time.delta());
|
||||
}
|
||||
|
||||
pub fn update_total_timer_display(
|
||||
mut text_query: Query<&mut Text, With<TotalTimerDisplay>>,
|
||||
total_timer: Res<TotalTimer>,
|
||||
) {
|
||||
let Ok(mut text) = text_query.get_single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
text.0 = format!(
|
||||
"Total Timer: {}",
|
||||
format_duration_adaptive(total_timer.0.elapsed_secs())
|
||||
);
|
||||
}
|
||||
24
src/theme/assets.rs
Normal file
24
src/theme/assets.rs
Normal file
@ -0,0 +1,24 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Resource, Asset, Reflect, Clone)]
|
||||
pub struct InteractionAssets {
|
||||
#[dependency]
|
||||
pub(super) hover: Handle<AudioSource>,
|
||||
#[dependency]
|
||||
pub(super) press: Handle<AudioSource>,
|
||||
}
|
||||
|
||||
impl InteractionAssets {
|
||||
pub const PATH_BUTTON_HOVER: &'static str = "audio/sound_effects/button_hover.ogg";
|
||||
pub const PATH_BUTTON_PRESS: &'static str = "audio/sound_effects/button_press.ogg";
|
||||
}
|
||||
|
||||
impl FromWorld for InteractionAssets {
|
||||
fn from_world(world: &mut World) -> Self {
|
||||
let assets = world.resource::<AssetServer>();
|
||||
Self {
|
||||
hover: assets.load(Self::PATH_BUTTON_HOVER),
|
||||
press: assets.load(Self::PATH_BUTTON_PRESS),
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/theme/components.rs
Normal file
16
src/theme/components.rs
Normal file
@ -0,0 +1,16 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Palette for widget interactions. Add this to an entity that supports
|
||||
/// [`Interaction`]s, such as a button, to change its [`BackgroundColor`] based
|
||||
/// on the current interaction state.
|
||||
#[derive(Component, Debug, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct InteractionPalette {
|
||||
pub none: Color,
|
||||
pub hovered: Color,
|
||||
pub pressed: Color,
|
||||
}
|
||||
|
||||
#[derive(Debug, Reflect, Component)]
|
||||
#[reflect(Component)]
|
||||
pub struct UrlLink(pub String);
|
||||
6
src/theme/events.rs
Normal file
6
src/theme/events.rs
Normal file
@ -0,0 +1,6 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Event triggered on a UI entity when the [`Interaction`] component on the same entity changes to
|
||||
/// [`Interaction::Pressed`]. Observe this event to detect e.g. button presses.
|
||||
#[derive(Event)]
|
||||
pub struct OnPress;
|
||||
@ -1,102 +0,0 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::{asset_tracking::LoadResource, audio::SoundEffect};
|
||||
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.register_type::<InteractionPalette>();
|
||||
app.load_resource::<InteractionAssets>();
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
trigger_on_press,
|
||||
apply_interaction_palette,
|
||||
trigger_interaction_sound_effect,
|
||||
)
|
||||
.run_if(resource_exists::<InteractionAssets>),
|
||||
);
|
||||
}
|
||||
|
||||
/// Palette for widget interactions. Add this to an entity that supports
|
||||
/// [`Interaction`]s, such as a button, to change its [`BackgroundColor`] based
|
||||
/// on the current interaction state.
|
||||
#[derive(Component, Debug, Reflect)]
|
||||
#[reflect(Component)]
|
||||
pub struct InteractionPalette {
|
||||
pub none: Color,
|
||||
pub hovered: Color,
|
||||
pub pressed: Color,
|
||||
}
|
||||
|
||||
/// Event triggered on a UI entity when the [`Interaction`] component on the same entity changes to
|
||||
/// [`Interaction::Pressed`]. Observe this event to detect e.g. button presses.
|
||||
#[derive(Event)]
|
||||
pub struct OnPress;
|
||||
|
||||
fn trigger_on_press(
|
||||
interaction_query: Query<(Entity, &Interaction), Changed<Interaction>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
for (entity, interaction) in &interaction_query {
|
||||
if matches!(interaction, Interaction::Pressed) {
|
||||
commands.trigger_targets(OnPress, entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_interaction_palette(
|
||||
mut palette_query: Query<
|
||||
(&Interaction, &InteractionPalette, &mut BackgroundColor),
|
||||
Changed<Interaction>,
|
||||
>,
|
||||
) {
|
||||
for (interaction, palette, mut background) in &mut palette_query {
|
||||
*background = match interaction {
|
||||
Interaction::None => palette.none,
|
||||
Interaction::Hovered => palette.hovered,
|
||||
Interaction::Pressed => palette.pressed,
|
||||
}
|
||||
.into();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource, Asset, Reflect, Clone)]
|
||||
pub struct InteractionAssets {
|
||||
#[dependency]
|
||||
hover: Handle<AudioSource>,
|
||||
#[dependency]
|
||||
press: Handle<AudioSource>,
|
||||
}
|
||||
|
||||
impl InteractionAssets {
|
||||
pub const PATH_BUTTON_HOVER: &'static str = "audio/sound_effects/button_hover.ogg";
|
||||
pub const PATH_BUTTON_PRESS: &'static str = "audio/sound_effects/button_press.ogg";
|
||||
}
|
||||
|
||||
impl FromWorld for InteractionAssets {
|
||||
fn from_world(world: &mut World) -> Self {
|
||||
let assets = world.resource::<AssetServer>();
|
||||
Self {
|
||||
hover: assets.load(Self::PATH_BUTTON_HOVER),
|
||||
press: assets.load(Self::PATH_BUTTON_PRESS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger_interaction_sound_effect(
|
||||
interaction_query: Query<&Interaction, Changed<Interaction>>,
|
||||
interaction_assets: Res<InteractionAssets>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
for interaction in &interaction_query {
|
||||
let source = match interaction {
|
||||
Interaction::Hovered => interaction_assets.hover.clone(),
|
||||
Interaction::Pressed => interaction_assets.press.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
commands.spawn((
|
||||
AudioPlayer::<AudioSource>(source),
|
||||
PlaybackSettings::DESPAWN,
|
||||
SoundEffect,
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -2,23 +2,33 @@
|
||||
|
||||
// Unused utilities may trigger this lints undesirably.
|
||||
|
||||
pub mod assets;
|
||||
mod colorscheme;
|
||||
pub mod interaction;
|
||||
pub mod components;
|
||||
pub mod events;
|
||||
pub mod palette;
|
||||
mod widgets;
|
||||
mod systems;
|
||||
pub mod widgets;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub mod prelude {
|
||||
pub use super::{
|
||||
colorscheme::{ColorScheme, ColorSchemeWrapper},
|
||||
interaction::{InteractionPalette, OnPress},
|
||||
components::{InteractionPalette, UrlLink},
|
||||
events::OnPress,
|
||||
palette as ui_palette,
|
||||
widgets::{Containers as _, Widgets as _},
|
||||
};
|
||||
}
|
||||
|
||||
use assets::InteractionAssets;
|
||||
use bevy::prelude::*;
|
||||
use prelude::InteractionPalette;
|
||||
|
||||
use crate::asset_tracking::LoadResource;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
52
src/theme/systems/button.rs
Normal file
52
src/theme/systems/button.rs
Normal 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
18
src/theme/systems/mod.rs
Normal 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>),
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,13 @@
|
||||
//! 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 super::prelude::ColorScheme;
|
||||
use crate::theme::{interaction::InteractionPalette, palette::*};
|
||||
use super::prelude::{ColorScheme, InteractionPalette};
|
||||
use crate::theme::palette::*;
|
||||
|
||||
/// An extension trait for spawning UI widgets.
|
||||
pub trait Widgets {
|
||||
@ -16,6 +19,8 @@ pub trait Widgets {
|
||||
|
||||
/// Spawn a simple text label.
|
||||
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 {
|
||||
@ -35,6 +40,7 @@ impl<T: SpawnUi> Widgets for T {
|
||||
border: UiRect::all(Px(4.)),
|
||||
..default()
|
||||
},
|
||||
CursorIcon::System(SystemCursorIcon::Pointer),
|
||||
BorderRadius::all(Px(8.)),
|
||||
BorderColor(RosePineDawn::Text.to_color()),
|
||||
InteractionPalette {
|
||||
@ -103,6 +109,21 @@ impl<T: SpawnUi> Widgets for T {
|
||||
));
|
||||
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.
|
||||
|
||||
@ -1,21 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description"
|
||||
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">
|
||||
<title>Maze Ascension: The Labyrinth of Echoes</title>
|
||||
<link data-trunk rel="copy-dir" href="../assets" />
|
||||
<link data-trunk rel="inline" href="style.css" />
|
||||
<link data-trunk rel="inline" type="module" href="restart-audio-context.js" />
|
||||
<link data-trunk
|
||||
rel="rust"
|
||||
data-cargo-no-default-features
|
||||
data-wasm-opt="s"
|
||||
href="../" />
|
||||
</head>
|
||||
<body>
|
||||
<link data-trunk rel="rust" data-cargo-no-default-features data-wasm-opt="s" href="../" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="game" class="center">
|
||||
<div id="loading-screen" class="center">
|
||||
<span class="spinner"></span>
|
||||
@ -32,7 +29,8 @@
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(bevy, { attributeFilter: ["height"] });
|
||||
observer.observe(bevy, {attributeFilter: ["height"]});
|
||||
</script>
|
||||
</body>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user