Compare commits

...

9 Commits

19 changed files with 1905 additions and 353 deletions

911
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,3 +5,8 @@ edition = "2021"
[dependencies]
rand = "0.9.0"
rodio = "0.20.1"
[dependencies.sdl2]
version = "0.37.0"
features = ["ttf"]

View File

@ -1,7 +1,7 @@
use std::collections::HashMap;
#[derive(Hash, PartialEq, Eq)]
pub enum Controls {
pub enum Action {
Left,
Right,
SoftDrop,
@ -11,17 +11,20 @@ pub enum Controls {
RotateCcw,
}
pub struct ControlsHeld(HashMap<Controls, usize>);
pub struct ActionsHeld(HashMap<Action, usize>);
impl ControlsHeld {
pub fn just_pressed(&self, ticks: usize, control: &Controls) -> bool {
impl ActionsHeld {
pub fn new() -> Self {
Self(HashMap::new())
}
pub fn just_pressed(&self, ticks: usize, control: &Action) -> bool {
self.held_for(ticks, control, |held_for| held_for == 0)
}
pub fn held_for<F: Fn(usize) -> bool>(
&self,
ticks: usize,
control: &Controls,
control: &Action,
functor: F,
) -> bool {
self.get(control)
@ -30,15 +33,15 @@ impl ControlsHeld {
}
}
impl std::ops::Deref for ControlsHeld {
type Target = HashMap<Controls, usize>;
impl std::ops::Deref for ActionsHeld {
type Target = HashMap<Action, usize>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for ControlsHeld {
impl std::ops::DerefMut for ActionsHeld {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}

View File

@ -1,6 +1,6 @@
use std::ops::{Deref, DerefMut};
use crate::{CurrentTetromino, Tetromino};
use crate::{game::CurrentTetromino, Tetromino};
#[derive(PartialEq)]
pub struct Board([[Option<Tetromino>; Self::WIDTH]; Self::HEIGHT]);
@ -28,26 +28,35 @@ impl Board {
Board(board)
}
pub fn colliding(
pub fn lowest_y(
&self,
CurrentTetromino {
tetromino,
direction,
x: cur_x,
y: cur_y,
x,
y,
}: &CurrentTetromino,
) -> bool {
) -> i8 {
let pattern = tetromino.direction_pattern(direction);
let mut y = *y;
loop {
if self.pattern_and_position_colliding(pattern, *x, y) {
break y - 1;
}
y += 1;
}
}
for (y, row) in pattern.iter().enumerate() {
for x in row
fn pattern_and_position_colliding(&self, pattern: [[bool; 4]; 4], x: i8, y: i8) -> bool {
for (y_offset, row) in pattern.iter().enumerate() {
for x_offset in row
.iter()
.enumerate()
.filter(|(_, exists)| **exists)
.map(|(x, _)| x)
{
let x = x as i8 + cur_x;
let y = y as i8 + cur_y;
let x = x_offset as i8 + x;
let y = y_offset as i8 + y;
if y < 0 {
continue;
@ -70,6 +79,19 @@ impl Board {
false
}
pub fn colliding(
&self,
CurrentTetromino {
tetromino,
direction,
x,
y,
}: &CurrentTetromino,
) -> bool {
let pattern = tetromino.direction_pattern(direction);
self.pattern_and_position_colliding(pattern, *x, *y)
}
pub fn lines_cleared(&mut self) -> usize {
let line_clears: Vec<_> = self
.iter()

317
src/game.rs Normal file
View File

@ -0,0 +1,317 @@
use crate::actions::{Action, ActionsHeld};
use crate::board::Board;
use crate::tetromino::{Direction, DirectionDiff, Tetromino};
pub struct CurrentTetromino {
pub tetromino: Tetromino,
pub direction: Direction,
pub x: i8,
pub y: i8,
}
impl CurrentTetromino {
fn new(tetromino: Tetromino) -> Self {
const PIECE_WIDTH: i8 = 2;
Self {
tetromino,
direction: Direction::Up,
x: (Board::WIDTH as i8 - PIECE_WIDTH) / 2,
y: -1,
}
}
}
pub struct Game {
pub game_over: bool,
pub board: Board,
pub next_tetrominos: [Tetromino; 3],
pub current_tetromino: CurrentTetromino,
pub held_tetromino: Option<Tetromino>,
has_swapped_held: bool,
pub score: Score,
pub ticks: usize,
}
pub enum SoundEffect {
HardDrop,
LineClear(usize),
Move,
Rotation,
}
pub struct Score {
pub level: usize,
pub points: usize,
pub lines: usize,
pub combo: usize,
back_to_back: bool,
}
impl Score {
const fn new() -> Self {
Self {
level: 0,
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
}
}
}
impl Game {
pub fn new() -> Self {
Self {
game_over: false,
board: Board::new(),
next_tetrominos: std::array::from_fn(|_| Tetromino::random()),
current_tetromino: CurrentTetromino::new(Tetromino::random()),
held_tetromino: None,
has_swapped_held: false,
score: Score::new(),
ticks: 0,
}
}
fn take_next_in_bag(&mut self, mut last: Tetromino) -> Tetromino {
for value in self.next_tetrominos.iter_mut().rev() {
std::mem::swap(value, &mut last)
}
last
}
fn try_hard_drop(&mut self, actions: &ActionsHeld, effects: &mut Vec<SoundEffect>) {
if !actions.just_pressed(self.ticks, &Action::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(effects);
break;
}
}
fn soft_drop(&mut self, actions: &ActionsHeld, effects: &mut Vec<SoundEffect>) {
let mut delay = 32 - self.score.level * 2;
if actions.contains_key(&Action::SoftDrop) {
delay /= 10;
}
if self.ticks % delay != 0 {
return;
}
self.current_tetromino.y += 1;
if self.board.colliding(&self.current_tetromino) {
self.current_tetromino.y -= 1;
self.place_current_tetromino();
self.check_line_clears(effects);
} else if actions.contains_key(&Action::SoftDrop) {
self.score.points += 1;
}
}
fn try_move_horizontally(&mut self, actions: &ActionsHeld, effects: &mut Vec<SoundEffect>) {
for key in [Action::Left, Action::Right] {
let just_pressed = actions.just_pressed(self.ticks, &key);
let long_press = actions.held_for(self.ticks, &key, |held_for| held_for > 15);
if !just_pressed && !long_press {
continue;
}
let offset = match key {
Action::Left => -1,
Action::Right => 1,
_ => unreachable!(),
};
self.current_tetromino.x += offset;
if self.board.colliding(&self.current_tetromino) {
self.current_tetromino.x -= offset;
} else {
effects.push(SoundEffect::Move);
}
}
}
fn check_line_clears(&mut self, effects: &mut Vec<SoundEffect>) {
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);
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;
effects.push(SoundEffect::LineClear(lines_cleared));
} else {
self.score.combo = 0;
effects.push(SoundEffect::HardDrop);
}
}
pub fn step(&mut self, actions: &ActionsHeld) -> Vec<SoundEffect> {
if self.game_over {
panic!("should check if game is over before stepping");
}
let mut effects = Vec::new();
self.try_hard_drop(actions, &mut effects);
self.soft_drop(actions, &mut effects);
self.try_move_horizontally(actions, &mut effects);
if actions.just_pressed(self.ticks, &Action::Swap) {
self.try_swap_tetromino(&mut effects);
}
for (control, direction) in [
(Action::RotateCw, DirectionDiff::Cw),
(Action::RotateCcw, DirectionDiff::Ccw),
] {
if !actions.just_pressed(self.ticks, &control) {
continue;
}
self.try_rotate(direction, &mut effects);
}
self.ticks += 1;
effects
}
fn try_rotate(&mut self, diff: DirectionDiff, effects: &mut Vec<SoundEffect>) {
let rotated = self.current_tetromino.direction.rotate(&diff);
let old_direction = std::mem::replace(&mut self.current_tetromino.direction, rotated);
if !self.board.colliding(&self.current_tetromino) {
effects.push(SoundEffect::Rotation);
return;
}
let wall_kicks = self
.current_tetromino
.tetromino
.wall_kicks(&old_direction, &diff);
for (x, y) in wall_kicks {
self.current_tetromino.x += x;
self.current_tetromino.y += y;
if !(self.board.colliding(&self.current_tetromino)) {
effects.push(SoundEffect::Rotation);
return;
}
self.current_tetromino.x -= x;
self.current_tetromino.y -= y;
}
self.current_tetromino.direction = old_direction;
}
fn place_current_tetromino(&mut self) {
let next = CurrentTetromino::new(self.take_next_in_bag(Tetromino::random()));
let current = std::mem::replace(&mut self.current_tetromino, next);
let pattern = current.tetromino.direction_pattern(&current.direction);
if current.y <= 0 {
self.game_over = true;
}
for (y, row) in pattern.iter().enumerate() {
for x in row
.iter()
.enumerate()
.filter(|(_, exists)| **exists)
.map(|(x, _)| x)
{
let y = current.y + y as i8;
if y < 0 {
continue;
}
let y = y as usize;
let x = (current.x + x as i8) as usize;
self.board[y][x] = Some(current.tetromino.clone());
}
}
self.has_swapped_held = false;
}
fn try_swap_tetromino(&mut self, effects: &mut Vec<SoundEffect>) {
if self.has_swapped_held {
return;
}
self.has_swapped_held = true;
let held_or_first_in_bag_tetromino = self
.held_tetromino
.take()
.unwrap_or_else(|| self.take_next_in_bag(Tetromino::random()));
let current_tetromino = CurrentTetromino::new(held_or_first_in_bag_tetromino);
let old_tetromino = std::mem::replace(&mut self.current_tetromino, current_tetromino);
self.held_tetromino.replace(old_tetromino.tetromino);
effects.push(SoundEffect::Rotation);
}
}
#[cfg(test)]
mod test {
use super::{Board, CurrentTetromino, Game, Score, Tetromino};
#[test]
fn advance_bag() {
let mut game = Game {
game_over: false,
board: Board::new(),
score: Score::new(),
next_tetrominos: [Tetromino::I, Tetromino::J, Tetromino::O],
current_tetromino: CurrentTetromino::new(Tetromino::J),
held_tetromino: None,
has_swapped_held: false,
ticks: 0,
};
assert_eq!(game.take_next_in_bag(Tetromino::S), Tetromino::I);
assert_eq!(
game.next_tetrominos,
[Tetromino::J, Tetromino::O, Tetromino::S]
);
}
}

5
src/gui.rs Normal file
View File

@ -0,0 +1,5 @@
mod audio;
mod sdl;
mod ui;
pub use sdl::start_game;

99
src/gui/audio.rs Normal file
View File

@ -0,0 +1,99 @@
use std::{fs::File, io::BufReader, sync::mpsc};
use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink, Source};
use crate::game::SoundEffect;
fn source_from_path<P: AsRef<std::path::Path>>(path: P) -> Decoder<BufReader<File>> {
let file = BufReader::new(File::open(path).unwrap());
let source = Decoder::new(file).unwrap();
source
}
fn play_audio<P: AsRef<std::path::Path>>(
stream_handle: &OutputStreamHandle,
sink: &mut Option<Sink>,
path: P,
volume: f32,
) {
let source = source_from_path(path);
*sink = Sink::try_new(&stream_handle).ok();
if let Some(sink) = sink {
sink.set_volume(volume);
sink.append(source);
}
}
pub enum Command {
ToggleMuted,
PlayEffect(SoundEffect),
}
pub fn audio_thread() -> mpsc::Sender<Command> {
let (sender, receiver) = mpsc::channel::<Command>();
let _ = std::thread::spawn(move || {
let (_stream, stream_handle) = OutputStream::try_default().unwrap();
let music_sink = Sink::try_new(&stream_handle).unwrap();
let mut hard_drop_sink = None;
let mut line_clear_sink = None;
let mut move_sink = None;
let mut rotation_sink = None;
let mut muted = false;
music_sink.append(source_from_path("resources/music.ogg").repeat_infinite());
loop {
let Ok(cmd) = receiver.recv() else {
break;
};
let effect = match cmd {
Command::ToggleMuted => {
muted = !muted;
if muted {
music_sink.pause();
} else {
music_sink.play();
}
continue;
}
Command::PlayEffect(effect) => effect,
};
if muted {
continue;
}
let base_volume = 0.5;
match effect {
SoundEffect::HardDrop => play_audio(
&stream_handle,
&mut hard_drop_sink,
"resources/hard_drop.ogg",
base_volume,
),
SoundEffect::LineClear(lines_cleared) => play_audio(
&stream_handle,
&mut line_clear_sink,
"resources/line_clear.ogg",
base_volume + (lines_cleared as f32 - 1.0) * 0.5,
),
SoundEffect::Move => play_audio(
&stream_handle,
&mut move_sink,
"resources/move.ogg",
base_volume,
),
SoundEffect::Rotation => play_audio(
&stream_handle,
&mut rotation_sink,
"resources/rotation.ogg",
base_volume,
),
};
}
});
sender
}

223
src/gui/sdl.rs Normal file
View File

@ -0,0 +1,223 @@
use crate::actions::{Action, ActionsHeld};
use crate::game::Game;
use sdl2::event::Event;
use sdl2::keyboard::Keycode;
use sdl2::pixels::Color;
use sdl2::rect::Rect;
use sdl2::render::{Texture, TextureCreator, WindowCanvas};
use sdl2::ttf::Sdl2TtfContext;
use std::time::Duration;
use super::audio::{self};
use super::ui::{GameUiCtx, Rgb, UiCtx};
const WIDTH: i32 = 1000;
const HEIGHT: i32 = 800;
fn font_texture<'font, 'a, P: AsRef<std::path::Path>, Text: AsRef<str>, C>(
font: P,
text: Text,
ttf_context: &'a Sdl2TtfContext,
texture_creator: &'font TextureCreator<C>,
) -> Result<Texture<'font>, String> {
let font = ttf_context.load_font(font, 24)?;
let game_over_text = font
.render(text.as_ref())
.solid(Color::RGB(255, 255, 255))
.map_err(|err| err.to_string())?;
let texture = texture_creator
.create_texture_from_surface(game_over_text)
.map_err(|err| err.to_string())?;
Ok(texture)
}
struct SdlUiCtx {
canvas: WindowCanvas,
ttf: Sdl2TtfContext,
}
impl SdlUiCtx {
fn present(&mut self) {
self.canvas.present();
}
}
impl UiCtx<String> for SdlUiCtx {
fn window_size(&self) -> Result<(i32, i32), String> {
let (width, height) = self.canvas.window().size();
Ok((width as i32, height as i32))
}
fn fill_rect(
&mut self,
x: i32,
y: i32,
width: i32,
height: i32,
rgb: &super::ui::Rgb,
) -> Result<(), String> {
self.canvas.set_draw_color(Color::RGB(rgb.0, rgb.1, rgb.2));
self.canvas
.fill_rect(Rect::new(x, y, width as u32, height as u32))?;
Ok(())
}
fn outline_rect(
&mut self,
x: i32,
y: i32,
width: i32,
height: i32,
rgb: &super::ui::Rgb,
) -> Result<(), String> {
self.canvas.set_draw_color(Color::RGB(rgb.0, rgb.1, rgb.2));
self.canvas
.draw_rect(Rect::new(x, y, width as u32, height as u32))?;
Ok(())
}
fn text_size<P: AsRef<std::path::Path>, Text: AsRef<str>>(
&mut self,
font: P,
text: Text,
) -> Result<(i32, i32), String> {
let texture_creator = self.canvas.texture_creator();
let texture = font_texture(font, text, &self.ttf, &texture_creator)?;
let query = texture.query();
Ok((query.width as i32, query.height as i32))
}
fn fill_text<P: AsRef<std::path::Path>, Text: AsRef<str>>(
&mut self,
font: P,
text: Text,
x: i32,
y: i32,
width: i32,
height: i32,
) -> Result<(), String> {
let texture_creator = self.canvas.texture_creator();
let texture = font_texture(font, text, &self.ttf, &texture_creator)?;
self.canvas.copy(
&texture,
None,
Some(Rect::new(x, y, width as u32, height as u32)),
)?;
Ok(())
}
fn clear(&mut self, rgb: &Rgb) -> Result<(), String> {
self.canvas.set_draw_color(Color::RGB(rgb.0, rgb.1, rgb.2));
self.canvas.clear();
Ok(())
}
}
pub fn start_game() -> Result<(), String> {
let mut game = Game::new();
let mut actions = ActionsHeld::new();
let mut paused = false;
let audio_thread = audio::audio_thread();
let sdl_context = sdl2::init()?;
let ttf_context = sdl2::ttf::init().unwrap();
let video_subsystem = sdl_context.video()?;
let window = video_subsystem
.window("reimtris2", WIDTH as u32, HEIGHT as u32)
.resizable()
.position_centered()
.build()
.unwrap();
let canvas = window.into_canvas().build().unwrap();
let mut ctx = SdlUiCtx {
canvas,
ttf: ttf_context,
};
let mut event_pump = sdl_context.event_pump()?;
'running: loop {
ctx.clear(&Rgb(16, 16, 16))?;
for event in event_pump.poll_iter() {
match event {
Event::Quit { .. }
| Event::KeyDown {
keycode: Some(Keycode::Escape),
..
} => break 'running Ok(()),
Event::KeyDown {
keycode: Some(keycode),
..
} => {
let keycode = match keycode {
Keycode::Return if !paused && game.game_over => {
game = Game::new();
continue;
}
Keycode::M => {
audio_thread.send(audio::Command::ToggleMuted).unwrap();
continue;
}
Keycode::P => {
paused = !paused;
continue;
}
Keycode::Left | Keycode::A => Action::Left,
Keycode::Right | Keycode::D => Action::Right,
Keycode::Down | Keycode::S => Action::SoftDrop,
Keycode::Space => Action::HardDrop,
Keycode::Z => Action::RotateCcw,
Keycode::X => Action::RotateCw,
Keycode::C => Action::Swap,
_ => continue,
};
actions.insert(keycode, game.ticks);
}
Event::KeyUp {
keycode: Some(keycode),
..
} => {
let keycode = match keycode {
Keycode::Left | Keycode::A => Action::Left,
Keycode::Right | Keycode::D => Action::Right,
Keycode::Down | Keycode::S => Action::SoftDrop,
Keycode::Space => Action::HardDrop,
Keycode::Z => Action::RotateCcw,
Keycode::X => Action::RotateCw,
Keycode::C => Action::Swap,
_ => continue,
};
actions.remove(&keycode);
}
_ => {}
}
}
ctx.draw_board(&game.board, &game.current_tetromino)?;
if paused {
ctx.draw_important_text(
"resources/josenfin_sans_regular.ttf",
"game paused o_o... press [p] to unpause !!",
)?;
} else if game.game_over {
ctx.draw_important_text(
"resources/josenfin_sans_regular.ttf",
"game over T_T... press [enter] 2 restart :D",
)?;
} else {
let effects = game.step(&actions);
effects.into_iter().for_each(|effect| {
audio_thread
.send(audio::Command::PlayEffect(effect))
.unwrap()
});
}
ctx.present();
::std::thread::sleep(Duration::new(0, 1_000_000_000u32 / 60));
}
}

156
src/gui/ui.rs Normal file
View File

@ -0,0 +1,156 @@
use crate::{board::Board, game::CurrentTetromino, tetromino::Tetromino};
pub trait UiCtx<Err> {
fn window_size(&self) -> Result<(i32, i32), Err>;
fn fill_rect(&mut self, x: i32, y: i32, width: i32, height: i32, rgb: &Rgb) -> Result<(), Err>;
fn outline_rect(
&mut self,
x: i32,
y: i32,
width: i32,
height: i32,
rgb: &Rgb,
) -> Result<(), Err>;
fn text_size<P: AsRef<std::path::Path>, Text: AsRef<str>>(
&mut self,
font: P,
text: Text,
) -> Result<(i32, i32), Err>;
fn fill_text<P: AsRef<std::path::Path>, Text: AsRef<str>>(
&mut self,
font: P,
text: Text,
x: i32,
y: i32,
width: i32,
height: i32,
) -> Result<(), Err>;
fn clear(&mut self, rgb: &Rgb) -> Result<(), Err>;
}
pub trait GameUiCtx<Err>: UiCtx<Err> {
fn draw_tetromino_from_parts(
&mut self,
x: i8,
y: i8,
color: Rgb,
pattern: [[bool; 4]; 4],
filled: bool,
) -> Result<(), Err> {
for (y_offset, row) in pattern.iter().enumerate() {
for x_offset in row
.iter()
.enumerate()
.filter(|(_, exists)| **exists)
.map(|(x, _)| x)
{
let x = x_offset as i8 + x;
let y = y_offset as i8 + y;
if y < 0 {
continue;
}
self.draw_board_tile(x as i32, y as i32, &color, filled)?
}
}
Ok(())
}
fn draw_board_tile(&mut self, x: i32, y: i32, color: &Rgb, filled: bool) -> Result<(), Err> {
let tile_size = 24;
let (win_width, win_height) = self.window_size()?;
let x = center(tile_size * Board::WIDTH as i32, win_width) + x * tile_size;
let y = center(tile_size * Board::HEIGHT as i32, win_height) + y * tile_size;
if filled {
self.fill_rect(x, y, 24, 24, color)?;
} else {
self.outline_rect(x, y, 24, 24, color)?;
}
Ok(())
}
fn draw_board(&mut self, board: &Board, current: &CurrentTetromino) -> Result<(), Err> {
let (win_width, win_height) = self.window_size()?;
self.outline_rect(
center(24 * Board::WIDTH as i32, win_width) - 1,
center(24 * Board::HEIGHT as i32, win_height) - 1,
24 * Board::WIDTH as i32 + 2,
24 * Board::HEIGHT as i32 + 2,
&Rgb(255, 255, 255),
)?;
for (y, row) in board.iter().enumerate() {
for (x, piece) in row.iter().enumerate() {
let color = match piece {
Some(t) => Rgb::from_tetromino(t),
None => Rgb(0, 0, 0),
};
self.draw_board_tile(x as i32, y as i32, &color, true)?
}
}
let pattern = current.tetromino.direction_pattern(&current.direction);
self.draw_tetromino_from_parts(
current.x,
board.lowest_y(&current),
Rgb(255, 255, 255),
pattern,
false,
)?;
self.draw_tetromino_from_parts(
current.x,
current.y,
Rgb::from_tetromino(&current.tetromino),
pattern,
true,
)?;
Ok(())
}
fn draw_important_text<P: AsRef<std::path::Path>, Text: AsRef<str>>(
&mut self,
font: P,
text: Text,
) -> Result<(), Err> {
let (win_width, win_height) = self.window_size()?;
let size = self.text_size(font.as_ref(), text.as_ref())?;
let width = size.0;
let height = size.1;
let x = center(width, win_width);
let y = center(height, win_height);
self.outline_rect(x - 9, y - 9, width + 18, height + 18, &Rgb(255, 255, 255))?;
self.fill_rect(x - 8, y - 8, width + 16, height + 16, &Rgb(16, 16, 16))?;
self.fill_text(font, text, x, y, width, height)?;
Ok(())
}
}
impl<T, Err> GameUiCtx<Err> for T where T: UiCtx<Err> {}
pub struct Rgb(pub u8, pub u8, pub u8);
impl Rgb {
pub fn from_tetromino(tetromino: &Tetromino) -> Self {
match tetromino {
Tetromino::I => Self(0, 255, 255),
Tetromino::J => Self(0, 0, 255),
Tetromino::L => Self(255, 128, 0),
Tetromino::O => Self(255, 255, 0),
Tetromino::S => Self(0, 255, 0),
Tetromino::T => Self(255, 0, 255),
Tetromino::Z => Self(255, 0, 0),
}
}
}
fn center(length: i32, max: i32) -> i32 {
(max - length) / 2
}

View File

@ -1,288 +1,11 @@
use actions::{Controls, ControlsHeld};
use board::Board;
use tetromino::{Direction, DirectionDiff, Tetromino};
use tetromino::Tetromino;
mod actions;
mod board;
mod game;
mod gui;
mod tetromino;
struct Rgb(u8, u8, u8);
struct CurrentTetromino {
tetromino: Tetromino,
direction: Direction,
x: i8,
y: i8,
}
impl CurrentTetromino {
fn new(tetromino: Tetromino) -> Self {
const PIECE_WIDTH: i8 = 2;
Self {
tetromino,
direction: Direction::Up,
x: (Board::WIDTH as i8 - PIECE_WIDTH) / 2,
y: -1,
}
}
}
struct Game {
board: Board,
next_tetrominos: [Tetromino; 3],
current_tetromino: CurrentTetromino,
held_tetromino: Option<Tetromino>,
has_swapped_held: bool,
score: Score,
ticks: usize,
}
struct Score {
level: usize,
points: usize,
lines: usize,
combo: usize,
back_to_back: bool,
}
impl Score {
const fn new() -> Self {
Self {
level: 0,
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
}
}
}
impl Game {
fn take_next_in_bag(&mut self, mut last: Tetromino) -> Tetromino {
for value in self.next_tetrominos.iter_mut().rev() {
std::mem::swap(value, &mut last)
}
last
}
fn hard_drop(&mut self, controls: &ControlsHeld) {
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;
}
}
fn soft_drop(&mut self, controls: &ControlsHeld) {
let mut delay = 32 - self.score.level * 2;
if controls.contains_key(&Controls::SoftDrop) {
delay /= 10;
}
if self.ticks % delay != 0 {
return;
}
self.current_tetromino.y += 1;
if self.board.colliding(&self.current_tetromino) {
self.current_tetromino.y -= 1;
self.place_current_tetromino();
self.check_line_clears();
} else if controls.contains_key(&Controls::SoftDrop) {
self.score.points += 1;
}
}
fn move_horizontally(&mut self, controls: &ControlsHeld) {
for key in [Controls::Left, Controls::Right] {
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 {
Controls::Left => -1,
Controls::Right => 1,
_ => unreachable!(),
};
self.current_tetromino.x += offset;
if self.board.colliding(&self.current_tetromino) {
self.current_tetromino.x -= offset;
}
}
}
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);
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 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 {
let rotated = self.current_tetromino.direction.rotate(&diff);
let old_direction = std::mem::replace(&mut self.current_tetromino.direction, rotated);
if !self.board.colliding(&self.current_tetromino) {
return true;
}
let wall_kicks = self
.current_tetromino
.tetromino
.wall_kicks(&old_direction, &diff);
for (x, y) in wall_kicks {
self.current_tetromino.x += x;
self.current_tetromino.y += y;
if !(self.board.colliding(&self.current_tetromino)) {
return true;
}
self.current_tetromino.x -= x;
self.current_tetromino.y -= y;
}
self.current_tetromino.direction = old_direction;
false
}
fn place_current_tetromino(&mut self) {
let next = CurrentTetromino::new(self.take_next_in_bag(Tetromino::random()));
let current = std::mem::replace(&mut self.current_tetromino, next);
let pattern = current.tetromino.direction_pattern(&current.direction);
for (y, row) in pattern.iter().enumerate() {
for x in row
.iter()
.enumerate()
.filter(|(_, exists)| **exists)
.map(|(x, _)| x)
{
let y = (current.y + y as i8) as usize;
let x = (current.x + x as i8) as usize;
self.board[y][x] = Some(current.tetromino.clone());
}
}
self.has_swapped_held = false;
}
fn try_swap_tetromino(&mut self) {
if self.has_swapped_held {
return;
}
self.has_swapped_held = true;
let held_or_first_in_bag_tetromino = self
.held_tetromino
.take()
.unwrap_or_else(|| self.take_next_in_bag(Tetromino::random()));
let current_tetromino = CurrentTetromino::new(held_or_first_in_bag_tetromino);
let old_tetromino = std::mem::replace(&mut self.current_tetromino, current_tetromino);
self.held_tetromino.replace(old_tetromino.tetromino);
}
}
fn main() {}
#[cfg(test)]
mod test {
use crate::{Board, CurrentTetromino, Game, Score, Tetromino};
#[test]
fn advance_bag() {
let mut game = Game {
board: Board::new(),
score: Score::new(),
next_tetrominos: [Tetromino::I, Tetromino::J, Tetromino::O],
current_tetromino: CurrentTetromino::new(Tetromino::J),
held_tetromino: None,
has_swapped_held: false,
ticks: 0,
};
assert_eq!(game.take_next_in_bag(Tetromino::S), Tetromino::I);
assert_eq!(
game.next_tetrominos,
[Tetromino::J, Tetromino::O, Tetromino::S]
);
}
fn main() {
gui::start_game().unwrap();
}

View File

@ -1,5 +1,3 @@
use crate::Rgb;
#[derive(Clone, Debug, PartialEq)]
pub enum Tetromino {
I,
@ -53,65 +51,173 @@ impl Tetromino {
}
}
const fn color(&self) -> Rgb {
match self {
Self::I => Rgb(0, 255, 255),
Self::J => Rgb(0, 0, 255),
Self::L => Rgb(255, 128, 0),
Self::O => Rgb(255, 255, 0),
Self::S => Rgb(0, 255, 0),
Self::T => Rgb(255, 0, 255),
Self::Z => Rgb(255, 0, 0),
}
}
pub fn direction_pattern(&self, direction: &Direction) -> [[bool; 4]; 4] {
let dir = match self {
Self::I => match direction {
Direction::Up => [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
Direction::Right => [[0, 0, 1, 0], [0, 0, 1, 0], [0, 0, 1, 0], [0, 0, 1, 0]],
Direction::Down => [[0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0]],
Direction::Left => [[0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]],
Direction::Up => [
['-', '-', '-', '-'],
['#', '#', '#', '#'],
['-', '-', '-', '-'],
['-', '-', '-', '-'],
],
Direction::Right => [
['-', '-', '#', '-'],
['-', '-', '#', '-'],
['-', '-', '#', '-'],
['-', '-', '#', '-'],
],
Direction::Down => [
['-', '-', '-', '-'],
['-', '-', '-', '-'],
['#', '#', '#', '#'],
['-', '-', '-', '-'],
],
Direction::Left => [
['-', '#', '-', '-'],
['-', '#', '-', '-'],
['-', '#', '-', '-'],
['-', '#', '-', '-'],
],
},
Self::J => match direction {
Direction::Up => [[0, 0, 0, 0], [1, 0, 0, 0], [1, 1, 1, 0], [0, 0, 0, 0]],
Direction::Right => [[0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 0, 0], [0, 1, 0, 0]],
Direction::Down => [[0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 1, 0], [0, 0, 1, 0]],
Direction::Left => [[0, 0, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], [1, 1, 0, 0]],
Direction::Up => [
['-', '-', '-', '-'],
['#', '-', '-', '-'],
['#', '#', '#', '-'],
['-', '-', '-', '-'],
],
Direction::Right => [
['-', '-', '-', '-'],
['-', '#', '#', '-'],
['-', '#', '-', '-'],
['-', '#', '-', '-'],
],
Direction::Down => [
['-', '-', '-', '-'],
['-', '-', '-', '-'],
['#', '#', '#', '-'],
['-', '-', '#', '-'],
],
Direction::Left => [
['-', '-', '-', '-'],
['-', '#', '-', '-'],
['-', '#', '-', '-'],
['#', '#', '-', '-'],
],
},
Self::L => match direction {
Direction::Up => [[0, 0, 0, 0], [0, 0, 1, 0], [1, 1, 1, 0], [0, 0, 0, 0]],
Direction::Right => [[0, 0, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 1, 0]],
Direction::Down => [[0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 1, 0], [1, 0, 0, 0]],
Direction::Left => [[0, 0, 0, 0], [1, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]],
},
Self::O => match direction {
Direction::Up => [[0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0]],
Direction::Right => [[0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0]],
Direction::Down => [[0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0]],
Direction::Left => [[0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0]],
Direction::Up => [
['-', '-', '-', '-'],
['-', '-', '#', '-'],
['#', '#', '#', '-'],
['-', '-', '-', '-'],
],
Direction::Right => [
['-', '-', '-', '-'],
['-', '#', '-', '-'],
['-', '#', '-', '-'],
['-', '#', '#', '-'],
],
Direction::Down => [
['-', '-', '-', '-'],
['-', '-', '-', '-'],
['#', '#', '#', '-'],
['#', '-', '-', '-'],
],
Direction::Left => [
['-', '-', '-', '-'],
['#', '#', '-', '-'],
['-', '#', '-', '-'],
['-', '#', '-', '-'],
],
},
Self::S => match direction {
Direction::Up => [[0, 0, 0, 0], [0, 1, 1, 0], [1, 1, 0, 0], [0, 0, 0, 0]],
Direction::Right => [[0, 0, 0, 0], [0, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 0]],
Direction::Down => [[0, 0, 0, 0], [0, 0, 0, 0], [0, 1, 1, 0], [1, 1, 0, 0]],
Direction::Left => [[0, 0, 0, 0], [1, 0, 0, 0], [1, 1, 0, 0], [0, 1, 0, 0]],
Direction::Up => [
['-', '-', '-', '-'],
['-', '#', '#', '-'],
['#', '#', '-', '-'],
['-', '-', '-', '-'],
],
Direction::Right => [
['-', '-', '-', '-'],
['-', '#', '-', '-'],
['-', '#', '#', '-'],
['-', '-', '#', '-'],
],
Direction::Down => [
['-', '-', '-', '-'],
['-', '-', '-', '-'],
['-', '#', '#', '-'],
['#', '#', '-', '-'],
],
Direction::Left => [
['-', '-', '-', '-'],
['#', '-', '-', '-'],
['#', '#', '-', '-'],
['-', '#', '-', '-'],
],
},
Self::T => match direction {
Direction::Up => [[0, 0, 0, 0], [0, 1, 0, 0], [1, 1, 1, 0], [0, 0, 0, 0]],
Direction::Right => [[0, 0, 0, 0], [0, 1, 0, 0], [0, 1, 1, 0], [0, 1, 0, 0]],
Direction::Down => [[0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 1, 0], [0, 1, 0, 0]],
Direction::Left => [[0, 0, 0, 0], [0, 1, 0, 0], [1, 1, 0, 0], [0, 1, 0, 0]],
Direction::Up => [
['-', '-', '-', '-'],
['-', '#', '-', '-'],
['#', '#', '#', '-'],
['-', '-', '-', '-'],
],
Direction::Right => [
['-', '-', '-', '-'],
['-', '#', '-', '-'],
['-', '#', '#', '-'],
['-', '#', '-', '-'],
],
Direction::Down => [
['-', '-', '-', '-'],
['-', '-', '-', '-'],
['#', '#', '#', '-'],
['-', '#', '-', '-'],
],
Direction::Left => [
['-', '-', '-', '-'],
['-', '#', '-', '-'],
['#', '#', '-', '-'],
['-', '#', '-', '-'],
],
},
Self::Z => match direction {
Direction::Up => [[0, 0, 0, 0], [1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 0, 0]],
Direction::Right => [[0, 0, 0, 0], [0, 0, 1, 0], [0, 1, 1, 0], [0, 1, 0, 0]],
Direction::Down => [[0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 0, 0], [0, 1, 1, 0]],
Direction::Left => [[0, 0, 0, 0], [0, 1, 0, 0], [1, 1, 0, 0], [1, 0, 0, 0]],
Direction::Up => [
['-', '-', '-', '-'],
['#', '#', '-', '-'],
['-', '#', '#', '-'],
['-', '-', '-', '-'],
],
Direction::Right => [
['-', '-', '-', '-'],
['-', '-', '#', '-'],
['-', '#', '#', '-'],
['-', '#', '-', '-'],
],
Direction::Down => [
['-', '-', '-', '-'],
['-', '-', '-', '-'],
['#', '#', '-', '-'],
['-', '#', '#', '-'],
],
Direction::Left => [
['-', '-', '-', '-'],
['-', '#', '-', '-'],
['#', '#', '-', '-'],
['#', '-', '-', '-'],
],
},
Self::O => [
['-', '-', '-', '-'],
['-', '#', '#', '-'],
['-', '#', '#', '-'],
['-', '-', '-', '-'],
],
};
dir.map(|row| row.map(|v| v != 0))
dir.map(|row| row.map(|v| v != '-'))
}
pub const fn wall_kicks(&self, direction: &Direction, diff: &DirectionDiff) -> [(i8, i8); 5] {