5
\$\begingroup\$

In order to get a hang of the ECS paradigm of game making, I decided to try my hand at making a clone of Tetris. I am using the specs crate for Rust in order to facilitate this.

The game is on a non-standard 10x10 grid, played in the terminal (although the rendering function is omitted because it's 100 lines long and not really what I'm interested in).

Without further ado, here are the files. First, main.rs, containing all the setup and the core game loop.

extern crate rand;

use std::time::{SystemTime, Duration};

extern crate specs;
use specs::{World, DispatcherBuilder};

extern crate termion;
use termion::{async_stdin, raw::IntoRawMode};
use std::io::{Read, stdout};

mod components;
use components::{Cell, Fixed, Tetromino, Next, Center, Score, IsGameOver, ReadyForNext};

mod systems;
use systems::{GameStart, MoveDown, MoveRight, MoveLeft, Rotate, ClearRow, Render, ShouldFix, Fix, GameEnd, NewTetro};

fn main() {
    let mut stdin = async_stdin().bytes();
    let _stdout = stdout().into_raw_mode().unwrap();

    let mut world = World::new();
    world.register::<Cell>();
    world.register::<Fixed>();
    world.register::<Tetromino>();
    world.register::<Next>();
    world.register::<Center>();
    world.add_resource(IsGameOver(false));
    world.add_resource(ReadyForNext(true));
    world.add_resource(Score(0));


    let mut start = DispatcherBuilder::new()
        .with(GameStart, "starter", &[])
        .build();

    let mut progress = DispatcherBuilder::new()
        .with(ShouldFix, "fixer", &[])
        .with(Fix, "fix", &["fixer"])
        .with(GameEnd, "ender", &["fix"])
        .with(NewTetro, "new", &["ender"])
        .with(MoveDown, "move_down", &["new"])
        .with(ClearRow, "clear_row", &["move_down"])
        .with(Render, "render", &["clear_row"])
        .build();
    let mut move_left = DispatcherBuilder::new()
        .with(MoveLeft, "move_left", &[])
        .with(Render, "render", &["move_left"])
        .build();
    let mut move_right = DispatcherBuilder::new()
        .with(MoveRight, "move_right", &[])
        .with(Render, "render", &["move_right"])
        .build();
    let mut rotate = DispatcherBuilder::new()
        .with(Rotate, "rotate", &[])
        .with(Render, "render", &["rotate"])
        .build();

    start.dispatch(&mut world.res);
    world.maintain();

    let mut last_update = SystemTime::now();

    while !world.read_resource::<IsGameOver>().0 {
        if let Some(Ok(k)) = stdin.next() {
            match k {
                65 => {
                    rotate.dispatch(&mut world.res);
                    world.maintain();
                },
                66 => {
                    progress.dispatch(&mut world.res);
                    world.maintain();
                    last_update = SystemTime::now();
                },
                67 => {
                    move_right.dispatch(&mut world.res);
                    world.maintain();
                },
                68 => {
                    move_left.dispatch(&mut world.res);
                    world.maintain();
                },
                13 | 3 => break,
                _ => {}
            }
        }
        if let Ok(d) = SystemTime::now().duration_since(last_update) {
            if d > Duration::from_millis(1000) {
                progress.dispatch(&mut world.res);
                world.maintain();
                last_update = SystemTime::now();
            }
        }
    }

}

Next is the file components.rs, containing all the component definitions.

use specs::{Component, VecStorage, NullStorage};

#[derive(Default)]
pub struct IsGameOver(pub bool);

#[derive(Default)]
pub struct Score(pub u32);

#[derive(Default)]
pub struct ReadyForNext(pub bool);

// A single active cell in the tetris game.
// Empty cells are not stored.
#[derive(Debug)]
pub struct Cell {
    pub x: i8,
    pub y: i8,
}

impl Component for Cell {
    type Storage = VecStorage<Self>;
}

// Flag for cells which have landed and stuck
#[derive(Default)]
pub struct Fixed;

impl Component for Fixed {
    type Storage = NullStorage<Self>;
}

// Flag for cells which are part of a falling tetromino
#[derive(Default)]
pub struct Tetromino;

impl Component for Tetromino {
    type Storage = NullStorage<Self>;
}

// The rotational center cell of a tetromino
// A tetromino doesn't always rotate about the center
// of any given cell, which is what the offset
// is for. And there are four offset coordinates to
// make sure that rotating a tetromino four times 
// (twice for T and I blocks, and once for O block)
// takes it back to where it started
#[derive(Default)]
pub struct Center {
    pub rot_offset: [(i8, i8); 4],
    pub offset_index: usize,
}

impl Component for Center {
    type Storage = VecStorage<Self>;
}

// Flag for cells which are part of the next tetromino
#[derive(Default)]
pub struct Next;

impl Component for Next {
    type Storage = NullStorage<Self>;
}

And finally, the systems.rs file, containing all the systems.

use rand::Rng;

use specs::{System,
            ReadStorage,
            WriteStorage,
            Join,
            Entities,
            Write as WriteResource,
            Read as ReadResource};

use super::components::*;

use termion::{color, cursor, clear, style};

// Starts the game with one tetromino at the
// top and one tetromino as next.
pub struct GameStart;

impl<'a> System<'a> for GameStart {
    type SystemData = (WriteStorage<'a, Cell>,
                       WriteStorage<'a, Tetromino>,
                       WriteStorage<'a, Next>,
                       WriteStorage<'a, Center>,
                       Entities<'a>);

    fn run(&mut self, (mut cells, mut tetros, mut nexts, mut centers, ents): Self::SystemData) {
        let mut rand = rand::thread_rng();
        let r: usize = rand.gen_range(0, 7);
        for i in 0..4 {
            let ent = ents.create();
            nexts.insert(ent, Next {})
                .expect("Couldn't make new 'next' tetromino");
            cells.insert(ent, Cell { x: NEW_COORDS[r][i].0, y: NEW_COORDS[r][i].1 })
                .expect("Couldn't make new 'next' tetromino");
            if i == 0 {
                centers.insert(ent, Center { rot_offset: ROT_OFFSET[r], offset_index: 0 })
                    .expect("Couldn't make new 'next' tetromino");
            }
        }
        let r: usize = rand.gen_range(0, 7);
        for i in 0..4 {
            let ent = ents.create();
            tetros.insert(ent, Tetromino {})
                .expect("Couldn't make new 'next' tetromino");
            cells.insert(ent, Cell { x: NEW_COORDS[r][i].0 - 10, y: NEW_COORDS[r][i].1 + 2})
                .expect("Couldn't make new 'next' tetromino");
            if i == 0 {
                centers.insert(ent, Center { rot_offset: ROT_OFFSET[r], offset_index: 0 })
                    .expect("Couldn't make new 'next' tetromino");
            }
        }
    }
}


// Moves the tetromino down one unit,
// if there is space
pub struct MoveDown;

impl<'a> System<'a> for MoveDown {
    type SystemData = (WriteStorage<'a, Cell>,
                       ReadStorage<'a, Tetromino>,
                       ReadResource<'a, ReadyForNext>,
                       ReadResource<'a, IsGameOver>);

    fn run(&mut self, (mut cells, tetros, ready_for_next, game_over): Self::SystemData) {
        if game_over.0 | ready_for_next.0 {
            return;
        }
        for (cell, _tetro) in (&mut cells, &tetros).join() {
            cell.y = cell.y-1;
        }
    }
}

// Moves a tetromino right one unit,
// if there is room
pub struct MoveRight;

impl<'a> System<'a> for MoveRight {
    type SystemData = (WriteStorage<'a, Cell>,
                       ReadStorage<'a, Tetromino>,
                       ReadStorage<'a, Fixed>,
                       ReadResource<'a, IsGameOver>);

    fn run(&mut self, (mut cells, tetros, fixeds, game_over): Self::SystemData) {
        if game_over.0 {
            return;
        }
        let mut tetro_coords = Vec::new();
        for (cell, _tetro) in (&cells, &tetros).join() {
            if cell.x == 9 { 
                return;
            }
            tetro_coords.push((cell.x+1, cell.y));
        }
        for (cell, _fixed) in (&cells, &fixeds).join() {
            if tetro_coords.contains(&(cell.x, cell.y)) {
                return;
            }
        }
        for (cell, _tetro) in (&mut cells, &tetros).join() {
            cell.x = cell.x + 1;
        }
    }
}

// Moves a tetromino left one unit,
// if there is room
pub struct MoveLeft;

impl<'a> System<'a> for MoveLeft {
    type SystemData = (WriteStorage<'a, Cell>,
                       ReadStorage<'a, Tetromino>,
                       ReadStorage<'a, Fixed>,
                       ReadResource<'a, IsGameOver>);

    fn run(&mut self, (mut cells, tetros, fixeds, game_over): Self::SystemData) {
        if game_over.0 {
            return;
        }

        let mut tetro_coords = Vec::new();
        for (cell, _tetro) in (&cells, &tetros).join() {
            if cell.x == 0 {
                return;
            }
            tetro_coords.push((cell.x-1, cell.y));
        }
        for (cell, _fixed) in (&cells, &fixeds).join() {
            if tetro_coords.contains(&(cell.x, cell.y)) {
                return;
            }
        }
        for (cell, _tetro) in (&mut cells, &tetros).join() {
            cell.x = cell.x - 1;
        }
    }
}

// Rotates a tetromino ninety degrees,
// if there is room
pub struct Rotate;

impl<'a> System<'a> for Rotate {
    type SystemData = (WriteStorage<'a, Cell>,
                       ReadStorage<'a, Tetromino>,
                       ReadStorage<'a, Fixed>,
                       WriteStorage<'a, Center>,
                       ReadResource<'a, IsGameOver>);

    fn run(&mut self, (mut cells, tetros, fixeds, mut centers, game_over): Self::SystemData) {
        if game_over.0 {
            return;
        }
        let mut center_coord: (i8, i8) = (0, 0);
        let mut rot_offset: (i8, i8) = (0, 0);
        for (cell, _tetro, center) in (&cells, &tetros, &centers).join() {
            center_coord = (cell.x, cell.y);
            rot_offset = center.rot_offset[center.offset_index];
        }
        let mut tetro_coords = Vec::new();
        for (cell, _tetro) in (&cells, &tetros).join() {
            tetro_coords.push((center_coord.0 + center_coord.1 - cell.y + rot_offset.0, 
                               center_coord.1 + cell.x - center_coord.0 + rot_offset.1));
            if let Some(&(x, y)) = tetro_coords.last() {
                if (x < 0) | (x >= 10) | (y < 0) {
                    return;
                }
            }
        }
        for (cell, _fixed) in (&cells, &fixeds).join() {
            if tetro_coords.contains(&(cell.x, cell.y)) {
                return;
            }
        }
        for (cell, _tetro) in (&mut cells, &tetros).join() {
            let new_x = center_coord.0 + center_coord.1 - cell.y + rot_offset.0; 
            cell.y = center_coord.1 + cell.x - center_coord.0 + rot_offset.1;
            cell.x = new_x;
        }
        for (_tetro, center) in (&tetros, &mut centers).join() {
            center.offset_index += 1;
            if center.offset_index >= 4 {
                center.offset_index -= 4;
            }
        }
    }
}

// Checks if the current tetromino should be fixed
pub struct ShouldFix;

impl<'a> System<'a> for ShouldFix {
    type SystemData = (ReadStorage<'a, Cell>,
                       ReadStorage<'a, Tetromino>,
                       ReadStorage<'a, Fixed>,
                       WriteResource<'a, ReadyForNext>);

    fn run(&mut self, 
           (cells, tetros, fixeds, mut ready_for_next): Self::SystemData) {
        let mut tetro_coords = Vec::new();
        let mut should_fix = false;

        for (cell, _tetro) in (&cells, &tetros).join() {
            if cell.y == 0 {
                should_fix = true;
            }
            tetro_coords.push((cell.x, cell.y - 1));
        }

        for (cell, _fixed) in (&cells, &fixeds).join() {
            if tetro_coords.contains(&(cell.x, cell.y)) {
                should_fix = true;
            }
        }

        ready_for_next.0 = should_fix;
    }
}

// Fixes the next tetromino if ShouldFix figured out it ought to
pub struct Fix;

impl<'a> System<'a> for Fix {
    type SystemData = (WriteStorage<'a, Cell>,
                       WriteStorage<'a, Tetromino>,
                       WriteStorage<'a, Center>,
                       WriteStorage<'a, Fixed>,
                       Entities<'a>,
                       ReadResource<'a, ReadyForNext>);

    fn run(&mut self, (cells, mut tetros, mut centers, mut fixeds, ents, ready_for_next): Self::SystemData) {
        if !ready_for_next.0 {
            return;
        }

        for _ in (&cells, &ents, &tetros, centers.drain()).join() {}

        for (_cell, ent, _tetro) in (&cells, &ents, tetros.drain()).join() {
            fixeds.insert(ent, Fixed {})
                .expect("Couldn't fix tetromino");
        }
    }
}

// Checks if the game ought to end
pub struct GameEnd;

impl<'a> System<'a> for GameEnd {
    type SystemData = (ReadStorage<'a, Cell>,
                       ReadStorage<'a, Fixed>,
                       ReadStorage<'a, Next>,
                       ReadResource<'a, ReadyForNext>,
                       WriteResource<'a, IsGameOver>);

    fn run(&mut self, (cells, fixeds, nexts, ready_for_next, mut is_game_over): Self::SystemData) {
        if !ready_for_next.0 {
            return;
        }
        let mut fixed_coords = Vec::new();

        for (cell, _fixed) in (&cells, &fixeds).join() {
            fixed_coords.push((cell.x, cell.y));
        }
        for (cell, _next) in (&cells, &nexts).join() {
            if fixed_coords.contains(&(cell.x - 10, cell.y + 1)) {
                is_game_over.0 = true;
            }
        }
    }
}

// Puts the "next" tetromino into play, 
// if there is need and room, and makes a new "next"
pub struct NewTetro;

impl<'a> System<'a> for NewTetro {
    type SystemData = (WriteStorage<'a, Cell>,
                       WriteStorage<'a, Tetromino>,
                       WriteStorage<'a, Next>,
                       WriteStorage<'a, Center>,
                       Entities<'a>,
                       ReadResource<'a, ReadyForNext>,
                       ReadResource<'a, IsGameOver>);

    fn run(&mut self, (mut cells, mut tetros, mut nexts, mut centers, ents, ready_for_next, is_game_over): Self::SystemData) {
        if !ready_for_next.0 | is_game_over.0 {
            return;
        }

        for (cell, ent, _next) in (&mut cells, &ents, nexts.drain()).join() {
            cell.x -= 10;
            cell.y += 1;
            tetros.insert(ent, Tetromino {})
                .expect("Couldn't start next tetromino");
        }
        let mut rand = rand::thread_rng();
        let r: usize = rand.gen_range(0, 7);
        for i in 0..4 {
            let ent = ents.create();
            nexts.insert(ent, Next {})
                .expect("Couldn't make new 'next' tetromino");
            cells.insert(ent, Cell { x: NEW_COORDS[r][i].0, y: NEW_COORDS[r][i].1 })
                .expect("Couldn't make new 'next' tetromino");
            if i == 0 {
                centers.insert(ent, Center { rot_offset: ROT_OFFSET[r], offset_index: 0 })
                    .expect("Couldn't make new 'next' tetromino");
            }
        }
    }
}

const NEW_COORDS: [[(i8, i8); 4];7] = [[(15, 8), (14, 8), (13, 8), (15, 7)], // J
                                       [(13, 8), (14, 8), (15, 8), (13, 7)], // L
                                       [(14, 8), (13, 8), (15, 8), (16, 8)], // I
                                       [(14, 7), (13, 7), (15, 7), (14, 8)], // T
                                       [(14, 7), (13, 7), (14, 8), (15, 8)], // Z
                                       [(14, 8), (13, 8), (14, 7), (15, 7)], // S
                                       [(14, 8), (15, 8), (15, 7), (14, 7)]];// O

const ROT_OFFSET: [[(i8, i8); 4]; 7] = [[(-1, 0), (0, -1), (1, 0), (0, 1)],
                                        [(0, -1), (1, 0), (0, 1), (-1, 0)],
                                        [(0, 0), (1, 0), (-1, 1), (0, -1)],
                                        [(0, 0); 4],
                                        [(1, 1), (-1, 0), (0, 0), (0, -1)],
                                        [(0, 0), (0, -1), (1, 1), (-1, 0)],
                                        [(0, -1), (1, 0), (0, 1), (-1, 0)]];

pub struct ClearRow;

impl<'a> System<'a> for ClearRow {
    type SystemData = (WriteStorage<'a, Cell>,
                       ReadStorage<'a, Fixed>,
                       Entities<'a>,
                       WriteResource<'a, Score>);

    fn run(&mut self, (mut cells, fixeds, ents, mut score): Self::SystemData) {
        let mut cleared_rows: u32 = 0;
        let mut filled_rows = [0 as u8; 10];
        for(cell, _fixed) in (&cells, &fixeds).join() {
            if cell.y < 10 {
                filled_rows[cell.y as usize] += 1;
            }
        }

        // (*)
        for i in (0..10).filter(|i| filled_rows[*i as usize] == 10).rev() {
            let mut ents_to_delete = Vec::new();
            cleared_rows += 1;
            for (cell, _fixed, ent) in (&mut cells, &fixeds, &ents).join() {
                if cell.y as usize == i {
                    ents_to_delete.push(ent);
                }
                else if cell.y as usize > i {
                    cell.y -= 1;
                }
            }
            for ent in ents_to_delete {
                cells.remove(ent);
                ents.delete(ent)
                    .expect("Couldn't delete row");
            }
        }
        score.0 += cleared_rows*(cleared_rows + 1)*50;
    }
}

// Prints all the cells to the standard output (terminal)
pub struct Render;

impl<'a> System<'a> for Render {
    type SystemData = (ReadStorage<'a, Cell>,
                       ReadStorage<'a, Tetromino>,
                       ReadStorage<'a, Fixed>,
                       ReadStorage<'a, Next>,
                       ReadResource<'a, IsGameOver>,
                       ReadResource<'a, Score>);


    fn run(&mut self, (cells, tetros, fixeds, nexts, is_game_over, score): Self::SystemData) {
        // Omitted in this post.
    }
}

Any kind of feedback would be good to have, but I have a few specific things I'm worrying about:

  • There is a loop in the run() method of ClearRow annotated with a (*) comment. Here I had a comment from past me saying this was "easy" to do with a single for loop rather than a nested loop. But apparently a comment was too small a space to contain any hints to future me as to how this was supposed to be achieved. Is this feasible, or was past me wrong?
  • The progress dispatch, whose task is to progress the game by one frame, is a bit too big and chaotic for my taste. It does many things, possibly in the wrong order (not that I have noticed any bugs), and juggles two separate flags as it does so, both of which basically turns entire functions on or off. But i tried refactoring it, and that basically just ended in me calling the same several dispatches one after the other each time instead. So is it acceptable?
  • Is the code "Rust-y"?
  • I should probably have more files, shouldn't I?
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.