【Rust】winit と tiny-skia で低レベルなグラフィックス描画 〜テトリスを作る①〜


はじめに

前回は、winit と tiny-skia にて簡単なウインドウへの描画方法について説明しました。

blog1.mammb.com

今回は、もう少しだけ凝った例として、テトリスをゲームできるようにしていきたいと思います。

実装の全体像は以下のリポジトリを参照してください。

github.com

では、テトリスのゲーム部分を作っていきましょう。


テトロミノ

同じ大きさの4個の正方形を辺に沿ってつなげた形を総称してテトロミノと呼びます。 4つの正方形を辺に沿ってつなげた形は回転によって同じになるものを同一と考えると7種類があり、それぞれを I型、O型、T型、L型、S型、L型の鏡像をJ型、S型の鏡像をZ型とします。

これらの種類を Tetromino として以下のように定義します。

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Tetromino { S, Z, I, T, O, J, L, X, }

impl Tetromino {
    fn rand() -> Tetromino {
        match rand::random::<u16>() % 7 {
            0 => Tetromino::S, 1 => Tetromino::Z,
            2 => Tetromino::I, 3 => Tetromino::T,
            4 => Tetromino::O, 5 => Tetromino::J,
            6 => Tetromino::L, _ => Tetromino::X,
        }
    }

    fn points(&self) -> [[i16; 2]; 4] {
        match self {
            Tetromino::S => [[ 0, -1], [0,  0], [-1, 0], [-1, 1]],
            Tetromino::Z => [[ 0, -1], [0,  0], [ 1, 0], [ 1, 1]],
            Tetromino::I => [[ 0, -1], [0,  0], [ 0, 1], [ 0, 2]],
            Tetromino::T => [[-1,  0], [0,  0], [ 1, 0], [ 0, 1]],
            Tetromino::O => [[ 0,  0], [1,  0], [ 0, 1], [ 1, 1]],
            Tetromino::J => [[-1, -1], [0, -1], [ 0, 0], [ 0, 1]],
            Tetromino::L => [[ 1, -1], [0, -1], [ 0, 0], [ 0, 1]],
            Tetromino::X => [[ 0,  0], [0,  0], [ 0, 0], [ 0, 0]],
        }
    }

    fn is_rotatable(&self) -> bool {
        !(matches!(self, Tetromino::O) || matches!(self, Tetromino::X))
    }

    fn color(&self) -> (u8, u8, u8) {
        match self {
            Tetromino::S => (204, 102, 102),
            Tetromino::Z => (102, 204, 102),
            Tetromino::I => (104, 102, 204),
            Tetromino::T => (204, 204, 102),
            Tetromino::O => (204, 102, 204),
            Tetromino::J => (204, 204, 204),
            Tetromino::L => (218, 170,   0),
            _            => (  0,   0,   0)
        }
    }
}

原点を中心とした座標位置を points() で得ます。これらの座標は、以下に該当します。

後述の処理のため、空のテトロミノは Tetromino::X として定義しています。

それぞれのテトロミノには color() で色の定義を(r, g, b) のタプルで取得できるようにしています。


テトロミノをランダムに生成するため、rand クレートを入れておきます。

cargo add rand

rand クレートにて、以下のような関連関数で、テトロミノをランダムに生成(空のテトロミノ以外)しています。

    fn rand() -> Tetromino {
        match rand::random::<u16>() % 7 { ... }


ブロック

テトリスでは、テトロミノを回転するブロックとして扱うため、これをそのまま Block 構造体として定義します。

#[derive(Copy, Clone, Debug)]
struct Block {
    kind: Tetromino,
    points: [[i16; 2]; 4],
}

ブロックは rand() でランダムなものを生成できるようにし、rotate_left()rotate_right() で座標を回転できるようにします。

impl Block {

    fn rand() -> Block {
        Block::block(Tetromino::rand())
    }

    fn block(t: Tetromino) -> Block {
        Block { kind: t, points: t.points() }
    }

    fn rotate_left(&self) -> Block {
        if self.kind.is_rotatable() {
            let mut points: [[i16; 2]; 4] = [[0; 2]; 4];
            for i in 0..4 {
                points[i] = [self.points[i][1], -self.points[i][0]];
            }
            Block { points, ..*self }
        } else {
            *self
        }
    }

    fn rotate_right(&self) -> Block {
        if self.kind.is_rotatable() {
            let mut points: [[i16; 2]; 4] = [[0; 2]; 4];
            for i in 0..4 {
                points[i] = [-self.points[i][1], self.points[i][0]];
            }
            Block { points, ..*self }
        } else {
            *self
        }
    }

    fn min_y(&self) -> i16 {
        self.points.iter().min_by_key(|p| p[1]).unwrap()[1]
    }

}

回転操作は、原点から90度単位での回転だけ考えれば良いため、座標の x と y を入れ替えて一方の符号を反転させるだけで済みます。

以下の処理は、ブロック追加位置調整のため、最小の座標位置を取得するメソッドです。

fn min_y(&self) -> i16 {
    self.points.iter().min_by_key(|p| p[1]).unwrap()[1]
}

以下と同様の処理をイテレータで行っているだけです。

let mut ret = self.points[0][1];
for i in 0..4 {
    ret = std::cmp::min(ret, self.points[i][1]);
}
ret


盤面と落下ブロック

盤面は 10 x 22 マスとし、1マスは20ピクセルとして定数定義しておきます。

const UNIT_SIZE: i16 = 20;
const BOARD_WIDTH: i16 = 10;
const BOARD_HEIGHT: i16 = 22;
const BOARD_LEN: usize = BOARD_WIDTH as usize * BOARD_HEIGHT as usize;

落下ブロックは、盤面の座標位置と対象のブロックの構造体として以下のように定義できます。

struct FallingBlock {
    x: i16, y: i16, obj: Block,
}

落下ブロックは、左右への移動、落下、回転の操作をメソッドとして追加します。

impl FallingBlock {

    fn new() -> Self {
        let obj = Block::rand();
        FallingBlock {
            x: BOARD_WIDTH / 2,
            y: BOARD_HEIGHT - 1 + obj.min_y(),
            obj,
        }
    }

    fn empty() -> Self {
        FallingBlock { x: 0, y: 0, obj: Block::block(Tetromino::X) }
    }

    fn down(&self) -> FallingBlock {
        FallingBlock { y: self.y - 1, ..*self }
    }

    fn left(&self) -> FallingBlock {
        FallingBlock { x: self.x - 1, ..*self }
    }

    fn right(&self) -> FallingBlock {
        FallingBlock { x: self.x + 1, ..*self }
    }

    fn rotate_left(&self) -> FallingBlock {
        FallingBlock { obj: self.obj.rotate_left(), ..*self }
    }

    fn rotate_right(&self) -> FallingBlock {
        FallingBlock { obj: self.obj.rotate_right(), ..*self }
    }

    fn is_empty(&self) -> bool {
        self.obj.kind == Tetromino::X
    }

    fn point(&self, i: usize) -> (i16, i16) {
        (self.x + self.obj.points[i][0], self.y - self.obj.points[i][1])
    }
}

コンストラクタ new() では盤面の上部中央位置に、ランダムな落下ブロックを生成する処理となります。

ブロックの移動では座標位置を更新し、回転ではブロック位置を回転させる操作となります。

落下ブロックの操作時に利用するキー値についても列挙で準備しておきます。

enum Key { LEFT, RIGHT, UP, DOWN, SP, OTHER, }


テトリス

準備が整ったので、テトリス構造体を定義します。

struct Tetris {
    board: [Tetromino; BOARD_LEN],
    current: FallingBlock,
    stopped: bool,
    time: SystemTime,
    score: u32,
}

盤面は board として1次元の配列(BOARD_WIDTH * BOARD_HEIGHT)として定義しています。

current: FallingBlock が落下する操作対象のブロックで、下まで落下した時点で、board の中に盤面ブロックとして移し替えることになります。

time は、ブロックの落下スピードを制御するために、前回の落下時間を保持します。

コンストラクタで以下のように初期化します。

impl Tetris {

    fn new() -> Self {
        Tetris {
            board: [Tetromino::X; BOARD_LEN],
            current: FallingBlock::empty(),
            stopped: false,
            time: SystemTime::now(),
            score: 0,
        }
    }
    // ...
}

board 全体は、空のテトロミノ Tetromino::X で満たしています。

tick() メソッドでは、落下ブロックがなければ追加(put_block())、前回落下から1秒経過で1ブロック分落下(down())させる処理を行います。

    fn tick(&mut self) {
        if self.current.is_empty() {
            self.put_block();
        } else if self.time.elapsed().unwrap() > Duration::from_millis((1000 - self.score) as u64) {
            self.down();
            self.time = SystemTime::now();
        }
    }

落下時間は、スコアが上がるにつれて短くなるようにしています。


ブロックの移動と当たり判定

キー操作に応じた処理は以下のようになります。

    fn key_pressed(&mut self, key: Key) {
        if self.stopped || self.current.is_empty() {
            return;
        }
        match key {
            Key::LEFT  => { self.try_move(self.current.left()); },
            Key::RIGHT => { self.try_move(self.current.right()); },
            Key::UP    => { self.try_move(self.current.rotate_right()); },
            Key::DOWN  => { self.try_move(self.current.rotate_left()); },
            Key::OTHER => { self.down(); },
            Key::SP    => { self.drop_down(); },
        };
    }

ここで、try_move() はブロックの当たり判定を行い、移動可能な場合に落下ブロックの位置を変更します。以下のようなコードになります。

    fn try_move(&mut self, block: FallingBlock) -> bool {
        for i in 0..4 {
            let (x, y) = block.point(i);
            if x < 0 || x >= BOARD_WIDTH || y < 0 || y >= BOARD_HEIGHT {
                return false
            }
            if self.shape_at(x, y) != Tetromino::X {
                return false
            }
        }
        self.current = block;
        true
    }

次の移動位置が盤面を超える場合は移動不可、Tetromino::X 以外のブロックが存在する場合は移動不可というチェックを、ブロックの各点についてチェックし、当たり無しの場合は、現在ブロックと置き換えます。

ある点のブロックは shape_at(x, y) で調べます。これは以下のようなメソッドになります。

    fn shape_at(&self, x: i16, y: i16) -> Tetromino {
        self.board[(y * BOARD_WIDTH + x) as usize]
    }


落下ブロックがまだ存在しない場合は、新しいブロックFallingBlock::new() を追加します。

    fn put_block(&mut self) {
        self.stopped = !self.try_move(FallingBlock::new());
    }

ブロックの落下は以下のようなメソッドで行います。

    fn down(&mut self) {
        if !self.try_move(self.current.down()) {
            self.block_dropped();
        }
    }

    fn drop_down(&mut self) {
        while self.current.y > 0 {
            if !self.try_move(self.current.down()) {
                break;
            }
        }
        self.block_dropped();
    }

    fn block_dropped(&mut self) {
        for i in 0..4 {
            let (x, y) = self.current.point(i);
            let index = (y * BOARD_WIDTH + x) as usize;
            self.board[index] = self.current.obj.kind;
        }
        self.remove_complete_lines();
        if self.current.is_empty() {
            self.put_block();
        }
    }

最初のメソッドは、ブロックを1段下へ、次のメソッドは、ブロックを最下段まで落下させるメソッドになります。

最後のメソッドでは、最下段まで落下した場合に、落下位置にブロックを配置する処理になります。

このメソッド中で self.remove_complete_lines(); により揃ったブロックを削除しています。 次のようなメソッドになります。

盤面を下から見ていき、横にブロックが揃った場合、その行を消して上部のブロックを下に移動する という処理を行っています。

    fn remove_complete_lines(&mut self) {
        let mut line_count = 0;

        for i in (0..BOARD_HEIGHT).rev() {
            let mut complete = true;
            for x in 0.. BOARD_WIDTH {
                if self.shape_at(x, i) == Tetromino::X {
                    complete = false;
                    break
                }
            }
            if complete {
                line_count += 1;
                for y in i..BOARD_HEIGHT - 1 {
                    for x in 0..BOARD_WIDTH {
                        self.board[(y * BOARD_WIDTH + x) as usize] = self.shape_at(x, y + 1);
                    }
                }
            }
        }
        self.score += line_count * line_count;
        self.current = FallingBlock::empty();
    }

スコアは、消した行数の2乗を加算していきます。4行削除で16点が入ります。

これでテトリスの中心部分はできたので、つづいて描画とキー操作の実装に入ります。

と思いましたが、テトリスの実装がメインになってしまっているので、続きは次回とします。