Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ゲームロジック

まず、ゲームロジックを説明します。おそらくスネークゲームはご存じだと思いますが、そうでない場合は、基本的な考え方として プレイヤーが 2D グリッド上でヘビを動かします。任意の時点で、グリッド上のランダムな場所にいくらかの「餌」があり、 ゲームの目的はヘビにできるだけ多くの餌を「食べ」させることです。ヘビが餌を食べるたびに、 その長さは伸びます。ヘビが自分自身のしっぽに衝突すると、プレイヤーの負けです。ゲームのバリアントによっては、ヘビが グリッドの端に衝突した場合にもプレイヤーの負けになりますが、今回のグリッドは小さいため、 「ラップアラウンド」ルールを実装します。これは、ヘビがグリッドの一方の端から外に出た場合、 反対側の端から続けて現れる、というものです。

game モジュール

このセクションのコードは、src ディレクトリ内の別ファイル game.rs に記述してください。

#![allow(unused)]
fn main() {
use heapless::FnvIndexSet;

/// グリッド上の 1 点。
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
struct Coords {
   // 負の値を扱えるように符号付き整数を使用する(グリッドの上端または
   // 左端から外に出たかどうかを確認するときに便利)
   row: i8,
   col: i8
}

impl Coords {
   /// グリッド内のランダムな座標を取得する。`exclude` は、
   /// 出力から除外すべき座標の省略可能な集合。
   fn random(
      rng: &mut Prng,  // `Prng` 構造体はこの後で定義する
      exclude: Option<&FnvIndexSet<Coords, 32>>
   ) -> Self {
      let mut coords = Coords {
         row: ((rng.random_u32() as usize) % 5) as i8,
         col: ((rng.random_u32() as usize) % 5) as i8
      };
      while exclude.is_some_and(|exc| exc.contains(&coords)) {
         coords = Coords {
            row: ((rng.random_u32() as usize) % 5) as i8,
            col: ((rng.random_u32() as usize) % 5) as i8
         }
      }
      coords
   }

   /// その点がグリッドの範囲外にあるかどうか。
   fn is_out_of_bounds(&self) -> bool {
      self.row < 0 || self.row >= 5 || self.col < 0 || self.col >= 5
   }
}
}

グリッド上の位置を表すために Coords 構造体を使います。Coords には 2 つの整数しか含まれていないため、 コンパイラに対して Copy トレイトの実装を derive するよう指定し、所有権を気にすることなく Coords 構造体を受け渡しできるようにします。

関連関数 Coords::random を定義すると、グリッド上のランダムな位置を取得できます。これは後で、 ヘビの餌をどこに配置するかを決めるために使用します。そのためには、乱数の供給源が必要です。nRF52833 には 乱数生成器(RNG)ペリフェラルがあり、仕様書 の第 6.19 節に記載されています。HAL は microbit::hal::rng::Rng 構造体を介して RNG へのシンプルなインターフェイスを提供しています。しかし、これは ブロッキングなインターフェイスであり、1 バイトの乱数データを生成するのに必要な時間は可変で予測できません。 そのため、xorshift アルゴリズムを使って疑似乱数の u32 値を生成する 疑似乱数 生成器(PRNG)を定義します。これを使って、餌をどこに配置するかを決定できます。 このアルゴリズムは基本的なもので、暗号学的に安全ではありませんが、効率的で実装が簡単であり、 私たちのささやかなスネークゲームには十分です。Prng 構造体には初期シード値が必要で、これは RNG ペリフェラルから取得します。

#![allow(unused)]
fn main() {
/// 基本的な疑似乱数生成器。
struct Prng {
    value: u32
}

impl Prng {
    fn new(seed: u32) -> Self {
        Self {value: seed}
    }

    /// 基本的な xorshift PRNG 関数: https://en.wikipedia.org/wiki/Xorshift を参照
    fn xorshift32(mut input: u32) -> u32 {
        input ^= input << 13;
        input ^= input >> 17;
        input ^= input << 5;
        input
    }

    /// 疑似乱数の `u32` を返す。
    fn random_u32(&mut self) -> u32 {
        self.value = Self::xorshift32(self.value);
        self.value
    }
}
}

ゲームの状態管理に役立ついくつかの enum も定義する必要があります。移動方向、曲がる方向、 現在のゲーム状態、そしてゲーム内の特定の「ステップ」(つまり、ヘビの 1 回の移動)の結果です。

#![allow(unused)]
fn main() {
/// ヘビが移動できる方向を定義する。
enum Direction {
    Up,
    Down,
    Left,
    Right
}

/// ヘビがどちらの方向に曲がるべきか。
#[derive(Debug, Copy, Clone)]
pub enum Turn {
    Left,
    Right,
    None
}

/// 現在のゲーム状態。
pub enum GameStatus {
    Won,
    Lost,
    Ongoing
}

/// 1 回の移動/ステップの結果。
enum StepOutcome {
    /// グリッドが満杯(プレイヤーの勝利)
    Full(Coords),
    /// ヘビが自分自身に衝突した(プレイヤーの敗北)
    Collision(Coords),
    /// ヘビが餌を食べた
    Eat(Coords),
    /// ヘビが移動した(ほかには何も起きていない)
    Move(Coords)
}
}

次に Snake 構造体を定義します。これは、ヘビが占有している座標と進行方向を追跡します。 座標の順序を追跡するためにキュー(heapless::spsc::Queue)を使い、高速な衝突判定を可能にするために ハッシュセット(heapless::FnvIndexSet)を使います。Snake には、移動を行うためのメソッドがあります。

#![allow(unused)]
fn main() {
use heapless::spsc::Queue;

// ...

struct Snake {
    /// ヘビの頭の座標。
    head: Coords,
    /// ヘビの残りの胴体の座標を保持するキュー。尾の端は
    /// 先頭にあります。
    tail: Queue<Coords, 32>,
    /// 現在ヘビが占有しているすべての座標を含むセット(高速な
    /// 衝突判定用)。
    coord_set: FnvIndexSet<Coords, 32>,
    /// ヘビが現在移動している方向。
    direction: Direction
}

impl Snake {
    fn new() -> Self {
        let head = Coords { row: 2, col: 2 };
        let initial_tail = Coords { row: 2, col: 1 };
        let mut tail = Queue::new();
        tail.enqueue(initial_tail).unwrap();
        let mut coord_set: FnvIndexSet<Coords, 32> = FnvIndexSet::new();
        coord_set.insert(head).unwrap();
        coord_set.insert(initial_tail).unwrap();
        Self {
            head,
            tail,
            coord_set,
            direction: Direction::Right,
        }
    }

    /// ヘビを指定した座標のタイルへ移動します。`extend` が false の場合、
    /// ヘビの尾は最後尾のタイルを空けます。
    fn move_snake(&mut self, coords: Coords, extend: bool) {
        // 頭の位置が尾の先頭になる
        self.tail.enqueue(self.head).unwrap();
        // 頭が新しい座標へ移動する
        self.head = coords;
        self.coord_set.insert(coords).unwrap();
        if !extend {
            let back = self.tail.dequeue().unwrap();
            self.coord_set.remove(&back);
        }
    }

    fn turn_right(&mut self) {
        self.direction = match self.direction {
            Direction::Up => Direction::Right,
            Direction::Down => Direction::Left,
            Direction::Left => Direction::Up,
            Direction::Right => Direction::Down
        }
    }

    fn turn_left(&mut self) {
        self.direction = match self.direction {
            Direction::Up => Direction::Left,
            Direction::Down => Direction::Right,
            Direction::Left => Direction::Down,
            Direction::Right => Direction::Up
        }
    }

    fn turn(&mut self, direction: Turn) {
        match direction {
            Turn::Left => self.turn_left(),
            Turn::Right => self.turn_right(),
            Turn::None => ()
        }
    }
}
}

Game 構造体はゲームの状態を追跡します。これには Snake オブジェクト、現在のエサの座標、ゲームの速度(ヘビの各移動の間に経過する時間を決定するために使用されます)、ゲームの状態(ゲームが進行中か、プレイヤーが勝利または敗北したか)、およびプレイヤーのスコアが含まれます。

この構造体には、ゲームの各ステップを処理し、ヘビの次の動きを決定して、それに応じてゲームの状態を更新するメソッドが含まれています。また、game_matrixscore_matrix という 2 つのメソッドも含まれており、ゲームの状態やプレイヤーのスコアを LED マトリクスに表示するために使用できる値の 2D 配列を出力します(これについては後で見ていきます)。

#![allow(unused)]
fn main() {
/// ゲームの状態と関連する振る舞いを保持する構造体
pub(crate) struct Game {
    rng: Prng,
    snake: Snake,
    food_coords: Coords,
    speed: u8,
    pub(crate) status: GameStatus,
    score: u8
}

impl Game {
    pub(crate) fn new(rng_seed: u32) -> Self {
        let mut rng = Prng::new(rng_seed);
        let mut tail: FnvIndexSet<Coords, 32> = FnvIndexSet::new();
        tail.insert(Coords { row: 2, col: 1 }).unwrap();
        let snake = Snake::new();
        let food_coords = Coords::random(&mut rng, Some(&snake.coord_set));
        Self {
            rng,
            snake,
            food_coords,
            speed: 1,
            status: GameStatus::Ongoing,
            score: 0
        }
    }

    /// 新しいゲームを開始するためにゲームの状態をリセットする。
    pub(crate) fn reset(&mut self) {
        self.snake = Snake::new();
        self.place_food();
        self.speed = 1;
        self.status = GameStatus::Ongoing;
        self.score = 0;
    }

    /// グリッド上にランダムに餌を配置する。
    fn place_food(&mut self) -> Coords {
        let coords = Coords::random(&mut self.rng, Some(&self.snake.coord_set));
        self.food_coords = coords;
        coords
    }

    /// 範囲外の座標を「折り返す」(例: グリッドの左側にはみ出した
    /// 座標は、最も右の列に現れる)。座標が範囲外になるのは
    /// 1 次元のみであると仮定する。
    fn wraparound(&self, coords: Coords) -> Coords {
        if coords.row < 0 {
            Coords { row: 4, ..coords }
        } else if coords.row >= 5 {
            Coords { row: 0, ..coords }
        } else if coords.col < 0 {
            Coords { col: 4, ..coords }
        } else {
            Coords { col: 0, ..coords }
        }
    }

    /// ヘビが次に移動するマスを決定する(実際には
    /// ヘビを移動させない)。
    fn get_next_move(&self) -> Coords {
        let head = &self.snake.head;
        let next_move = match self.snake.direction {
            Direction::Up => Coords { row: head.row - 1, col: head.col },
            Direction::Down => Coords { row: head.row + 1, col: head.col },
            Direction::Left => Coords { row: head.row, col: head.col - 1 },
            Direction::Right => Coords { row: head.row, col: head.col + 1 },
        };
        if next_move.is_out_of_bounds() {
            self.wraparound(next_move)
        } else {
            next_move
        }
    }

    /// ヘビの次の移動を評価し、その結果を返す。実際には
    /// ゲームの状態を更新しない。
    fn get_step_outcome(&self) -> StepOutcome {
        let next_move = self.get_next_move();
        if self.snake.coord_set.contains(&next_move) {
            // まだヘビを移動させていないので、次の移動先が
            // 尻尾の末端であれば、実際には衝突は発生しない
            // (頭がそのマスに移動するまでに尻尾が動いているため)
            if next_move != *self.snake.tail.peek().unwrap() {
                StepOutcome::Collision(next_move)
            } else {
                StepOutcome::Move(next_move)
            }
        } else if next_move == self.food_coords {
            if self.snake.tail.len() == 23 {
                StepOutcome::Full(next_move)
            } else {
                StepOutcome::Eat(next_move)
            }
        } else {
            StepOutcome::Move(next_move)
        }
    }

    /// 1 ステップの結果を処理し、ゲームの内部状態を更新する。
    fn handle_step_outcome(&mut self, outcome: StepOutcome) {
        self.status = match outcome {
            StepOutcome::Collision(_) => GameStatus::Lost,
            StepOutcome::Full(_) => GameStatus::Won,
            StepOutcome::Eat(c) => {
                self.snake.move_snake(c, true);
                self.place_food();
                self.score += 1;
                if self.score % 5 == 0 {
                    self.speed += 1
                }
                GameStatus::Ongoing
            },
            StepOutcome::Move(c) => {
                self.snake.move_snake(c, false);
                GameStatus::Ongoing
            }
        }
    }

    pub(crate) fn step(&mut self, turn: Turn) {
        self.snake.turn(turn);
        let outcome = self.get_step_outcome();
        self.handle_step_outcome(outcome);
    }

    /// ゲームの各ステップ間で待機する時間をミリ秒単位で計算する。
    /// 一般に、プレイヤーのスコアが増えるほどこの値は小さくなるが、
    /// 0 未満の値にならないよう注意する必要がある。
    pub(crate) fn step_len_ms(&self) -> u32 {
        let result = 1000 - (200 * ((self.speed as i32) - 1));
        if result < 200 {
            200u32
        } else {
            result as u32
        }
    }

    /// ゲームの状態を表す配列を返す。これは
    /// microbit の LED マトリクスに状態を表示するために使用できる。各 `_brightness`
    /// パラメータは 0 から 9 までの値である必要がある。
    pub(crate) fn game_matrix(
        &self,
        head_brightness: u8,
        tail_brightness: u8,
        food_brightness: u8
    ) -> [[u8; 5]; 5] {
        let mut values = [[0u8; 5]; 5];
        values[self.snake.head.row as usize][self.snake.head.col as usize] = head_brightness;
        for t in &self.snake.tail {
            values[t.row as usize][t.col as usize] = tail_brightness
        }
        values[self.food_coords.row as usize][self.food_coords.col as usize] = food_brightness;
        values
    }

    /// ゲームのスコアを表す配列を返す。これは
    /// microbit の LED マトリクスにスコアを表示するために使用できる(左->右、
    /// 上->下の順に、対応する数の LED を点灯する)。
    pub(crate) fn score_matrix(&self) -> [[u8; 5]; 5] {
        let mut values = [[0u8; 5]; 5];
        let full_rows = (self.score as usize) / 5;
        for r in 0..full_rows {
            values[r] = [1; 5];
        }
        for c in 0..(self.score as usize) % 5 {
            values[full_rows][c] = 1;
        }
        values
    }
}
}

main ファイル

次のコードを main.rs ファイルに配置してください。

#![no_main]
#![no_std]

mod game;

use cortex_m_rt::entry;
use microbit::{
   Board,
   hal::{prelude::*, Rng, Timer},
   display::blocking::Display
};
use rtt_target::rtt_init_print;
use panic_rtt_target as _;
use crate::game::{Game, GameStatus, Turn};

#[entry]
fn main() -> ! {
   rtt_init_print!();
   let mut board = Board::take().unwrap();
   let mut timer = Timer::new(board.TIMER0);
   let mut rng = Rng::new(board.RNG);
   let mut game = Game::new(rng.random_u32());
   let mut display = Display::new(board.display_pins);

   loop {
      loop {  // ゲームループ
         let image = game.game_matrix(9, 9, 9);
         // 現時点では明るさの値に意味はありません。まだ
         // 異なる明るさを表示できるディスプレイを
         // 実装していないためです
         display.show(&mut timer, image, game.step_len_ms());
         match game.status {
            GameStatus::Ongoing => game.step(Turn::None), // プレースホルダーです。まだ
                                                          // 操作を実装して
                                                          // いないためです
            _ => {
               for _ in 0..3 {
                  display.clear();
                  timer.delay_ms(200u32);
                  display.show(&mut timer, image, 200);
               }
               display.clear();
               display.show(&mut timer, game.score_matrix(), 1000);
               break
            }
         }
      }
      game.reset();
   }
}

ボードとそのタイマーおよび RNG ペリフェラルを初期化した後、Game 構造体と、microbit::display::blocking モジュールの Display を初期化します。

「ゲームループ」(main 関数内に配置した「メインループ」の内側で実行されるループ)では、以下の手順を繰り返し実行します。

  1. グリッドを表す 5x5 のバイト配列を取得します。Game::get_matrix メソッドは 3 つの整数引数を取ります(最終的には 0 から 9 までの値を両端を含めて指定する想定です)。これらは、頭、尾、食べ物をどれくらい明るく表示するかを表します。この時点で使用している基本的な Display は可変の明るさをサポートしていないため、この段階ではそれぞれに 9 を渡しています(ただし、0 以外の値であればどれでも動作します)。
  2. Game::step_len_ms メソッドで決まる時間だけ、その行列を表示します。現在の実装では、基本的にステップ間隔は 1 秒で、プレイヤーが 5 点獲得するごとに 200ms ずつ短くなります(食べ物 1 つを食べると 1 点)。ただし下限は 200ms です。
  3. ゲームの状態を確認します。これが Ongoing(初期値)であれば、ゲームを 1 ステップ進めてゲームの状態(status プロパティを含む)を更新します。そうでなければゲームオーバーなので、現在の画像を 3 回点滅させた後、プレイヤーのスコアを表示します(スコアに対応する数の LED を点灯させた形で表現されます)。その後、ゲームループを終了します。

メインループでは、各反復の後にゲームの状態をリセットしながら、ゲームループを繰り返し実行します。

これを実行すると、ディスプレイの中央の高さに 2 つの LED が点灯しているはずです(中央にヘビの頭、その左に尾があります)。また、ボード上のどこかに別の LED が点灯しているのも見えるはずで、これはヘビの食べ物を表しています。およそ 1 秒ごとに、ヘビは右に 1 マス移動します。

次に、ヘビの動きを操作できるようにします。