diff --git a/src/bin/tetris/TODO.md b/src/bin/tetris/TODO.md new file mode 100644 index 0000000..48ca17b --- /dev/null +++ b/src/bin/tetris/TODO.md @@ -0,0 +1,15 @@ +# Tetris Battle + +- Recognize finishing a level successfully +- Recognize failing a level +- Art pass +- Nicer preview for "Next" and "Swap" Shapes +- Use a timer resource for next step + +## Bugs +- Two blocks in the same position +- Skipping doesn't work 100% of the time +- A little delay after skipping + +## Nice to haves +- Fix tests after Shape -> ShapeLayout refactor diff --git a/src/bin/tetris/main.rs b/src/bin/tetris/main.rs index 070ed90..1ef3fff 100644 --- a/src/bin/tetris/main.rs +++ b/src/bin/tetris/main.rs @@ -27,6 +27,7 @@ fn main() { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_systems( Startup, ( @@ -37,6 +38,10 @@ fn main() { init_battler, ), ) + .add_systems( + Update, + game_state_machine.run_if(state_changed::) + ) // Input and basic systems .add_systems( Update, @@ -52,9 +57,10 @@ fn main() { .run_if(resource_changed::.or(resource_added::)), add_piece .run_if(not(any_with_component::)) + .run_if(in_state(GameState::Play)) .after(update_next_shapes), falling - .run_if(in_state(GameState::Falling)) + .run_if(in_state(GameState::Play)) .run_if(on_timer(Duration::from_secs(1))), update_position.run_if(any_component_changed::), update_shape_blocks @@ -74,6 +80,7 @@ fn main() { damage_on_place_shape.run_if(any_component_removed::), damage_on_clear_line.run_if(any_component_removed::), damage_over_time.run_if(on_timer(Duration::from_secs(5))), + check_level_goal.run_if(resource_changed::), ), ) // UI systems @@ -83,6 +90,7 @@ fn main() { sync_resource_to_ui::.run_if(resource_changed::), sync_resource_to_ui::.run_if(resource_changed::), sync_resource_to_ui::.run_if(resource_changed::), + sync_resource_to_ui::.run_if(resource_changed::), sync_singleton_to_ui::.run_if(any_component_changed::), ), ) @@ -221,9 +229,87 @@ impl std::ops::AddAssign<&GridPosition> for GridPosition { #[derive(States, Clone, Eq, PartialEq, Debug, Hash, Default, Component)] enum GameState { + // Sets up a new game #[default] - Falling, + NewGame, + // Actively playing the game + Play, + // Temporarily paused gameplay Pause, + // Reached level goal + LevelComplete, + // Sets up next level + NextLevel, + // Failed level + GameOver, +} + +// The core of the Game state machine, coordinates other stuff happening +fn game_state_machine( + curr: Res>, + mut next: ResMut>, + mut goal: ResMut, + mut score: ResMut, + items: Query, Or<(With, With)>)>, + mut commands: Commands, +) { + match curr.get() { + GameState::NewGame => { + // Set new game goal + *goal = LevelGoal::Score(3); + + // Reset score + *score = Score(0); + + // Clear board + items.iter().for_each(|i| { + commands.entity(i).despawn(); + }); + + // Set goal based on current goal + next.set(GameState::Play); + }, + GameState::Play => (), // handled by user input + GameState::Pause => (), // handled by user input + GameState::LevelComplete => (), // hanled by user input + GameState::NextLevel => { + // Increase goal + *goal = match *goal { + LevelGoal::Score(n) => LevelGoal::Score(n + 5), + }; + + // Reset score + *score = Score(0); + + // Clear board + items.iter().for_each(|i| { + commands.entity(i).despawn(); + }); + + // Progress to play + next.set(GameState::Play) + } + GameState::GameOver => (),// handled by user input + } +} + +#[derive(Resource)] +enum LevelGoal { + Score(usize), +} + +impl Default for LevelGoal { + fn default() -> Self { + Self::Score(0) + } +} + +impl Display for LevelGoal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LevelGoal::Score(n) => write!(f, "{n}") + } + } } #[derive(Resource, Debug)] @@ -516,8 +602,54 @@ fn init_ui(mut commands: Commands, output_images: Res, images: Res parent.spawn(Text::new("Score:")); parent.spawn((Text::new("???"), SyncResource::::default())); }); + parent + .spawn((Node { + flex_direction: FlexDirection::Column, + align_items: AlignItems::Center, + ..default() + },)) + .with_children(|parent| { + parent.spawn(Text::new("Goal:")); + parent.spawn((Text::new("???"), SyncResource::::default())); + }); }); + commands + .spawn(( + Node { + align_self: AlignSelf::Center, + justify_self: JustifySelf::Center, + ..default() + }, + ZIndex(1), + GameState::Pause, + children![(Text::new("paused"), TextColor(WHITE.into()), GameState::Pause)], + )); + + commands + .spawn(( + Node { + align_self: AlignSelf::Center, + justify_self: JustifySelf::Center, + ..default() + }, + ZIndex(1), + GameState::LevelComplete, + children![(Text::new("you did it!"), TextColor(WHITE.into()), GameState::LevelComplete)], + )); + + commands + .spawn(( + Node { + align_self: AlignSelf::Center, + justify_self: JustifySelf::Center, + ..default() + }, + ZIndex(1), + GameState::GameOver, + children![(Text::new("aww, you suck. i'm sorry."), TextColor(WHITE.into()), GameState::GameOver)], + )); + commands .spawn(( Node { @@ -569,19 +701,6 @@ fn init_ui(mut commands: Commands, output_images: Res, images: Res }, )); }); - - commands - .spawn(( - Node { - align_self: AlignSelf::Center, - justify_self: JustifySelf::Center, - ..default() - }, - GameState::Pause, - )) - .with_children(|parent| { - parent.spawn(Text::new("Paused")); - }); } fn init_debug_ui(mut commands: Commands) { @@ -772,7 +891,6 @@ impl ShapeLayout { } } -// TODO: move to event fn update_position( mut changed: Query< (Entity, &GridPosition, &mut Transform), @@ -791,7 +909,6 @@ fn update_position( }); } -// TODO: Move to event fn update_shape_blocks( query: Query< (Entity, &ShapeLayout, &GridPosition), @@ -809,7 +926,13 @@ fn update_shape_blocks( let mut p = sl.coordinates_at(center); blocks.iter_mut().for_each(|mut gp| { - *gp = p.next().unwrap().unwrap(); + match p.next() { + Some(next) => match next { + Ok(next_gp) => *gp = next_gp, + Err(_) => warn!("Next coordinate was an Err?"), + }, + None => warn!("Next coordinate was a None?"), + } }); }); } @@ -844,7 +967,7 @@ fn on_add_shape_layout( fn kb_input( mut events: MessageReader, - mut query: Query>, + mut query: Query>, // Single? curr: Res>, mut next: ResMut>, mut commands: Commands, @@ -855,47 +978,61 @@ fn kb_input( }| { if let ButtonState::Pressed = state { query.iter_mut().for_each(|e| { - match key_code { - // Up arrow should rotate if in falling mode - // Only move up if in falling::off mode - KeyCode::ArrowUp => { - commands.entity(e).trigger(|entity| Movement { - entity, - direction: MovementDirection::Rotate, - }); - } - KeyCode::ArrowDown => { - commands.entity(e).trigger(|entity| Movement { - entity, - direction: MovementDirection::Down, - }); - } - KeyCode::ArrowLeft => { - commands.entity(e).trigger(|entity| Movement { - entity, - direction: MovementDirection::Left, - }); - } - KeyCode::ArrowRight => { - commands.entity(e).trigger(|entity| Movement { - entity, - direction: MovementDirection::Right, - }); - } - KeyCode::Enter => { - commands.entity(e).trigger(|entity| Movement { - entity, - direction: MovementDirection::Skip, - }); - } - KeyCode::Space => { - commands.entity(e).trigger(|entity| Swap { entity }); + match curr.get() { + GameState::Play => match key_code { + // Up arrow should rotate if in falling mode + // Only move up if in falling::off mode + KeyCode::ArrowUp => { + commands.entity(e).trigger(|entity| Movement { + entity, + direction: MovementDirection::Rotate, + }); + } + KeyCode::ArrowDown => { + commands.entity(e).trigger(|entity| Movement { + entity, + direction: MovementDirection::Down, + }); + } + KeyCode::ArrowLeft => { + commands.entity(e).trigger(|entity| Movement { + entity, + direction: MovementDirection::Left, + }); + } + KeyCode::ArrowRight => { + commands.entity(e).trigger(|entity| Movement { + entity, + direction: MovementDirection::Right, + }); + } + KeyCode::Enter => { + commands.entity(e).trigger(|entity| Movement { + entity, + direction: MovementDirection::Skip, + }); + } + KeyCode::Space => { + commands.entity(e).trigger(|entity| Swap { entity }); + } + KeyCode::Escape => next.set(GameState::Pause), + _ => (), + }, + GameState::Pause => match key_code { + KeyCode::Escape | KeyCode::Enter => next.set(GameState::Play), + _ => () } - KeyCode::Escape => next.set(match curr.get() { - GameState::Falling => GameState::Pause, - GameState::Pause => GameState::Falling, - }), - _ => (), + GameState::GameOver => match key_code { + KeyCode::Escape => todo!("Quit the game"), + KeyCode::Enter => next.set(GameState::NewGame), + _ => () + }, + GameState::LevelComplete => match key_code { + KeyCode::Escape => todo!("Quit the game"), + KeyCode::Enter => next.set(GameState::NextLevel), + _ => () + }, + GameState::NewGame | GameState::NextLevel => (), } }); } @@ -1189,10 +1326,17 @@ fn update_next_shapes(mut buffer: ResMut) { } fn assert_grid_position_uniqueness( - grid_positions: Query<&GridPosition, (Without, Without)>, + grid_positions: Query<(Entity, &GridPosition, Option<&LineBlock>, Option<&ShapeBlock>), With>, + mut next: ResMut>, ) { - grid_positions.iter_combinations().for_each(|[a, b]| { - assert_ne!(a, b, "Two entities are in the same grid position!"); + grid_positions.iter_combinations().for_each(|[(e1, a, lb1, sb1), (e2, b, lb2, sb2)]| { + if a == b { + error!("Two entities are in the same grid position: {a:?} <-> {b:?}!"); + error!("\tE1: {:?} (Parent Block: {:?}| Parent Line: {:?})!", e1, lb1, sb1); + error!("\tE2: {:?} (Parent Block: {:?}| Parent Line: {:?})!", e2, lb2, sb2); + next.set(GameState::Pause); + } + // assert_ne!(a, b, "Two entities are in the same grid position!"); }); } @@ -1279,3 +1423,17 @@ fn damage_over_time(protagonist: Single>, mut commands quantity: 1.0, }); } + +fn check_level_goal( + goal: Res, + score: Res, + mut next: ResMut>, +) { + match *goal { + LevelGoal::Score(g) => { + if score.0 >= g { + next.set(GameState::LevelComplete); + } + } + } +}