ゲームロジック
まず、ゲームロジックを説明します。おそらくスネークゲームはご存じだと思いますが、そうでない場合は、基本的な考え方として プレイヤーが 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_matrix と score_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 関数内に配置した「メインループ」の内側で実行されるループ)では、以下の手順を繰り返し実行します。
- グリッドを表す 5x5 のバイト配列を取得します。
Game::get_matrixメソッドは 3 つの整数引数を取ります(最終的には 0 から 9 までの値を両端を含めて指定する想定です)。これらは、頭、尾、食べ物をどれくらい明るく表示するかを表します。この時点で使用している基本的なDisplayは可変の明るさをサポートしていないため、この段階ではそれぞれに 9 を渡しています(ただし、0 以外の値であればどれでも動作します)。 Game::step_len_msメソッドで決まる時間だけ、その行列を表示します。現在の実装では、基本的にステップ間隔は 1 秒で、プレイヤーが 5 点獲得するごとに 200ms ずつ短くなります(食べ物 1 つを食べると 1 点)。ただし下限は 200ms です。- ゲームの状態を確認します。これが
Ongoing(初期値)であれば、ゲームを 1 ステップ進めてゲームの状態(statusプロパティを含む)を更新します。そうでなければゲームオーバーなので、現在の画像を 3 回点滅させた後、プレイヤーのスコアを表示します(スコアに対応する数の LED を点灯させた形で表現されます)。その後、ゲームループを終了します。
メインループでは、各反復の後にゲームの状態をリセットしながら、ゲームループを繰り返し実行します。
これを実行すると、ディスプレイの中央の高さに 2 つの LED が点灯しているはずです(中央にヘビの頭、その左に尾があります)。また、ボード上のどこかに別の LED が点灯しているのも見えるはずで、これはヘビの食べ物を表しています。およそ 1 秒ごとに、ヘビは右に 1 マス移動します。
次に、ヘビの動きを操作できるようにします。