diff --git a/src/debug.rs b/src/debug.rs index 7fbb906..111aa1b 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -61,6 +61,10 @@ fn debug_ui_system( ui.label(if settings.color { "✅" } else { "❌" }); ui.end_row(); + ui.label("Shape cues"); + ui.label(if settings.shape { "✅" } else { "❌" }); + ui.end_row(); + ui.label("Sound cues"); ui.label(if settings.sound { "✅" } else { "❌" }); ui.end_row(); @@ -119,6 +123,14 @@ fn debug_ui_system( }); ui.end_row(); + ui.label("Shape match"); + ui.label(match &engine.shapes { + Some(s) if s.is_match() => "🟢 YES", + Some(_) => "⚫ no", + None => "—", + }); + ui.end_row(); + ui.label("Sound match"); ui.label(match &engine.sounds { Some(s) if s.is_match() => "🟢 YES", @@ -163,6 +175,10 @@ fn debug_ui_system( ui.label(if answer.color { "🟢" } else { "⚫" }); ui.end_row(); + ui.label("Shape"); + ui.label(if answer.shape { "🟢" } else { "⚫" }); + ui.end_row(); + ui.label("Sound"); ui.label(if answer.sound { "🟢" } else { "⚫" }); ui.end_row(); diff --git a/src/game/mod.rs b/src/game/mod.rs index 79fd2e9..9212189 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -8,7 +8,7 @@ use self::{ score::Score, }, settings::GameSettings, - tile::{Tile, TilePlugin}, + tile::{Tile, TileMeshes, TilePlugin}, ui::{UiPlugin, button::GameButtonPlugin}, }; @@ -33,7 +33,12 @@ impl Plugin for GamePlugin { } /// Spawn the arena, the tile with its first cue, and the session entity. -fn setup_game(mut commands: Commands, settings: Res) { +fn setup_game( + mut commands: Commands, + settings: Res, + mut meshes: ResMut>, + mut materials: ResMut>, +) { let edge = (config::TILE_SIZE * 3.0) + (config::TILE_SPACING * 4.0); let bounds = Vec2::new(edge, edge); let marker = DespawnOnExit(AppState::Game); @@ -76,34 +81,42 @@ fn setup_game(mut commands: Commands, settings: Res) { )); } + // Pre-build mesh handles for every shape variant. + let tile_meshes = TileMeshes::new(&mut meshes); + // Create engine and generate the first cue up-front so the player // sees a real cue from the start (no phantom round). let mut engine = CueEngine::new( settings.n, settings.position, settings.color, + settings.shape, settings.sound, ); - let (first_pos, first_color, first_sound) = engine.new_cue(); + let first = engine.new_cue(); + + let tile_pos = first.position.unwrap_or_default(); + let tile_color = first.color.unwrap_or_default(); + let tile_shape = first.shape.unwrap_or_default(); + let tile_sound = first.sound.unwrap_or_default(); - let tile_pos = first_pos.unwrap_or_default(); - let tile_color = first_color.unwrap_or_default(); - let tile_sound = first_sound.unwrap_or_default(); + let mesh_handle = tile_meshes.get(&tile_shape); + let mat_handle = materials.add(ColorMaterial::from_color(Color::from(&tile_color))); - // Spawn tile with the first cue already applied. + commands.insert_resource(tile_meshes); + + // Spawn tile with the first cue already applied (mesh-based rendering). // Change-detection will fire on the first frame, playing the sound // and triggering the pop animation. commands.spawn(( Name::new("tile"), Tile, - Sprite { - color: (&tile_color).into(), - custom_size: Some(Vec2::new(config::TILE_SIZE, config::TILE_SIZE)), - ..default() - }, + Mesh2d(mesh_handle), + MeshMaterial2d(mat_handle), Transform::from_translation((&tile_pos).into()), tile_pos, tile_color, + tile_shape, tile_sound, marker.clone(), )); @@ -158,7 +171,6 @@ fn spawn_pause_overlay(mut commands: Commands, asset_server: Res) { ..default() }, BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)), - // Render on top of the game UI GlobalZIndex(10), children![( Text::new("PAUSED"), diff --git a/src/game/session/answer.rs b/src/game/session/answer.rs index 75f1da1..a5b796e 100644 --- a/src/game/session/answer.rs +++ b/src/game/session/answer.rs @@ -8,6 +8,7 @@ use bevy::prelude::*; pub struct Answer { pub position: bool, pub color: bool, + pub shape: bool, pub sound: bool, } @@ -16,6 +17,7 @@ impl Answer { info!("reset answer"); self.position = false; self.color = false; + self.shape = false; self.sound = false; } } diff --git a/src/game/session/engine.rs b/src/game/session/engine.rs index 7662d6f..24d8a87 100644 --- a/src/game/session/engine.rs +++ b/src/game/session/engine.rs @@ -1,24 +1,36 @@ use bevy::prelude::*; -use crate::game::tile::{color::TileColor, position::TilePosition, sound::TileSound}; +use crate::game::tile::{ + color::TileColor, position::TilePosition, shape::TileShape, sound::TileSound, +}; use super::cue::CueChain; +/// Generated cues for one round. +pub struct Cue { + pub position: Option, + pub color: Option, + pub shape: Option, + pub sound: Option, +} + /// The n-back game engine. Owns one [`CueChain`] per enabled stimulus channel. #[derive(Component)] pub struct CueEngine { n: usize, pub positions: Option>, pub colors: Option>, + pub shapes: Option>, pub sounds: Option>, } impl CueEngine { - pub fn new(n: usize, position: bool, color: bool, sound: bool) -> Self { + pub fn new(n: usize, position: bool, color: bool, shape: bool, sound: bool) -> Self { CueEngine { n, positions: position.then(|| CueChain::with_n_back(n)), colors: color.then(|| CueChain::with_n_back(n)), + shapes: shape.then(|| CueChain::with_n_back(n)), sounds: sound.then(|| CueChain::with_n_back(n)), } } @@ -27,17 +39,18 @@ impl CueEngine { self.n } - pub fn new_cue(&mut self) -> (Option, Option, Option) { - let new_position = self.positions.as_mut().map(|p| p.next_cue()); - let new_color = self.colors.as_mut().map(|c| c.next_cue()); - let new_sound = self.sounds.as_mut().map(|s| s.next_cue()); - - (new_position, new_color, new_sound) + pub fn new_cue(&mut self) -> Cue { + Cue { + position: self.positions.as_mut().map(|p| p.next_cue()), + color: self.colors.as_mut().map(|c| c.next_cue()), + shape: self.shapes.as_mut().map(|s| s.next_cue()), + sound: self.sounds.as_mut().map(|s| s.next_cue()), + } } } impl Default for CueEngine { fn default() -> Self { - CueEngine::new(2, true, true, true) + CueEngine::new(2, true, true, true, true) } } diff --git a/src/game/session/mod.rs b/src/game/session/mod.rs index 1422a0f..ccd6f11 100644 --- a/src/game/session/mod.rs +++ b/src/game/session/mod.rs @@ -5,7 +5,7 @@ use crate::{ phase::GamePhase, score::{ScoreHistory, ScoreRecord}, settings::GameSettings, - tile::{color::TileColor, position::TilePosition, sound::TileSound}, + tile::{color::TileColor, position::TilePosition, shape::TileShape, sound::TileSound}, }, state::AppState, }; @@ -66,10 +66,15 @@ fn end_of_round_system( ), With, >, - mut tile: Single<(&mut TilePosition, &mut TileColor, &mut TileSound)>, + mut tile: Single<( + &mut TilePosition, + &mut TileColor, + &mut TileShape, + &mut TileSound, + )>, ) { let (engine, round, score, answer, timer) = &mut *session; - let (position, color, sound) = &mut *tile; + let (position, color, shape, sound) = &mut *tile; if !timer.just_finished() { return; @@ -78,19 +83,23 @@ fn end_of_round_system( // Evaluate each cue channel score.evaluate(&engine.positions, answer.position); score.evaluate(&engine.colors, answer.color); + score.evaluate(&engine.shapes, answer.shape); score.evaluate(&engine.sounds, answer.sound); answer.reset(); // Generate next cues - let (new_position, new_color, new_sound) = engine.new_cue(); - if let Some(p) = new_position { + let cue = engine.new_cue(); + if let Some(p) = cue.position { **position = p; } - if let Some(c) = new_color { + if let Some(c) = cue.color { **color = c; } - if let Some(s) = new_sound { + if let Some(s) = cue.shape { + **shape = s; + } + if let Some(s) = cue.sound { **sound = s; } diff --git a/src/game/settings.rs b/src/game/settings.rs index 8ac7413..781c31b 100644 --- a/src/game/settings.rs +++ b/src/game/settings.rs @@ -7,6 +7,7 @@ pub struct GameSettings { pub round_time: f32, pub position: bool, pub color: bool, + pub shape: bool, pub sound: bool, } @@ -24,6 +25,7 @@ impl Default for GameSettings { round_time: 3.0, position: true, color: true, + shape: true, sound: true, } } diff --git a/src/game/tile/mod.rs b/src/game/tile/mod.rs index 7359b0b..7259034 100644 --- a/src/game/tile/mod.rs +++ b/src/game/tile/mod.rs @@ -1,12 +1,13 @@ use bevy::prelude::*; use bevy_kira_audio::prelude::*; -use crate::{asset::AudioAssets, state::AppState}; +use crate::{asset::AudioAssets, config, state::AppState}; -use self::{color::TileColor, position::TilePosition, sound::TileSound}; +use self::{color::TileColor, position::TilePosition, shape::TileShape, sound::TileSound}; pub mod color; pub mod position; +pub mod shape; pub mod sound; pub struct TilePlugin; @@ -18,6 +19,7 @@ impl Plugin for TilePlugin { ( tile_position_system, tile_color_system, + tile_shape_system, tile_sound_system, tile_pop_animation_system, ) @@ -42,9 +44,65 @@ impl Default for TilePopAnimation { /// Marker component for the game tile. Required components are auto-inserted. #[derive(Component, Default)] -#[require(TilePopAnimation, TilePosition, TileColor, TileSound)] +#[require(TilePopAnimation, TilePosition, TileColor, TileShape, TileSound)] pub struct Tile; +/// Pre-computed mesh handles for each tile shape. +#[derive(Resource)] +pub struct TileMeshes { + pub circle: Handle, + pub triangle: Handle, + pub square: Handle, + pub pentagon: Handle, + pub hexagon: Handle, +} + +impl TileMeshes { + /// Build meshes sized so every shape fills a `TILE_SIZE × TILE_SIZE` box. + /// + /// `RegularPolygon::new(r, n)` takes the *circumradius* (center → vertex). + /// Each shape's bounding box depends on its geometry, so we compute a + /// per-shape circumradius that makes the largest dimension equal to + /// `TILE_SIZE`. + pub fn new(meshes: &mut Assets) -> Self { + use std::f32::consts::{FRAC_PI_4, TAU}; + + let size = config::TILE_SIZE; + + // Circle: bbox = 2r × 2r → r = size / 2 + // Triangle: bbox = r√3 × 1.5r → r = size / √3 (width-limited) + // Square: bbox = r√2 × r√2 (rot.) → r = size / √2 + // Pentagon: bbox = 2r·sin(72°) × … → r = size / (2·sin(72°)) + // Hexagon: bbox = r√3 × 2r → r = size / 2 (height-limited) + let r_circle = size / 2.0; + let r_triangle = size / 3_f32.sqrt(); + let r_square = size / 2_f32.sqrt(); + let r_pentagon = size / (2.0 * (TAU / 5.0).sin()); + let r_hexagon = size / 2.0; + + TileMeshes { + circle: meshes.add(Circle::new(r_circle)), + triangle: meshes.add(RegularPolygon::new(r_triangle, 3)), + square: meshes.add( + Mesh::from(RegularPolygon::new(r_square, 4)) + .rotated_by(Quat::from_rotation_z(FRAC_PI_4)), + ), + pentagon: meshes.add(RegularPolygon::new(r_pentagon, 5)), + hexagon: meshes.add(RegularPolygon::new(r_hexagon, 6)), + } + } + + pub fn get(&self, shape: &TileShape) -> Handle { + match shape { + TileShape::Circle => self.circle.clone(), + TileShape::Triangle => self.triangle.clone(), + TileShape::Square | TileShape::None => self.square.clone(), + TileShape::Pentagon => self.pentagon.clone(), + TileShape::Hexagon => self.hexagon.clone(), + } + } +} + /// Update tile state every time the position changes. pub fn tile_position_system( mut tile: Single<(&mut Transform, &mut TilePopAnimation, &TilePosition), Changed>, @@ -52,7 +110,6 @@ pub fn tile_position_system( let (transform, anim, position) = &mut *tile; info!(?position, "tile updated"); transform.translation = (*position).into(); - // Reset and start the pop animation anim.timer.reset(); } @@ -71,11 +128,27 @@ pub fn tile_pop_animation_system( } } -/// Update tile state every time the color changes. -pub fn tile_color_system(mut tile: Single<(&mut Sprite, &TileColor), Changed>) { - let (sprite, color) = &mut *tile; - info!(?color, "tile updated"); - sprite.color = (*color).into(); +/// Update tile material color when the color cue changes. +pub fn tile_color_system( + mut materials: ResMut>, + tile: Single<(&MeshMaterial2d, &TileColor), Changed>, +) { + let (mat_handle, color) = *tile; + info!(?color, "tile color updated"); + if let Some(material) = materials.get_mut(mat_handle) { + material.color = color.into(); + } +} + +/// Swap the tile mesh when the shape cue changes. +pub fn tile_shape_system( + tile_meshes: Res, + mut tile: Single<(&mut Mesh2d, &mut TilePopAnimation, &TileShape), Changed>, +) { + let (mesh, anim, shape) = &mut *tile; + info!(?shape, "tile shape updated"); + mesh.0 = tile_meshes.get(shape); + anim.timer.reset(); } /// Update tile state every time the sound changes. diff --git a/src/game/tile/shape.rs b/src/game/tile/shape.rs new file mode 100644 index 0000000..16ac4f5 --- /dev/null +++ b/src/game/tile/shape.rs @@ -0,0 +1,28 @@ +use bevy::prelude::*; +use rand::{ + Rng, RngExt, + distr::{Distribution, StandardUniform}, +}; + +#[derive(Component, Clone, Debug, Default, PartialEq)] +pub enum TileShape { + Circle, + Triangle, + Square, + Pentagon, + Hexagon, + #[default] + None, +} + +impl Distribution for StandardUniform { + fn sample(&self, rng: &mut R) -> TileShape { + match rng.random_range(0..=4) { + 0 => TileShape::Circle, + 1 => TileShape::Triangle, + 2 => TileShape::Square, + 3 => TileShape::Pentagon, + _ => TileShape::Hexagon, + } + } +} diff --git a/src/game/ui/button.rs b/src/game/ui/button.rs index 67c5baa..4e45d42 100644 --- a/src/game/ui/button.rs +++ b/src/game/ui/button.rs @@ -20,8 +20,9 @@ pub struct Shortcut(pub KeyCode); #[derive(Component)] pub enum ButtonAction { SamePosition, - SameSound, SameColor, + SameShape, + SameSound, } /// Returns a game button bundle as a tuple. @@ -86,8 +87,9 @@ fn button_system( *border_color = BorderColor::all(BUTTON_BORDER_COLOR); match action { ButtonAction::SamePosition => answer.position = true, - ButtonAction::SameSound => answer.sound = true, ButtonAction::SameColor => answer.color = true, + ButtonAction::SameShape => answer.shape = true, + ButtonAction::SameSound => answer.sound = true, } } Interaction::Hovered => { @@ -124,8 +126,9 @@ fn button_shortcut_system( if keyboard_input.just_pressed(shortcut.0) { match action { ButtonAction::SamePosition => answer.position = true, - ButtonAction::SameSound => answer.sound = true, ButtonAction::SameColor => answer.color = true, + ButtonAction::SameShape => answer.shape = true, + ButtonAction::SameSound => answer.sound = true, } } diff --git a/src/game/ui/mod.rs b/src/game/ui/mod.rs index 9e1ef39..f1a1558 100644 --- a/src/game/ui/mod.rs +++ b/src/game/ui/mod.rs @@ -86,16 +86,22 @@ pub fn game_ui( ButtonAction::SamePosition ), game_button( - "Sound (S)", + "Color (S)", font.clone(), KeyCode::KeyS, - ButtonAction::SameSound + ButtonAction::SameColor ), game_button( - "Color (D)", + "Shape (D)", font.clone(), KeyCode::KeyD, - ButtonAction::SameColor + ButtonAction::SameShape + ), + game_button( + "Sound (F)", + font.clone(), + KeyCode::KeyF, + ButtonAction::SameSound ), ], ), diff --git a/src/menu/checkbox.rs b/src/menu/checkbox.rs index fbadf7e..ea7f571 100644 --- a/src/menu/checkbox.rs +++ b/src/menu/checkbox.rs @@ -15,8 +15,9 @@ pub struct Checkbox { #[derive(Component)] pub enum CheckboxAction { Position, - Sound, Color, + Shape, + Sound, } type CheckboxQuery<'w> = ( @@ -42,8 +43,9 @@ pub fn checkbox_system( match action { CheckboxAction::Position => settings.position = checkbox.checked, - CheckboxAction::Sound => settings.sound = checkbox.checked, CheckboxAction::Color => settings.color = checkbox.checked, + CheckboxAction::Shape => settings.shape = checkbox.checked, + CheckboxAction::Sound => settings.sound = checkbox.checked, } } } diff --git a/src/menu/ui.rs b/src/menu/ui.rs index 4007993..0550932 100644 --- a/src/menu/ui.rs +++ b/src/menu/ui.rs @@ -83,6 +83,7 @@ pub fn menu_ui( GridTrack::min_content(), GridTrack::min_content(), GridTrack::min_content(), + GridTrack::min_content(), ], row_gap: px(12), column_gap: px(12), @@ -93,10 +94,12 @@ pub fn menu_ui( children![ checkbox(settings.position, CheckboxAction::Position), cue_label("Position", font.clone()), - checkbox(settings.sound, CheckboxAction::Sound), - cue_label("Sound", font.clone()), checkbox(settings.color, CheckboxAction::Color), cue_label("Color", font.clone()), + checkbox(settings.shape, CheckboxAction::Shape), + cue_label("Shape", font.clone()), + checkbox(settings.sound, CheckboxAction::Sound), + cue_label("Sound", font.clone()), ], ), // Play button