Compare commits

..

No commits in common. "main" and "v1.0.2" have entirely different histories.
main ... v1.0.2

46 changed files with 223 additions and 1225 deletions

68
Cargo.lock generated
View File

@ -1409,7 +1409,7 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"rustc-hash 1.1.0",
"rustc-hash",
"shlex",
"syn",
]
@ -1614,12 +1614,6 @@ 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"
@ -1813,7 +1807,7 @@ dependencies = [
"log",
"rangemap",
"rayon",
"rustc-hash 1.1.0",
"rustc-hash",
"rustybuzz",
"self_cell",
"swash",
@ -1925,18 +1919,6 @@ 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"
@ -2656,16 +2638,15 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "hexlab"
version = "0.6.1"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e247b21d6885136e4a910e1e6fac755d8dc56112c40ebf1062582f0644d62c62"
checksum = "7d2fbc6c41965686841aa5ea0e1af448730d0902274e49251c7d1fb7c78fffb9"
dependencies = [
"bevy",
"bevy_reflect",
"bevy_utils",
"glam",
"hexx",
"pathfinding",
"rand",
"thiserror 2.0.6",
]
@ -2907,15 +2888,6 @@ 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"
@ -3157,18 +3129,16 @@ dependencies = [
[[package]]
name = "maze-ascension"
version = "1.1.3"
version = "1.0.2"
dependencies = [
"anyhow",
"bevy",
"bevy-inspector-egui",
"bevy_egui",
"claims",
"hexlab",
"hexx",
"log",
"rand",
"rayon",
"rstest",
"rstest_reuse",
"strum",
@ -3250,7 +3220,7 @@ dependencies = [
"indexmap",
"log",
"pp-rs",
"rustc-hash 1.1.0",
"rustc-hash",
"spirv",
"termcolor",
"thiserror 1.0.69",
@ -3271,7 +3241,7 @@ dependencies = [
"once_cell",
"regex",
"regex-syntax 0.8.5",
"rustc-hash 1.1.0",
"rustc-hash",
"thiserror 1.0.69",
"tracing",
"unicode-ident",
@ -3785,20 +3755,6 @@ 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"
@ -4220,12 +4176,6 @@ 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"
@ -5266,7 +5216,7 @@ dependencies = [
"parking_lot",
"profiling",
"raw-window-handle",
"rustc-hash 1.1.0",
"rustc-hash",
"smallvec",
"thiserror 1.0.69",
"wgpu-hal",
@ -5308,7 +5258,7 @@ dependencies = [
"range-alloc",
"raw-window-handle",
"renderdoc-sys",
"rustc-hash 1.1.0",
"rustc-hash",
"smallvec",
"thiserror 1.0.69",
"wasm-bindgen",

View File

@ -1,7 +1,7 @@
[package]
name = "maze-ascension"
authors = ["Kristofers Solo <dev@kristofers.xyz>"]
version = "1.1.3"
version = "1.0.2"
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.6", features = ["bevy", "pathfinding"] }
hexlab = { version = "0.5", features = ["bevy"] }
bevy-inspector-egui = { version = "0.28", optional = true }
bevy_egui = { version = "0.31", optional = true }
thiserror = "2.0"
@ -26,8 +26,6 @@ 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 = [

View File

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

View File

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

View File

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

View File

@ -1,19 +0,0 @@
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,12 +1,10 @@
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;
@ -18,7 +16,6 @@ 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)),

View File

@ -4,7 +4,7 @@ use crate::{
components::{CurrentFloor, Floor, FloorYTarget},
events::TransitionFloor,
},
maze::components::{HexMaze, MazeConfig},
maze::components::HexMaze,
player::components::{MovementSpeed, Player},
};
@ -18,22 +18,13 @@ 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,
&MazeConfig,
Has<CurrentFloor>,
),
With<FloorYTarget>,
>,
mut maze_query: Query<(Entity, &mut Transform, &FloorYTarget), With<FloorYTarget>>,
player_query: Query<&MovementSpeed, With<Player>>,
time: Res<Time>,
) {
let speed = player_query.get_single().map_or(100., |s| s.0);
let movement_distance = speed * time.delta_secs();
for (entity, mut transform, movement_state, config, is_current_floor) in maze_query.iter_mut() {
for (entity, mut transform, movement_state) 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());
@ -41,13 +32,6 @@ 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,
);
}
}
}
}
@ -74,7 +58,7 @@ pub fn handle_floor_transition_events(
}
for event in event_reader.read() {
let Ok((current_entity, current_floor)) = current_query.get_single() else {
let Some((current_entity, current_floor)) = current_query.get_single().ok() else {
continue;
};

View File

@ -32,7 +32,11 @@ pub fn spawn_floor(
commands.queue(SpawnMaze {
floor: target_floor,
config: MazeConfig::from_self(config),
config: MazeConfig {
start_pos: config.end_pos,
radius: config.radius + 1,
..default()
},
});
}
}

View File

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

View File

@ -1,6 +1,5 @@
pub mod asset_tracking;
pub mod audio;
pub mod camera;
pub mod constants;
#[cfg(feature = "dev")]
pub mod dev_tools;
@ -9,7 +8,6 @@ pub mod hint;
pub mod maze;
pub mod player;
pub mod screens;
pub mod stats;
pub mod theme;
use bevy::{
@ -17,7 +15,6 @@ use bevy::{
audio::{AudioPlugin, Volume},
prelude::*,
};
use camera::spawn_camera;
use constants::TITLE;
use theme::{palette::rose_pine, prelude::ColorScheme};
@ -72,8 +69,6 @@ impl Plugin for AppPlugin {
floor::plugin,
player::plugin,
hint::plugin,
stats::plugin,
camera::plugin,
));
// Enable dev tools for dev builds.
@ -95,6 +90,21 @@ 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()));

View File

@ -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::{coordinates::is_within_radius, GlobalMazeConfig};
use super::GlobalMazeConfig;
use crate::floor::components::Floor;
use bevy::prelude::*;
@ -49,19 +49,31 @@ impl MazeConfig {
radius: u16,
orientation: HexOrientation,
seed: Option<u64>,
global_config: &GlobalMazeConfig,
global_conig: &GlobalMazeConfig,
start_pos: Option<Hex>,
) -> Self {
let (seed, mut rng) = setup_rng(seed);
let seed = seed.unwrap_or_else(|| thread_rng().gen());
let mut rng = StdRng::seed_from_u64(seed);
let start_pos = start_pos.unwrap_or_else(|| generate_pos(radius, &mut rng));
// Generate end position ensuring start and end are different
let end_pos = generate_end_pos(radius, start_pos, &mut rng);
let 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 layout = HexLayout {
orientation,
hex_size: Vec2::splat(global_config.hex_size),
hex_size: Vec2::splat(global_conig.hex_size),
..default()
};
@ -74,21 +86,6 @@ 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);
@ -107,38 +104,21 @@ 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 {
// 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));
let r = rng.gen_range(-radius..=radius);
let s = -q - r; // Calculate third coordinate (axial coordinates: q + r + s = 0)
if let Ok(is_valid) = is_within_radius(radius, &(q, r)) {
if is_valid {
return Hex::new(q, r);
}
// 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 {
return Hex::new(q, r);
}
}
}
@ -146,9 +126,20 @@ fn generate_pos<R: Rng>(radius: u16, rng: &mut R) -> Hex {
#[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)]
@ -165,8 +156,18 @@ mod tests {
assert_eq!(config.seed, 12345);
assert_eq!(config.layout.orientation, orientation);
assert_ok!(is_within_radius(radius, &config.start_pos),);
assert_ok!(is_within_radius(radius, &config.end_pos));
assert!(
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_ne!(config.start_pos, config.end_pos);
}
@ -177,13 +178,13 @@ mod tests {
let config = MazeConfig::default();
let radius = config.radius;
assert_ok!(is_within_radius(radius, &config.start_pos));
assert_ok!(is_within_radius(radius, &config.end_pos));
assert!(is_within_radius(config.start_pos, radius));
assert!(is_within_radius(config.end_pos, radius));
assert_ne!(config.start_pos, config.end_pos);
}
}
#[test]
#[rstest]
fn maze_config_default_with_seeds() {
let test_seeds = [
None,
@ -205,8 +206,8 @@ mod tests {
assert_eq!(config.radius, 8);
assert_eq!(config.layout.orientation, HexOrientation::Flat);
assert_ok!(is_within_radius(8, &config.start_pos));
assert_ok!(is_within_radius(8, &config.end_pos));
assert!(is_within_radius(config.start_pos, 8));
assert!(is_within_radius(config.end_pos, 8));
assert_ne!(config.start_pos, config.end_pos);
}
}
@ -237,7 +238,12 @@ mod tests {
for _ in 0..10 {
let pos = generate_pos(radius, &mut rng);
assert_ok!(is_within_radius(radius, &pos),);
assert!(
is_within_radius(pos, radius),
"Position {:?} outside radius {}",
pos,
radius
);
}
}
@ -311,51 +317,4 @@ 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));
}
}
}

View File

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

View File

@ -1,7 +1,6 @@
mod assets;
pub mod commands;
pub mod components;
pub mod coordinates;
pub mod errors;
pub mod resources;
mod systems;

View File

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

View File

@ -15,7 +15,7 @@ use crate::{
components::{HexMaze, MazeConfig, Tile, Wall},
resources::GlobalMazeConfig,
},
screens::GameplayElement,
screens::Screen,
theme::palette::rose_pine::RosePineDawn,
};
@ -62,13 +62,12 @@ pub fn spawn_maze(
config.clone(),
Transform::from_translation(Vec3::ZERO.with_y(y_offset)),
Visibility::Visible,
GameplayElement,
StateScoped(Screen::Gameplay),
))
.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,

View File

@ -1,13 +0,0 @@
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

@ -4,7 +4,6 @@ mod movement;
pub mod respawn;
mod sound_effect;
pub mod spawn;
mod toggle_pause;
mod vertical_transition;
use crate::{screens::Screen, AppSet};
@ -12,7 +11,6 @@ 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;
@ -32,5 +30,4 @@ 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>));
}

View File

@ -5,7 +5,7 @@ use crate::{
assets::{blue_material, generate_pill_mesh},
components::{CurrentPosition, Player},
},
screens::GameplayElement,
screens::Screen,
};
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),
GameplayElement,
StateScoped(Screen::Gameplay),
));
}

View File

@ -1,13 +0,0 @@
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,62 +1,29 @@
//! The screen state for the main gameplay.
use crate::{
hint::spawn_hint_command, maze::spawn_level_command, player::spawn_player_command,
screens::Screen, stats::spawn_stats_command,
};
use crate::player::spawn_player_command;
use crate::screens::Screen;
use crate::{hint::spawn_hint_command, maze::spawn_level_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()
.run_if(not(resource_exists::<GameplayInitialized>)),
.chain(),
);
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,
pause_game.run_if(in_state(Screen::Gameplay).and(input_just_pressed(KeyCode::Escape))),
return_to_title_screen
.run_if(in_state(Screen::Gameplay).and(input_just_pressed(KeyCode::Escape))),
);
}
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();
}
}
fn return_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title);
}

View File

@ -7,7 +7,7 @@ use crate::{
hint::assets::HintAssets,
player::assets::PlayerAssets,
screens::Screen,
theme::{assets::InteractionAssets, prelude::*},
theme::{interaction::InteractionAssets, prelude::*},
};
pub fn plugin(app: &mut App) {

View File

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

View File

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

View File

@ -1,21 +0,0 @@
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;

View File

@ -1,22 +0,0 @@
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()
},
))
}
}

View File

@ -1,18 +0,0 @@
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);
}

View File

@ -1,31 +0,0 @@
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

@ -1,28 +0,0 @@
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

@ -1,39 +0,0 @@
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

@ -1,33 +0,0 @@
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())
);
}

View File

@ -1,38 +0,0 @@
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

@ -1,13 +0,0 @@
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;
}

View File

@ -1,188 +0,0 @@
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

@ -1,25 +0,0 @@
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

@ -1,23 +0,0 @@
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())
);
}

View File

@ -1,24 +0,0 @@
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),
}
}
}

View File

@ -1,16 +0,0 @@
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);

View File

@ -1,6 +0,0 @@
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;

102
src/theme/interaction.rs Normal file
View File

@ -0,0 +1,102 @@
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,33 +2,23 @@
// Unused utilities may trigger this lints undesirably.
pub mod assets;
mod colorscheme;
pub mod components;
pub mod events;
pub mod interaction;
pub mod palette;
mod systems;
pub mod widgets;
mod widgets;
#[allow(unused_imports)]
pub mod prelude {
pub use super::{
colorscheme::{ColorScheme, ColorSchemeWrapper},
components::{InteractionPalette, UrlLink},
events::OnPress,
interaction::{InteractionPalette, 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.register_type::<InteractionPalette>();
app.load_resource::<InteractionAssets>();
app.add_plugins(systems::plugin);
app.add_plugins(interaction::plugin);
}

View File

@ -1,52 +0,0 @@
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,
));
}
}

View File

@ -1,18 +0,0 @@
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,13 +1,10 @@
//! Helper traits for creating common widgets.
use bevy::{
ecs::system::EntityCommands, prelude::*, ui::Val::*, window::SystemCursorIcon,
winit::cursor::CursorIcon,
};
use bevy::{ecs::system::EntityCommands, prelude::*, ui::Val::*};
use rose_pine::RosePineDawn;
use super::prelude::{ColorScheme, InteractionPalette};
use crate::theme::palette::*;
use super::prelude::ColorScheme;
use crate::theme::{interaction::InteractionPalette, palette::*};
/// An extension trait for spawning UI widgets.
pub trait Widgets {
@ -19,8 +16,6 @@ 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 {
@ -40,7 +35,6 @@ 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 {
@ -109,21 +103,6 @@ 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.