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


はじめに

前回は、テトリスのゲーム部分の実装を行いました。

blog1.mammb.com

今回は、winit と tiny-skia による描画をあわせて、ゲームを完成させましょう。

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

github.com


ウインドウの変更

前々回のウインドウ作成処理は以下のようなものでした。

fn main() {

    let event_loop = EventLoop::new().unwrap();

    let window = WindowBuilder::new()
        .with_inner_size(winit::dpi::LogicalSize::new(500, 300))
        .with_title("winit")
        .build(&event_loop).unwrap();

    let window = std::rc::Rc::new(window);
    let context = softbuffer::Context::new(window.clone()).unwrap();
    let mut surface = softbuffer::Surface::new(&context, window.clone()).unwrap();

    event_loop.set_control_flow(ControlFlow::Wait);

    let _ = event_loop.run(move |event, elwt| {
        match event {
            Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => elwt.exit(),
            Event::AboutToWait => window.request_redraw(),
            Event::WindowEvent {
                window_id, event: WindowEvent::RedrawRequested
            } if window_id == window.id() => {
                // 描画処理
            },
            _ => ()
        }
    });
}

最初にウインドウのサイズとタイトルを変更しておきましょう。

テトリスの盤面に合わせたサイズとし、タイトルも Tetris に変更しておきます。

    let window = WindowBuilder::new()
        .with_inner_size(winit::dpi::LogicalSize::new(200, 440))
        .with_title("Tetris")
        .build(&event_loop).unwrap();

次にコントロールフローを ControlFlow::Wait から ControlFlow::Poll に変更します。

    event_loop.set_control_flow(ControlFlow::Poll);

ControlFlow::Poll は、OSがイベントをディスパッチしていなくても、イベントループを継続的に実行します。一方、ControlFlow::Wait は、処理可能なイベントがない場合にイベントループを一時停止します。ゲームなどのアプリケーションでは、ControlFlow::Poll を選択します。

コントロールフローの変更に加えて、テトリスのインスタンスも追加しておきましょう。

    event_loop.set_control_flow(ControlFlow::Poll);

    let mut game: Tetris = Tetris::new();
    // ...

続いて、キー入力を処理します。


キーイベントの捕捉

winit のイベントは以下のような定義となっています。

pub enum Event<T: 'static> {
    NewEvents(StartCause),
    WindowEvent {
        window_id: WindowId,
        event: WindowEvent,
    },
    DeviceEvent {
        device_id: DeviceId,
        event: DeviceEvent,
    },
    UserEvent(T),
    Suspended,
    Resumed,
    AboutToWait,
    LoopExiting,
    MemoryWarning,
}

同じ名前で紛らわしいですが、WindowEvent の中の event: WindowEvent は以下の定義となっており、この内の KeyboardInput でキー入力を処理することができます。

pub enum WindowEvent {
    ActivationTokenDone {
        serial: AsyncRequestSerial,
        token: ActivationToken,
    },
    Resized(PhysicalSize<u32>),
    Moved(PhysicalPosition<i32>),
    CloseRequested,
    Destroyed,
    DroppedFile(PathBuf),
    HoveredFile(PathBuf),
    HoveredFileCancelled,
    Focused(bool),
    KeyboardInput {
        device_id: DeviceId,
        event: KeyEvent,
        is_synthetic: bool,
    },
    // 略
}

Event::WindowEventWindowEvent::KeyboardInput をパターンマッチに追加します。

match event {
    Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => elwt.exit(),
    Event::WindowEvent {
        event: WindowEvent::KeyboardInput {event, .. },
        ..
    } if event.state.is_pressed() => {
        match event.logical_key {
            Named(NamedKey::ArrowRight) => game.key_pressed(Key::RIGHT),
            Named(NamedKey::ArrowLeft) => game.key_pressed(Key::LEFT),
            Named(NamedKey::ArrowDown) => game.key_pressed(Key::DOWN),
            Named(NamedKey::ArrowUp) => game.key_pressed(Key::UP),
            Named(NamedKey::Space) => game.key_pressed(Key::SP),
            Named(NamedKey::Escape) => game.rerun(),
            _ => game.key_pressed(Key::OTHER),
        };
        window.request_redraw();
    },
    Event::AboutToWait => {
        // ...

event.logical_key の内容に応じて、ゲームのインスタンスのメソッドをコールするだけです。

ここでは、常に window.request_redraw(); で再描画をリクエストしていますが、必要に応じて再描画を行う方が良いでしょう。が、ここでは簡単のため、そのままにします。


続いて AboutToWait の内容を変更します。

前回まででは、再描画を要求するのみでしたが、ここでゲームを進めるために game.tick() を呼び出すことにします。

Event::AboutToWait => {
    if !game.stopped {
        game.tick();
        window.set_title(format!("Tetris:{}", game.score).as_str());
        window.request_redraw();
    }
},

加えて、ゲームスコアをタイトルに設定し、再描画をリクエストします。

ゲームが終了していた場合は、何も処理を行いません。


ゲームの描画

ゲームの描画は再描画要求に応じて、以下のように書きます。

Event::WindowEvent {
    window_id, event: WindowEvent::RedrawRequested
} if window_id == window.id() => {
    let (width, height) = {
        let size = window.inner_size();
        (size.width, size.height)
    };
    surface.resize(
        core::num::NonZeroU32::new(width).unwrap(),
        core::num::NonZeroU32::new(height).unwrap(),
    ).unwrap();

    let mut pixmap = Pixmap::new(width, height).unwrap();
    game.draw(&mut pixmap);
    let mut buffer = surface.buffer_mut().unwrap();
    for index in 0..(width * height) as usize {
        buffer[index] =
            pixmap.data()[index * 4 + 2] as u32
         | (pixmap.data()[index * 4 + 1] as u32) << 8
         | (pixmap.data()[index * 4 + 0] as u32) << 16;
    }
    buffer.present().unwrap();
},

前々回に見た通り、pixmap にグラフィックス書き込みを行い、バッファに反映する流れは同様です。

ゲームの描画は、game.draw(&mut pixmap); として委譲しています。

実装は以下のようになります。

impl Tetris {
    // ...
    fn draw(&self, pixmap: &mut Pixmap) {
        for y in 0..BOARD_HEIGHT {
            for x in 0..BOARD_WIDTH {
                Tetris::draw_square(
                    pixmap,
                    x * UNIT_SIZE,
                    y * UNIT_SIZE,
                    self.shape_at(x, BOARD_HEIGHT - y - 1));
            }
        }

        for i in 0..4 {
            let (x, y) = self.current.point(i);
            Tetris::draw_square(
                pixmap,
                x * UNIT_SIZE,
                (BOARD_HEIGHT - y - 1) * UNIT_SIZE,
                self.current.obj.kind);
        }
    }

最初に盤面のブロックを書き込み、その後、落下ブロックを書き込んでいます。

draw_square() では種類に応じて色違いの四角形を描画しているだけです。

impl Tetris {
    // ...
    fn draw_square(pixmap: &mut Pixmap, x: i16, y: i16, kind: Tetromino) {
        if kind == Tetromino::X {
            return;
        }
        let rect = Rect::from_xywh(
            (x + 1) as f32,
            (y + 1) as f32,
            (UNIT_SIZE - 2) as f32,
            (UNIT_SIZE - 2) as f32,
        ).unwrap();
        let path = PathBuilder::from_rect(rect);
        let mut paint = Paint::default();
        let (r ,g, b) = kind.color();
        paint.set_color_rgba8(r, g, b, 255);
        pixmap.fill_path(
            &path,
            &paint,
            FillRule::EvenOdd,
            Transform::identity(),
            None,
        );
    }

}


ゲームの実行

以上で実装は完了です。

モジュール分割なども行わない、雑な実装になりますが、まとめると以下のコードになります。

use winit::event::{ Event, WindowEvent };
use winit::event_loop::{ ControlFlow, EventLoop };
use winit::window::WindowBuilder;
use tiny_skia::{ FillRule, Paint, PathBuilder, Pixmap, Rect, Transform };
use winit::keyboard::{ Key::Named, NamedKey };
use std::time::{ Duration, SystemTime };

fn main() {

    let event_loop = EventLoop::new().unwrap();

    let window = WindowBuilder::new()
        .with_inner_size(winit::dpi::LogicalSize::new(200, 440))
        .with_title("Tetris")
        .build(&event_loop).unwrap();

    let window = std::rc::Rc::new(window);
    let context = softbuffer::Context::new(window.clone()).unwrap();
    let mut surface = softbuffer::Surface::new(&context, window.clone()).unwrap();

    event_loop.set_control_flow(ControlFlow::Poll);

    let mut game: Tetris = Tetris::new();

    let _ = event_loop.run(move |event, elwt| {
        match event {
            Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => elwt.exit(),
            Event::WindowEvent {
                event: WindowEvent::KeyboardInput {event, .. },
                ..
            } if event.state.is_pressed() => {
                match event.logical_key {
                    Named(NamedKey::ArrowRight) => game.key_pressed(Key::RIGHT),
                    Named(NamedKey::ArrowLeft) => game.key_pressed(Key::LEFT),
                    Named(NamedKey::ArrowDown) => game.key_pressed(Key::DOWN),
                    Named(NamedKey::ArrowUp) => game.key_pressed(Key::UP),
                    Named(NamedKey::Space) => game.key_pressed(Key::SP),
                    Named(NamedKey::Escape) => game.rerun(),
                    _ => game.key_pressed(Key::OTHER),
                };
                window.request_redraw();
            },
            Event::AboutToWait => {
                if !game.stopped {
                    game.tick();
                    window.set_title(format!("Tetris:{}", game.score).as_str());
                    window.request_redraw();
                }
            },
            Event::WindowEvent {
                window_id, event: WindowEvent::RedrawRequested
            } if window_id == window.id() => {
                let (width, height) = {
                    let size = window.inner_size();
                    (size.width, size.height)
                };
                surface.resize(
                    core::num::NonZeroU32::new(width).unwrap(),
                    core::num::NonZeroU32::new(height).unwrap(),
                ).unwrap();

                let mut pixmap = Pixmap::new(width, height).unwrap();
                game.draw(&mut pixmap);
                let mut buffer = surface.buffer_mut().unwrap();
                for index in 0..(width * height) as usize {
                    buffer[index] =
                        pixmap.data()[index * 4 + 2] as u32
                     | (pixmap.data()[index * 4 + 1] as u32) << 8
                     | (pixmap.data()[index * 4 + 0] as u32) << 16;
                }
                buffer.present().unwrap();
            },
            _ => ()
        }
    });
}

/// Tetromino is a geometric shape composed of four squares, connected orthogonally.
#[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)
        }
    }
}

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

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]
    }

}

#[derive(Debug)]
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])
    }
}

/// Type of key.
enum Key { LEFT, RIGHT, UP, DOWN, SP, OTHER, }

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;

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

impl Tetris {

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

    fn rerun(&mut self) {
        self.board = [Tetromino::X; BOARD_LEN];
        self.current = FallingBlock::empty();
        self.stopped = false;
        self.time = SystemTime::now();
        self.score = 0;
    }

    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(); },
        };
    }

    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();
        }
    }

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

    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
    }

    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();
    }

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

    fn draw(&self, pixmap: &mut Pixmap) {
        for y in 0..BOARD_HEIGHT {
            for x in 0..BOARD_WIDTH {
                Tetris::draw_square(
                    pixmap,
                    x * UNIT_SIZE,
                    y * UNIT_SIZE,
                    self.shape_at(x, BOARD_HEIGHT - y - 1));
            }
        }

        for i in 0..4 {
            let (x, y) = self.current.point(i);
            Tetris::draw_square(
                pixmap,
                x * UNIT_SIZE,
                (BOARD_HEIGHT - y - 1) * UNIT_SIZE,
                self.current.obj.kind);
        }
    }

    fn draw_square(pixmap: &mut Pixmap, x: i16, y: i16, kind: Tetromino) {
        if kind == Tetromino::X {
            return;
        }
        let rect = Rect::from_xywh(
            (x + 1) as f32,
            (y + 1) as f32,
            (UNIT_SIZE - 2) as f32,
            (UNIT_SIZE - 2) as f32,
        ).unwrap();
        let path = PathBuilder::from_rect(rect);
        let mut paint = Paint::default();
        let (r ,g, b) = kind.color();
        paint.set_color_rgba8(r, g, b, 255);
        pixmap.fill_path(
            &path,
            &paint,
            FillRule::EvenOdd,
            Transform::identity(),
            None,
        );
    }

}

実行してみましょう。

cargo run

うまく動いていそうですね。

700 点を超えるのは、なかなか難しいです。


まとめ

Rust の GUI 周りを調べていたところ、なぜかテトリスができあがりました。

不思議です。