diff --git a/src/actions.rs b/src/actions.rs index 5791e4a..9c5f961 100644 --- a/src/actions.rs +++ b/src/actions.rs @@ -6,10 +6,30 @@ pub enum Controls { Right, SoftDrop, HardDrop, + Swap, + RotateCw, + RotateCcw, } pub struct ControlsHeld(HashMap); +impl ControlsHeld { + pub fn just_pressed(&self, ticks: usize, control: &Controls) -> bool { + self.held_for(ticks, control, |held_for| held_for == 0) + } + + pub fn held_for bool>( + &self, + ticks: usize, + control: &Controls, + functor: F, + ) -> bool { + self.get(control) + .map(|&held_since| ticks - held_since) + .is_some_and(functor) + } +} + impl std::ops::Deref for ControlsHeld { type Target = HashMap; diff --git a/src/board.rs b/src/board.rs index cc002c9..2bb29d2 100644 --- a/src/board.rs +++ b/src/board.rs @@ -2,6 +2,7 @@ use std::ops::{Deref, DerefMut}; use crate::{CurrentTetromino, Tetromino}; +#[derive(PartialEq)] pub struct Board([[Option; Self::WIDTH]; Self::HEIGHT]); impl Deref for Board { @@ -68,4 +69,142 @@ impl Board { false } + + pub fn lines_cleared(&mut self) -> usize { + let line_clears: Vec<_> = self + .iter() + .enumerate() + .filter_map(|(i, row)| if !row.contains(&None) { Some(i) } else { None }) + .collect(); + + let mut lines_cleared = 0; + for i in (0..self.len()).rev() { + let blank_line = std::array::from_fn(|_| None); + let line = std::mem::replace(&mut self[i], blank_line); + self[i + lines_cleared] = line; + + if line_clears.contains(&i) { + lines_cleared += 1; + } + } + lines_cleared + } +} + +impl std::fmt::Debug for Board { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let t = self + .0 + .iter() + .map(|row| { + row.iter() + .map(|t| match t { + Some(t) => match t { + Tetromino::I => "I", + Tetromino::J => "J", + Tetromino::L => "L", + Tetromino::O => "O", + Tetromino::S => "S", + Tetromino::T => "T", + Tetromino::Z => "Z", + }, + None => ".", + }) + .collect::>() + .join("") + }) + .collect::>() + .join("\n"); + write!(f, "{t}") + } +} + +#[cfg(test)] +mod test { + use super::Board; + + fn board_from_str(str: &'static str) -> Board { + use crate::Tetromino::*; + Board( + str.split_whitespace() + .map(|row| { + let row: [char; Board::WIDTH] = row + .chars() + .collect::>() + .try_into() + .expect("invalid board row"); + row.map(|char| match char { + '.' => None, + 'I' => Some(I), + 'J' => Some(J), + 'L' => Some(L), + 'O' => Some(O), + 'S' => Some(S), + 'T' => Some(T), + 'Z' => Some(Z), + c => panic!("invalid board char '{c}'"), + }) + }) + .collect::>() + .try_into() + .expect("invalid board content"), + ) + } + + #[test] + fn line_clear() { + let mut board = board_from_str( + " + .......... + .......... + .......... + .......... + ..OOOOOOOO + OOOOOOOO.. + JJJJJJJJJJ + JJJJJJJJJJ + ..JJJJJJJJ + JJJJJJJJ.. + JJJJJJJJ.. + JJJJJJJJ.. + JJJJJJJJJJ + JJJJJJJJJJ + JJJJJJJJJJ + JJJJJJJJJJ + JJJJJJJJJJ + JJJJJJJJJJ + JJJJJJJJJJ + JJJJJJJJJJ + ", + ); + + let after = board_from_str( + " + .......... + .......... + .......... + .......... + .......... + .......... + .......... + .......... + .......... + .......... + .......... + .......... + .......... + .......... + ..OOOOOOOO + OOOOOOOO.. + ..JJJJJJJJ + JJJJJJJJ.. + JJJJJJJJ.. + JJJJJJJJ.. + ", + ); + + assert_eq!(board.lines_cleared(), 10); + + assert_eq!(board, after); + } } diff --git a/src/main.rs b/src/main.rs index 7d40745..fc19f52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use actions::{Controls, ControlsHeld}; use board::Board; use tetromino::{Direction, DirectionDiff, Tetromino}; @@ -44,6 +42,7 @@ struct Score { points: usize, lines: usize, combo: usize, + back_to_back: bool, } impl Score { @@ -53,6 +52,34 @@ impl Score { points: 0, lines: 0, combo: 0, + back_to_back: false, + } + } + + fn level_up(&mut self, lines_cleared: usize) { + self.lines += lines_cleared; + if self.lines > self.level * 5 { + self.level += 1; + self.lines = 0; + } + } + + fn point_multiplier_from_lines_cleared(lines_cleared: usize) -> f32 { + match lines_cleared { + 0 => 0.0, + 1 => 100.0, + 2 => 300.0, + 3 => 500.0, + 4 => 800.0, + _ => unreachable!("we cannot clear more than 4 lines"), + } + } + + fn combos(&self, lines_cleared: usize) -> usize { + if lines_cleared > 0 { + self.combo * 50 * self.level + } else { + 0 } } } @@ -66,10 +93,20 @@ impl Game { } fn hard_drop(&mut self, controls: &ControlsHeld) { - if controls.contains_key(&Controls::HardDrop) { - loop { - todo!() + if !controls.just_pressed(self.ticks, &Controls::HardDrop) { + return; + } + let start_y = self.current_tetromino.y; + loop { + self.current_tetromino.y += 1; + if !self.board.colliding(&self.current_tetromino) { + continue; } + self.current_tetromino.y -= 1; + self.score.points += (self.current_tetromino.y - start_y) as usize * 2; + self.place_current_tetromino(); + self.check_line_clears(); + break; } } @@ -88,7 +125,6 @@ impl Game { self.current_tetromino.y -= 1; self.place_current_tetromino(); self.check_line_clears(); - self.has_swapped_held = false; } else if controls.contains_key(&Controls::SoftDrop) { self.score.points += 1; } @@ -96,11 +132,9 @@ impl Game { fn move_horizontally(&mut self, controls: &ControlsHeld) { for key in [Controls::Left, Controls::Right] { - let Some(held_since) = controls.get(&key) else { - continue; - }; - let held_for = self.ticks - held_since; - if held_for < 15 { + let just_pressed = controls.just_pressed(self.ticks, &key); + let long_press = controls.held_for(self.ticks, &key, |held_for| held_for > 15); + if !just_pressed && !long_press { continue; } let offset = match key { @@ -115,15 +149,57 @@ impl Game { } } - fn check_line_clears(&self) { - todo!() + fn check_line_clears(&mut self) { + let lines_cleared = self.board.lines_cleared(); + + self.score.level_up(lines_cleared); + + let mut points = + self.score.level as f32 * Score::point_multiplier_from_lines_cleared(lines_cleared); + + // Back to back tetris + if self.score.back_to_back && lines_cleared == 4 { + points *= 1.5; + } + points += self.score.combos(lines_cleared) as f32; + + self.score.points += points as usize; + + if lines_cleared == 4 { + self.score.back_to_back = true; + } else if lines_cleared > 0 { + self.score.back_to_back = false; + } + + if lines_cleared > 0 { + self.score.combo += 1; + // play_line_clears_sound(); + } else { + self.score.combo = 0; + // play_hard_drop_sound(); + } } fn step(&mut self, controls: &ControlsHeld) { - // TODO: ensure game is running at 60fps (`if !check_update_time(context, 60) { return; }`) - self.ticks += 1; + // TODO: ensure game is running at 60 ticks per second? (`if !check_update_time(context, 60) { return; }`) + self.hard_drop(controls); self.soft_drop(controls); self.move_horizontally(controls); + + if controls.just_pressed(self.ticks, &Controls::Swap) { + self.try_swap_tetromino(); + } + + for (control, direction) in [ + (Controls::RotateCw, DirectionDiff::Cw), + (Controls::RotateCcw, DirectionDiff::Ccw), + ] { + if !controls.just_pressed(self.ticks, &control) { + continue; + } + self.try_rotate(direction); + } + self.ticks += 1; } fn try_rotate(&mut self, diff: DirectionDiff) -> bool { @@ -168,6 +244,8 @@ impl Game { self.board[y][x] = Some(current.tetromino.clone()); } } + + self.has_swapped_held = false; } fn try_swap_tetromino(&mut self) { diff --git a/src/oldmain.rs b/src/oldmain.rs index b85e8ec..2f34150 100755 --- a/src/oldmain.rs +++ b/src/oldmain.rs @@ -1219,4 +1219,3 @@ unsafe fn create_popup_message(info: &panic::PanicInfo, path: PathBuf) { fn create_popup_message(info: &panic::PanicInfo) { // Do nothing } -