Compare commits

...

24 Commits
v1.0.3 ... main

Author SHA1 Message Date
ed77d18e1e
Merge pull request #42 from kristoferssolo/fix/visibility 2025-01-18 17:34:58 +02:00
5dd932a6fe fix: wall visibility 2025-01-18 17:34:13 +02:00
d820b19988
Merge pull request #41 from kristoferssolo/feature/hide-floors 2025-01-18 16:59:43 +02:00
f08bd72038 feat(floors): hide floors above #18 2025-01-18 16:58:43 +02:00
4864ecca93
Merge pull request #40 from kristoferssolo/feature/pause-screen 2025-01-18 16:41:57 +02:00
7fa567d522 feat: add game entity cleanup 2025-01-18 16:41:10 +02:00
7a4bcd81f9 feat(pause): hide walls and player when paused 2025-01-18 16:41:10 +02:00
5d50daf768 feat(screens): add translucent pause screen #36 2025-01-18 16:41:10 +02:00
e7bdb37093 feat(sceen): add pause screen 2025-01-18 15:52:06 +02:00
c8e968e76e
Merge pull request #39 from kristoferssolo/fix/web-zoom 2025-01-17 15:17:22 +02:00
099d163325 refactor(score): change scoring algorithm 2025-01-17 15:16:40 +02:00
4398620ac8 fix: scroll wheel sensitivity in WASM 2025-01-17 14:27:02 +02:00
e1fa12b6b9
Merge pull request #38 from kristoferssolo/fix/clippy 2025-01-17 13:52:00 +02:00
df4dcdf3cb chore: bump version number 2025-01-17 13:51:30 +02:00
62a91f5765 fix: clippy warnings 2025-01-17 12:41:22 +02:00
48a39d4430
Merge pull request #37 from kristoferssolo/feature/camera 2025-01-17 12:12:51 +02:00
88c46d679d feat(camera): add camera controls #34 2025-01-17 12:09:55 +02:00
d01e987b89
Merge pull request #35 from kristoferssolo/feture/score 2025-01-17 00:58:07 +02:00
5e1e4a546a refactor(stats): make score a resource 2025-01-17 00:56:26 +02:00
58276ea8f7 feat(score): add score calculator 2025-01-17 00:41:16 +02:00
d2dd57bcff feat: add game stats 2025-01-16 23:26:37 +02:00
472a238a1c chore: update hexlab version 2025-01-16 21:48:56 +02:00
2bd115a714
Merge pull request #32 from kristoferssolo/revert/speed 2025-01-10 19:28:13 +02:00
5a7c92cd96 revert: player speed 2025-01-10 19:27:50 +02:00
36 changed files with 811 additions and 53 deletions

60
Cargo.lock generated
View File

@ -1409,7 +1409,7 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"rustc-hash 1.1.0",
"shlex",
"syn",
]
@ -1813,7 +1813,7 @@ dependencies = [
"log",
"rangemap",
"rayon",
"rustc-hash",
"rustc-hash 1.1.0",
"rustybuzz",
"self_cell",
"swash",
@ -1925,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"
@ -2644,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",
]
@ -2894,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"
@ -3135,7 +3157,7 @@ dependencies = [
[[package]]
name = "maze-ascension"
version = "1.0.3"
version = "1.1.3"
dependencies = [
"anyhow",
"bevy",
@ -3228,7 +3250,7 @@ dependencies = [
"indexmap",
"log",
"pp-rs",
"rustc-hash",
"rustc-hash 1.1.0",
"spirv",
"termcolor",
"thiserror 1.0.69",
@ -3249,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",
@ -3763,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"
@ -4184,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"
@ -5224,7 +5266,7 @@ dependencies = [
"parking_lot",
"profiling",
"raw-window-handle",
"rustc-hash",
"rustc-hash 1.1.0",
"smallvec",
"thiserror 1.0.69",
"wgpu-hal",
@ -5266,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",

View File

@ -1,7 +1,7 @@
[package]
name = "maze-ascension"
authors = ["Kristofers Solo <dev@kristofers.xyz>"]
version = "1.0.3"
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"

View File

@ -8,7 +8,7 @@ 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:

64
src/camera.rs Normal file
View File

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

View File

@ -3,3 +3,24 @@ pub const WALL_OVERLAP_MODIFIER: f32 = 1.25;
pub const FLOOR_Y_OFFSET: u8 = 200;
pub const 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)]
#[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
View File

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

View File

@ -1,10 +1,12 @@
mod despawn;
mod 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)),

View File

@ -74,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;
};

View File

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

View File

@ -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()));

View File

@ -95,14 +95,6 @@ impl MazeConfig {
}
}
// TO
// 3928551514041614914
// (4, 0)
// FROM
// 7365371276044996661
// ()
impl Default for MazeConfig {
fn default() -> Self {
Self::new(

View File

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

View File

@ -15,7 +15,7 @@ use crate::{
components::{HexMaze, MazeConfig, Tile, Wall},
resources::GlobalMazeConfig,
},
screens::Screen,
screens::GameplayElement,
theme::palette::rose_pine::RosePineDawn,
};
@ -62,7 +62,7 @@ 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();

View File

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

View File

@ -16,7 +16,7 @@ pub struct MovementSpeed(pub f32);
impl Default for MovementSpeed {
fn default() -> Self {
Self(200.)
Self(100.)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ pub mod components;
pub mod events;
pub mod palette;
mod systems;
mod widgets;
pub mod widgets;
#[allow(unused_imports)]
pub mod prelude {

View File

@ -19,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 {
@ -107,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.