ゲームロジック
最初に作成するモジュールは、ゲームロジックです。あなたはおそらく snake ゲームをご存じでしょうが、 もし知らなくても、基本的なアイデアは、プレイヤーが 2D グリッド上でヘビを動かすというものです。常に、 グリッド上のどこかランダムな位置に「食べ物」があり、ゲームの目標はヘビにできるだけ多くの食べ物を 「食べさせる」ことです。ヘビは食べ物を食べるたびに長くなります。ヘビが自分の尻尾に衝突すると、 プレイヤーの負けです。
ゲームのバリエーションによっては、ヘビがグリッドの端に衝突してもプレイヤーの負けになりますが、 今回のグリッドは小さいため、「ラップアラウンド」ルールを実装します。つまり、ヘビがグリッドの一方の端から 外に出た場合、反対側の端から続けて現れます。
game モジュール
ゲームの仕組みは game モジュールの中で組み立てていきます。
座標
まず、ゲーム用の座標系を定義します(src/game/coords.rs)。
#![allow(unused)]
fn main() {
use super::Prng;
use heapless::index_set::FnvIndexSet;
/// A single point on the grid.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub struct Coords {
// Signed ints to allow negative values (handy when checking if we have gone
// off the top or left of the grid)
pub row: i8,
pub col: i8,
}
impl Coords {
/// Get random coordinates within a grid. `exclude` is an optional set of
/// coordinates which should be excluded from the output.
pub fn random(rng: &mut 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
}
/// Whether the point is outside the bounds of the grid.
pub fn is_out_of_bounds(&self) -> bool {
self.row < 0 || self.row >= 5 || self.col < 0 || self.col >= 5
}
}
}
グリッド上の位置を表すために Coords 構造体を使います。Coords は 2 つの整数しか含まないため、
所有権を気にせずに Coords 構造体を受け渡しできるよう、コンパイラに Copy トレイトの実装を
derive させます。
乱数生成
関連関数 Coords::random を定義します。これにより、グリッド上のランダムな位置を取得できます。
これは後で、ヘビの食べ物をどこに配置するかを決めるために使います。
ランダムな座標を生成するには、乱数の供給源が必要です。nRF52833 にはハードウェア乱数生成器
(HWRNG)ペリフェラルがあり、これは nRF52833 spec の 6.19 節に記載されています。HAL は
microbit::hal::rng::Rng 構造体を介して HWRNG へのシンプルなインターフェースを提供してくれます。HWRNG は
ゲーム用途には十分な速度でない可能性があります。またテストでは、実行ごとに生成器が出力する乱数列を
再現できると便利ですが、HWRNG では設計上それは不可能です。そこで、pseudo-random 数生成器(PRNG)も
定義します。PRNG は xorshift アルゴリズムを用いて、擬似乱数の u32 値を生成します。このアルゴリズムは
基本的なもので、暗号学的に安全ではありませんが、効率的で実装も簡単であり、ささやかな snake ゲームには
十分です。Prng 構造体には初期シード値が必要ですが、それは RNG ペリフェラルから取得します。
これらすべてをまとめたものが src/game/rng.rs です。
#![allow(unused)]
fn main() {
use crate::Rng;
/// A basic pseudo-random number generator.
pub struct Prng {
value: u32,
}
impl Prng {
pub fn seeded(rng: &mut Rng) -> Self {
Self::new(rng.random_u32())
}
pub fn new(seed: u32) -> Self {
Self { value: seed }
}
/// Basic xorshift PRNG function: see <https://en.wikipedia.org/wiki/Xorshift>
fn xorshift32(mut input: u32) -> u32 {
input ^= input << 13;
input ^= input >> 17;
input ^= input << 5;
input
}
/// Return a pseudo-random u32.
pub fn random_u32(&mut self) -> u32 {
self.value = Self::xorshift32(self.value);
self.value
}
}
}
移動
ゲームの状態管理に役立つ、いくつかの enum も定義する必要があります。移動方向、曲がる方向、
現在のゲーム状態、そしてゲーム内の特定の「ステップ」(つまり、ヘビの 1 回の移動)の結果です。
これらは src/game/movement.rs に含まれています。
#![allow(unused)]
fn main() {
use super::Coords;
/// Define the directions the snake can move.
pub enum Direction {
Up,
Down,
Left,
Right,
}
/// What direction the snake should turn.
#[derive(Debug, Copy, Clone)]
pub enum Turn {
Left,
Right,
None,
}
/// The current status of the game.
pub enum GameStatus {
Won,
Lost,
Ongoing,
}
/// The outcome of a single move/step.
pub enum StepOutcome {
/// Grid full (player wins)
Full,
/// Snake has collided with itself (player loses)
Collision,
/// Snake has eaten some food
Eat(Coords),
/// Snake has moved (and nothing else has happened)
Move(Coords),
}
}
Snake(A Snaaake!)
次に、ヘビが占有している座標と進行方向を追跡する Snake 構造体を定義します。座標の順序を管理するために
キュー(heapless::spsc::Queue)を使い、高速に衝突判定を行えるようにハッシュセット
(heapless::FnvIndexSet)を使います。Snake には移動を行うためのメソッドがあります。これを
src/game/snake.rs に実装します。
#![allow(unused)]
fn main() {
use super::{Coords, Direction, FnvIndexSet, Turn};
use heapless::spsc::Queue;
pub struct Snake {
/// Coordinates of the snake's head.
pub head: Coords,
/// Queue of coordinates of the rest of the snake's body. The end of the tail is
/// at the front.
pub tail: Queue<Coords, 32>,
/// A set containing all coordinates currently occupied by the snake (for fast
/// collision checking).
pub coord_set: FnvIndexSet<Coords, 32>,
/// The direction the snake is currently moving in.
pub direction: Direction,
}
impl Snake {
pub fn make_snake() -> 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,
}
}
/// Move the snake onto the tile at the given coordinates. If `extend` is false,
/// the snake's tail vacates the rearmost tile.
pub fn move_snake(&mut self, coords: Coords, extend: bool) {
// Location of head becomes front of tail
self.tail.enqueue(self.head).unwrap();
// Head moves to new coords
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,
}
}
pub 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 マトリクス上にゲーム状態やプレイヤーのスコアを表示するために使える値の 2 次元配列を出力します
(これについては後で見ます)。
Game 構造体は、game モジュールの最上位である src/game.rs に配置します。
#![allow(unused)]
fn main() {
mod coords;
mod movement;
mod rng;
mod snake;
use crate::Rng;
pub use coords::Coords;
pub use movement::{Direction, GameStatus, StepOutcome, Turn};
pub use rng::Prng;
pub use snake::Snake;
use heapless::index_set::FnvIndexSet;
/// Struct to hold game state and associated behaviour
pub struct Game {
pub status: GameStatus,
rng: Prng,
snake: Snake,
food_coords: Coords,
speed: u8,
score: u8,
}
impl Game {
pub fn new(rng: &mut Rng) -> Self {
let mut rng = Prng::seeded(rng);
let snake = Snake::make_snake();
let food_coords = Coords::random(&mut rng, Some(&snake.coord_set));
Self {
rng,
snake,
food_coords,
speed: 1,
status: GameStatus::Ongoing,
score: 0,
}
}
/// Reset the game state to start a new game.
pub fn reset(&mut self) {
self.snake = Snake::make_snake();
self.place_food();
self.speed = 1;
self.status = GameStatus::Ongoing;
self.score = 0;
}
/// Randomly place food on the grid.
fn place_food(&mut self) -> Coords {
let coords = Coords::random(&mut self.rng, Some(&self.snake.coord_set));
self.food_coords = coords;
coords
}
/// "Wrap around" out of bounds coordinates (eg, coordinates that are off to the
/// left of the grid will appear in the rightmost column). Assumes that
/// coordinates are out of bounds in one dimension only.
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 }
}
}
/// Determine the next tile that the snake will move on to (without actually
/// moving the snake).
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
}
}
/// Assess the snake's next move and return the outcome. Doesn't actually update
/// the game state.
fn get_step_outcome(&self) -> StepOutcome {
let next_move = self.get_next_move();
if self.snake.coord_set.contains(&next_move) {
// We haven't moved the snake yet, so if the next move is at the end of
// the tail, there won't actually be any collision (as the tail will have
// moved by the time the head moves onto the tile)
if next_move != *self.snake.tail.peek().unwrap() {
StepOutcome::Collision
} else {
StepOutcome::Move(next_move)
}
} else if next_move == self.food_coords {
if self.snake.tail.len() == 23 {
StepOutcome::Full
} else {
StepOutcome::Eat(next_move)
}
} else {
StepOutcome::Move(next_move)
}
}
/// Handle the outcome of a step, updating the game's internal state.
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.is_multiple_of(5) {
self.speed += 1
}
GameStatus::Ongoing
}
StepOutcome::Move(c) => {
self.snake.move_snake(c, false);
GameStatus::Ongoing
}
}
}
pub fn step(&mut self, turn: Turn) {
self.snake.turn(turn);
let outcome = self.get_step_outcome();
self.handle_step_outcome(outcome);
}
/// Calculate the length of time to wait between game steps, in milliseconds.
/// Generally this will get lower as the player's score increases, but need to
/// be careful it cannot result in a value below zero.
pub fn step_len_ms(&self) -> u32 {
let result = 1000 - (200 * ((self.speed as i32) - 1));
if result < 200 {
200u32
} else {
result as u32
}
}
/// Return an array representing the game state, which can be used to display the
/// state on the microbit's LED matrix. Each `_brightness` parameter should be a
/// value between 0 and 9.
pub 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
}
/// Return an array representing the game score, which can be used to display the
/// score on the microbit's LED matrix (by illuminating the equivalent number of
/// LEDs, going left->right and top->bottom).
pub fn score_matrix(&self) -> [[u8; 5]; 5] {
let mut values = [[0u8; 5]; 5];
let full_rows = (self.score as usize) / 5;
#[allow(clippy::needless_range_loop)]
for r in 0..full_rows {
values[r] = [1; 5];
}
#[allow(clippy::needless_range_loop)]
for c in 0..(self.score as usize) % 5 {
values[full_rows][c] = 1;
}
values
}
}
}
次に、ヘビの動きを操作できるようにします。