Compare commits

...

12 Commits
v1.1.0 ... main

20 changed files with 342 additions and 41 deletions

2
Cargo.lock generated
View File

@ -3157,7 +3157,7 @@ dependencies = [
[[package]] [[package]]
name = "maze-ascension" name = "maze-ascension"
version = "1.1.0" version = "1.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bevy", "bevy",

View File

@ -1,7 +1,7 @@
[package] [package]
name = "maze-ascension" name = "maze-ascension"
authors = ["Kristofers Solo <dev@kristofers.xyz>"] authors = ["Kristofers Solo <dev@kristofers.xyz>"]
version = "1.1.0" version = "1.1.3"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@ -1,6 +1,6 @@
use bevy::{input::mouse::MouseWheel, prelude::*}; use bevy::{input::mouse::MouseWheel, prelude::*};
use crate::constants::{BASE_ZOOM_SPEED, MAX_ZOOM, MIN_ZOOM}; use crate::constants::{BASE_ZOOM_SPEED, MAX_ZOOM, MIN_ZOOM, SCROLL_MODIFIER};
pub(super) fn plugin(app: &mut App) { pub(super) fn plugin(app: &mut App) {
app.add_systems(Update, camera_zoom); app.add_systems(Update, camera_zoom);
@ -53,7 +53,7 @@ fn camera_zoom(
} }
for ev in scrool_evr.read() { for ev in scrool_evr.read() {
zoom_delta += ev.y * adjusted_zoom_speed; zoom_delta += ev.y * adjusted_zoom_speed * SCROLL_MODIFIER;
} }
if zoom_delta != 0.0 { if zoom_delta != 0.0 {

View File

@ -5,15 +5,22 @@ pub const MOVEMENT_COOLDOWN: f32 = 1.0; // one second cooldown
pub const TITLE: &str = "Maze Ascension: The Labyrinth of Echoes"; pub const TITLE: &str = "Maze Ascension: The Labyrinth of Echoes";
// Base score constants // Base score constants
pub const BASE_FLOOR_SCORE: usize = 1000; pub const BASE_FLOOR_SCORE: usize = 100;
pub const BASE_TIME_SCORE: usize = 100;
// Floor progression constants // Floor progression constants
pub const FLOOR_DIFFICULTY_MULTIPLIER: f32 = 1.2; // Higher floors are exponentially harder pub const FLOOR_PROGRESSION_MULTIPLIER: f32 = 1.2;
pub const MIN_TIME_MULTIPLIER: f32 = 0.1; // Minimum score multiplier for time pub const MIN_TIME_MULTIPLIER: f32 = 0.2; // Minimum score multiplier for time
pub const TIME_REFERENCE_SECONDS: f32 = 60.0; // Reference time for score calculation 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 // Constants for camera control
pub const BASE_ZOOM_SPEED: f32 = 10.0; 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 MIN_ZOOM: f32 = 50.0;
pub const MAX_ZOOM: f32 = 2500.0; pub const MAX_ZOOM: f32 = 2500.0;

View File

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

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

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

View File

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

View File

@ -74,7 +74,7 @@ pub fn handle_floor_transition_events(
} }
for event in event_reader.read() { for event in event_reader.read() {
let Some((current_entity, current_floor)) = current_query.get_single().ok() else { let Ok((current_entity, current_floor)) = current_query.get_single() else {
continue; continue;
}; };

View File

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

View File

@ -15,7 +15,7 @@ use crate::{
components::{HexMaze, MazeConfig, Tile, Wall}, components::{HexMaze, MazeConfig, Tile, Wall},
resources::GlobalMazeConfig, resources::GlobalMazeConfig,
}, },
screens::Screen, screens::GameplayElement,
theme::palette::rose_pine::RosePineDawn, theme::palette::rose_pine::RosePineDawn,
}; };
@ -62,7 +62,7 @@ pub fn spawn_maze(
config.clone(), config.clone(),
Transform::from_translation(Vec3::ZERO.with_y(y_offset)), Transform::from_translation(Vec3::ZERO.with_y(y_offset)),
Visibility::Visible, Visibility::Visible,
StateScoped(Screen::Gameplay), GameplayElement,
)) ))
.insert_if(CurrentFloor, || floor == 1) // Only floor 1 gets CurrentFloor .insert_if(CurrentFloor, || floor == 1) // Only floor 1 gets CurrentFloor
.id(); .id();

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

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

View File

@ -5,7 +5,7 @@ use crate::{
assets::{blue_material, generate_pill_mesh}, assets::{blue_material, generate_pill_mesh},
components::{CurrentPosition, Player}, components::{CurrentPosition, Player},
}, },
screens::Screen, screens::GameplayElement,
}; };
use bevy::prelude::*; use bevy::prelude::*;
@ -33,6 +33,6 @@ pub fn spawn_player(
Mesh3d(meshes.add(generate_pill_mesh(player_radius, player_height / 2.))), Mesh3d(meshes.add(generate_pill_mesh(player_radius, player_height / 2.))),
MeshMaterial3d(materials.add(blue_material())), MeshMaterial3d(materials.add(blue_material())),
Transform::from_xyz(start_pos.x, y_offset, start_pos.y), Transform::from_xyz(start_pos.x, y_offset, start_pos.y),
StateScoped(Screen::Gameplay), GameplayElement,
)); ));
} }

View File

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

View File

@ -8,6 +8,7 @@ use crate::{
use bevy::{input::common_conditions::input_just_pressed, prelude::*}; use bevy::{input::common_conditions::input_just_pressed, prelude::*};
pub(super) fn plugin(app: &mut App) { pub(super) fn plugin(app: &mut App) {
app.init_resource::<GameplayInitialized>();
app.add_systems( app.add_systems(
OnEnter(Screen::Gameplay), OnEnter(Screen::Gameplay),
( (
@ -16,16 +17,46 @@ pub(super) fn plugin(app: &mut App) {
spawn_hint_command, spawn_hint_command,
spawn_stats_command, spawn_stats_command,
) )
.chain(), .chain()
.run_if(not(resource_exists::<GameplayInitialized>)),
); );
app.add_systems(OnEnter(Screen::Gameplay), |mut commands: Commands| {
commands.insert_resource(GameplayInitialized(true));
});
app.add_systems(Update, cleanup_game.run_if(state_changed::<Screen>));
app.add_systems(OnEnter(Screen::Title), reset_gameplay_state);
app.add_systems( app.add_systems(
Update, Update,
return_to_title_screen pause_game.run_if(in_state(Screen::Gameplay).and(input_just_pressed(KeyCode::Escape))),
.run_if(in_state(Screen::Gameplay).and(input_just_pressed(KeyCode::Escape))),
); );
} }
fn return_to_title_screen(mut next_screen: ResMut<NextState<Screen>>) { fn pause_game(mut next_screen: ResMut<NextState<Screen>>) {
next_screen.set(Screen::Title); next_screen.set(Screen::Pause);
}
fn reset_gameplay_state(mut commands: Commands) {
commands.remove_resource::<GameplayInitialized>();
}
#[derive(Debug, Default, Reflect, Resource, DerefMut, Deref)]
#[reflect(Resource)]
pub struct GameplayInitialized(bool);
#[derive(Debug, Reflect, Component)]
#[reflect(Component)]
pub struct GameplayElement;
fn cleanup_game(
mut commands: Commands,
query: Query<Entity, With<GameplayElement>>,
state: Res<State<Screen>>,
) {
if !matches!(*state.get(), Screen::Gameplay | Screen::Pause) {
for entity in query.iter() {
commands.entity(entity).despawn_recursive();
}
}
} }

View File

@ -2,10 +2,12 @@
mod gameplay; mod gameplay;
mod loading; mod loading;
mod pause;
mod splash; mod splash;
mod title; mod title;
use bevy::prelude::*; use bevy::prelude::*;
pub use gameplay::{GameplayElement, GameplayInitialized};
pub(super) fn plugin(app: &mut App) { pub(super) fn plugin(app: &mut App) {
app.init_state::<Screen>(); app.init_state::<Screen>();
@ -16,6 +18,7 @@ pub(super) fn plugin(app: &mut App) {
loading::plugin, loading::plugin,
splash::plugin, splash::plugin,
title::plugin, title::plugin,
pause::plugin,
)); ));
} }
@ -28,4 +31,5 @@ pub enum Screen {
Loading, Loading,
Title, Title,
Gameplay, Gameplay,
Pause,
} }

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

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

View File

@ -13,19 +13,26 @@ use reset::reset_timers;
use score::{update_score, update_score_display}; use score::{update_score, update_score_display};
use total_timer::{update_total_timer, update_total_timer_display}; use total_timer::{update_total_timer, update_total_timer_display};
use crate::screens::Screen; use crate::screens::{GameplayInitialized, Screen};
pub(super) fn plugin(app: &mut App) { pub(super) fn plugin(app: &mut App) {
app.add_systems(OnEnter(Screen::Gameplay), reset_timers) app.add_systems(
.add_systems( OnEnter(Screen::Gameplay),
Update, reset_timers.run_if(not(resource_exists::<GameplayInitialized>)),
);
app.add_systems(
Update,
(
( (
(update_score, update_score_display).chain(), update_score.before(update_floor_timer),
(update_floor_timer, update_floor_timer_display).chain(), update_score_display,
(update_total_timer, update_total_timer_display).chain(),
update_floor_display,
update_highest_floor_display,
) )
.run_if(in_state(Screen::Gameplay)), .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

@ -2,7 +2,8 @@ use bevy::prelude::*;
use crate::{ use crate::{
constants::{ constants::{
BASE_FLOOR_SCORE, FLOOR_DIFFICULTY_MULTIPLIER, MIN_TIME_MULTIPLIER, TIME_REFERENCE_SECONDS, BASE_FLOOR_SCORE, BASE_PERFECT_TIME, FLOOR_PROGRESSION_MULTIPLIER, MIN_TIME_MULTIPLIER,
TIME_BONUS_MULTIPLIER, TIME_INCREASE_FACTOR,
}, },
floor::resources::HighestFloor, floor::resources::HighestFloor,
stats::{ stats::{
@ -20,7 +21,7 @@ pub fn update_score(
return; return;
} }
score.0 = calculate_score( score.0 += calculate_score(
hightes_floor.0.saturating_sub(1), hightes_floor.0.saturating_sub(1),
floor_timer.elapsed_secs(), floor_timer.elapsed_secs(),
); );
@ -38,13 +39,150 @@ pub fn update_score_display(
} }
fn calculate_score(floor_number: u8, completion_time: f32) -> usize { fn calculate_score(floor_number: u8, completion_time: f32) -> usize {
// Calculate base floor score with exponential scaling let perfect_time = calculate_perfect_time(floor_number);
let floor_multiplier = (floor_number as f32).powf(FLOOR_DIFFICULTY_MULTIPLIER);
// 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; let base_score = BASE_FLOOR_SCORE as f32 * floor_multiplier;
// Calculate time multiplier (decreases as time increases) // Time bonus calculation
let time_factor = 1. / (1. + (completion_time / TIME_REFERENCE_SECONDS)); // Perfect time or better gets maximum bonus
let time_multiplier = time_factor.max(MIN_TIME_MULTIPLIER); // 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 (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,7 +1,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::{ use crate::{
screens::Screen, screens::GameplayElement,
stats::{ stats::{
components::{ components::{
FloorDisplay, FloorTimerDisplay, HighestFloorDisplay, ScoreDisplay, TotalTimerDisplay, FloorDisplay, FloorTimerDisplay, HighestFloorDisplay, ScoreDisplay, TotalTimerDisplay,
@ -14,7 +14,7 @@ use crate::{
pub fn spawn_stats(mut commands: Commands) { pub fn spawn_stats(mut commands: Commands) {
commands commands
.ui_stats() .ui_stats()
.insert(StateScoped(Screen::Gameplay)) .insert(GameplayElement)
.with_children(|parent| { .with_children(|parent| {
parent.stats("Floor: 1", FloorDisplay); parent.stats("Floor: 1", FloorDisplay);
parent.stats("Highest Floor: 1", HighestFloorDisplay); parent.stats("Highest Floor: 1", HighestFloorDisplay);