Игровая логика
Первым мы построим игровую логику. Вероятно, вы знакомы с игрой «Змейка», но если нет, суть проста: игрок направляет змейку по двумерной сетке. В каждый момент в случайной клетке сетки есть «еда», и цель игры — заставить змейку съесть как можно больше еды. Каждый раз, съедая еду, змейка становится длиннее. Игрок проигрывает, если змейка врезается в собственный хвост.
В некоторых вариантах игры игрок также проигрывает, если змейка врезается в край сетки, но, учитывая небольшой размер нашей сетки, мы реализуем правило «сквозного перехода» (wraparound): если змейка уходит за один край сетки, она появляется с противоположного края.
Модуль game
Игровую механику мы соберём в модуле game.
Координаты
Начнём с системы координат для нашей игры (src/game/coords.rs).
#![allow(unused)]
fn main() {
use super::Prng;
use heapless::index_set::FnvIndexSet;
/// Одна точка на сетке.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub struct Coords {
// Знаковые целые, чтобы допускать отрицательные значения (удобно при проверке того, не вышли ли мы
// за верхний или левый край сетки)
pub row: i8,
pub col: i8,
}
impl Coords {
/// Получить случайные координаты внутри сетки. `exclude` — необязательное множество
/// координат, которые нужно исключить из результата.
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
}
/// Находится ли точка за границами сетки.
pub fn is_out_of_bounds(&self) -> bool {
self.row < 0 || self.row >= 5 || self.col < 0 || self.col >= 5
}
}
}
Для обозначения позиции на сетке мы используем структуру Coords. Поскольку Coords содержит только два целых числа, мы просим компилятор автоматически реализовать для неё трейт Copy, чтобы можно было передавать структуры Coords и не думать о владении.
Генерация случайных чисел
Мы определяем ассоциированную функцию Coords::random, которая выдаёт случайную позицию на сетке. Позже мы используем её, чтобы определять, где появится еда для змейки.
Чтобы генерировать случайные координаты, нужен источник случайных чисел. В nRF52833 есть аппаратный генератор случайных чисел (HWRNG), описанный в разделе 6.19 спецификации nRF52833. HAL предоставляет простой интерфейс к HWRNG через структуру microbit::hal::rng::Rng. Аппаратного генератора может не хватать по скорости для игры; кроме того, для тестирования удобно иметь возможность воспроизводить одну и ту же последовательность случайных чисел от запуска к запуску, а аппаратный генератор по своей конструкции это не позволяет. Поэтому мы также определяем псевдослучайный генератор чисел (PRNG). PRNG использует алгоритм xorshift для генерации псевдослучайных значений u32. Алгоритм простой и не является криптографически стойким, но он эффективен, легко реализуется и вполне подходит для нашей скромной игры «Змейка». Структуре Prng нужно начальное значение (seed), которое мы получаем из периферии RNG.
Всё это составляет src/game/rng.rs.
#![allow(unused)]
fn main() {
use crate::Rng;
/// Простой псевдослучайный генератор чисел.
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 }
}
/// Простая функция 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.
pub fn random_u32(&mut self) -> u32 {
self.value = Self::xorshift32(self.value);
self.value
}
}
}
Движение
Нам также нужно определить несколько enum, которые помогают управлять состоянием игры: направление движения, направление поворота, текущий статус игры и результат отдельного «шага» игры (то есть одного движения змейки). Все они находятся в src/game/movement.rs.
#![allow(unused)]
fn main() {
use super::Coords;
/// Определяем направления, в которых может двигаться змейка.
pub enum Direction {
Up,
Down,
Left,
Right,
}
/// В каком направлении должна повернуть змейка.
#[derive(Debug, Copy, Clone)]
pub enum Turn {
Left,
Right,
None,
}
/// Текущий статус игры.
pub enum GameStatus {
Won,
Lost,
Ongoing,
}
/// Результат одного хода (шага).
pub enum StepOutcome {
/// Сетка заполнена (игрок выигрывает)
Full,
/// Змейка врезалась в себя (игрок проигрывает)
Collision,
/// Змейка съела еду
Eat(Coords),
/// Змейка переместилась (и больше ничего не произошло)
Move(Coords),
}
}
Змейка (Змееее!)
Далее определим структуру 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 {
/// Координаты головы змейки.
pub head: Coords,
/// Очередь координат остального тела змейки. Конец хвоста находится
/// в начале очереди.
pub tail: Queue<Coords, 32>,
/// Множество всех координат, которые сейчас занимает змейка (для быстрой
/// проверки столкновений).
pub coord_set: FnvIndexSet<Coords, 32>,
/// Направление, в котором сейчас движется змейка.
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,
}
}
/// Переместить змейку на клетку с заданными координатами. Если `extend` ложно,
/// хвост змейки освобождает самую заднюю клетку.
pub 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,
}
}
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, которые возвращают двумерные массивы значений; их можно вывести на светодиодную матрицу, чтобы показать состояние игры или счёт игрока (как мы увидим позже).
Структуру 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;
/// Структура для хранения состояния игры и связанного с ним поведения
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,
}
}
/// Сбросить состояние игры, чтобы начать новую игру.
pub fn reset(&mut self) {
self.snake = Snake::make_snake();
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
}
/// «Сквозной переход» для координат за пределами сетки (например, координаты левее
/// левого края сетки появятся в крайнем правом столбце). Предполагается, что
/// координаты выходят за границы только по одному измерению.
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
} 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)
}
}
/// Обработать результат шага, обновив внутреннее состояние игры.
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);
}
/// Вычислить время ожидания между шагами игры в миллисекундах.
/// Обычно оно уменьшается по мере роста счёта игрока, но нужно
/// следить, чтобы оно не стало отрицательным.
pub fn step_len_ms(&self) -> u32 {
let result = 1000 - (200 * ((self.speed as i32) - 1));
if result < 200 {
200u32
} else {
result as u32
}
}
/// Вернуть массив, описывающий состояние игры; его можно использовать, чтобы вывести
/// состояние на светодиодной матрице micro:bit. Каждый параметр `_brightness` должен быть
/// значением от 0 до 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
}
/// Вернуть массив, описывающий счёт игры; его можно использовать, чтобы вывести
/// счёт на светодиодной матрице micro:bit (зажигая соответствующее количество
/// светодиодов, слева направо и сверху вниз).
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
}
}
}
Далее добавим возможность управлять движениями змейки.