macroquad

Это простая и удобная Rust библиотека для разработки небольших 2D игр. Библиотека является кроссплатформенной и работает на Windows, Linux, MacOs, HTML5, Android, IOS. Основные особенности:

  • Высокопроизводительный 2D рендеринг,

  • Минимум зависимостей и быстрая компиляция,

  • UI библиотека в комплекте,

  • Простое развёртывание на всех платформах,

  • Не требует написания platform-specific кода.

Данная библиотека избавляет разработчика от следующих задач:

  • Создание окна для отрисовки,

  • Организация игрового цикла,

  • Обработка событий ОС,

  • Организация рендеринга,

  • Выбор или написание утилитарных библиотек (алгебра, цвет, звук, случайные числа, ...).

Создание проекта и подготовка к работе

Предполагаю, что для разработки на Rust у нас всё готово. Если нет, то тут есть инструкция по установке необходимых утилит.

Для начала создадим новое Rust-приложение с именем "asteroids" используя команду:

cargo new --bin asteroids

Теперь у нас есть заготовка для Rust приложения. Подключим к нему библиотеку macroquad, добавив в таблицу [dependencies] файла Cargo.toml строку:

macroquad = "0.3"

Теперь мы можем использовать эту библиотеку для инициализации окна и запуска игрового цикла. Добавим в main.rs файл следующий код:

use macroquad::prelude::*;

// Точка входа в приложение. 
// Макрос позволяет сделать функцию main асинхронной,
// а также иницилизирует окно.
#[macroquad::main("Asteroids")]
async fn main() {
  // Запускаем игровой цикл.
  loop {
    // Очищаем фон тёмно-серым цветом.
    clear_background(DARKGRAY);

    // Ожидаем возможности заняться следующим кадром.
    next_frame().await;
  }
}

Данный фрагмент кода использует макрос #[macroquad::main("Asteroids")] для автоматической генерации кода инициализации окна и запуска асинхронного рантайма, в котором будет выполняться наше приложение.

Асинхронность нужна, в основном, для того, чтобы функция ожидания следующего кадра не блокировала основной поток выполнения приложения. Блокировка основного потока не допустима в Android и WASM. Также асинхронное выполнение позволяет более эффективно выполнять задачи ввода/вывода. Например, чтение файла.

При запуске приложения у нас должно появиться пустое окно:

Можно приступать к реализации игровой логики.

Игра

Игра будет представлять собой вертикальный скроллер. Игрок, управляя небольшим кораблём, должен уворачиваться от астероидов как можно дольше. Для простоты, позволим кораблю двигаться только по горизонтали. При этом, корабль будет постоянно ускоряться, из-за чего уклоняться от астероидов будет всё сложнее.

Состояние приложения

Опишем структуру, содержащую общее состояние приложения:

/// Состояние приложения.
struct State {
  /// Рекорное время.
  best_time: f64,
  /// Состояние игрового процесса.
  game: Option<Game>,
}

В данной структуре будем хранить рекордное время и состояние игрового процесса.

Так как, помимо игрового процесса, у нас в игре будет меню, состояние игры обернём в перечисление Option. Данное перечисление либо содержит в себе объект, либо не содержит ничего. Новое состояние игры будем создавать и помещать в Option при каждом новом запуске игрового процесса.

Далее, опишем логику инициализации состояния приложения по умолчанию. Это делается с помощью реализации трейта Default на типе:

/// Логика создания состояния приложения.
impl Default for State {
  fn default() -> Self {
    Self {
      best_time: 0.0,
      game: None, // Изначально находимся в меню.
    }
  }
}

Опишем методы для обновления состояния приложения и его отрисовки:

/// Логика обновления приложения.
pub fn update(&mut self) {}

/// Отображение приложения.
pub fn draw(&self) {}

Стоит отметить, что метод update() подразумевает изменение состояния нашего приложения, поэтому принимает данные приложения по мутабельной ссылке (&mut self). Метод draw() подразумевает только чтение данных для их отображения. Поэтому он принимает иммутабельную ссылку (&self).

Далее, инициализируем состояние приложения перед запуском игрового цикла. Обновляем и отображаем состояние на каждом кадре.

// Точка входа в приложение. Макрос позволяет сделать функцию main асинхронной,
// а также иницилизирует окно.
#[macroquad::main("Asteroids")]
async fn main() {
  // Инициализирум состояние наший игры по умолчанию.
  let mut state = State::default();

  // Запускаем игровой цикл.
  loop {
    // Очищаем фон тёмно-серым цветом.
    clear_background(DARKGRAY);

    // Обновляем состояние игры.
    state.update();

    // Отображаем игру в окне.
    state.draw();

    // Ожидаем возможности заняться следующим кадром.
    next_frame().await;
  }
}

Сейчас методы update() и draw() не делают ничего. Реализуем их.

/// Логика обновления приложения.
pub fn update(&mut self) {
  // Если нажат Enter - запускаем игру.
  if self.game.is_none() && is_key_pressed(KeyCode::Enter) {
    let game = Game::default(); // Создаём новое состояние игрового процесса.
    self.game = Some(game); // Запоминаем его.
    return;
  }

  // Если мы в игре - обновляем её состояние.
  let finished = self.game
  .as_mut(). // получаем уникальную (мутабельную) ссылку на содержимое Option, если оно есть.
  and_then(|game| { // Если получили, то выполняем функтор,
    game.update() // который обновляет состояние игры.
  });

  // Если игра завершена - то получим время, которое игроку удалось продержаться.
  if let Some(new_time) = finished {
    self.game = None; // Завершаем игру.
    if new_time > self.best_time {
      // Если новое время дольше рекордного,
      self.best_time = new_time; // то обновляем рекорд.
    }
  }
}

/// Отображение приложения.
pub fn draw(&self) {
  // Если игра запущена - отображаем её,
  if let Some(game) = &self.game {
    game.draw(self.best_time)
  } else {
    // иначе, рисуем меню.
    Self::draw_menu()
  }
}

В данном коде мы:

  • Запускаем игру, если была нажата клавиша Enter и игра ещё не запущена. Строки 3-8.

  • Так как поле State::game имеет тип Option<Game>, мы не можем получить непосредственный доступ к самой игре для обновления её состояния. Поэтому, для обновления состояния игры, передадим в метод Option::and_then() замыкание, которое будет выполнено только в том случае, если игра запущена. Результат выполнения game.update() возвращает Option<f64>, и может содержать время, в течении которого продержался игрок. Переменная finished будет содержать Some только если игра была завершена. Строки 10-15.

  • Проверяем, завершилась ли игра. Если это так, то завершаем игру и обновляем значение рекордного времени. Строки 17-24.

  • В методе draw(), в зависимости от состояния приложения, отображаем на экране либо меню, либо игру. Строки 29-35.

Для компиляции приложения нам не хватает некоторых типов и методов. Добавим их.

Тип Game пока оставим пустым:

/// Состояние игрового процесса.
struct Game {}

Добавим его реализацию по умолчанию:

impl Default for Game {
    /// Логика создания новой игры.
    fn default() -> Self {
        Self {}
    }
}

Опишем методы update() и draw(). На данный момент добавим только функционал завершения игры нажатием Escape.

impl Game {
    /// Логика обновления игрового процесса.
    pub fn update(&mut self) -> Option<f64> {
        if is_key_pressed(KeyCode::Escape) {
            // Если нажат Escape - выходим в меню.
            return Some(get_time() - self.start_time);
        }

        None // Игра продолжается.
    }

    /// Отображаем игру.
    pub fn draw(&self, best_time: f64) {}

Последнее, чего не хватает - метод рисования меню. В нём отобразим только текст о том, как запустить игру:

/// Отображение меню
fn draw_menu() {
  let font_size = 40.0;
  let text = "Press Enter to start game.";

  // Вычисляем, какой размер занимает текст на экране.
  let text_size = measure_text(text, None, font_size as _, 1.0);

  // Располагаем текст по центру.
  let text_pos = (
    (screen_width() - text_size.width) / 2.0,
    (screen_height() - text_size.height) / 2.0,
  );

  // Отображаем текст
  draw_text(text, text_pos.0, text_pos.1, font_size, BLACK);
}

Теперь, запустив приложение, видим приглашение начать игру, можем её запустить клавишей Enter и прервать клавишей Escape. К сожалению в самой игре у нас ничего нет. Пора наполнить её содержимым.

Содержимое игры

Для расчёта физики движений игровых объектов нам потребуется знать, сколько времени прошло между предыдущим и текущем кадром. Привязка ко времени обеспечит ровную скорость игрового процесса, независимо от частоты кадров. Добавим в структуру Game время последнего обновления:

/// Состояние игрового процесса.
struct Game {
    /// Время предыдущего обновления состояния игры.
    last_update: f64,
}

А в методе Game::update() будем обновлять это значение, вычисляя, предварительно, временной интервал между кадрами:

// Время, прошедшее с предыдущего кадра.
let elapsed_time = get_time() - self.last_update;

// Запоминаем время завершения обновления кадра.
self.last_update = get_time(); 

Теперь опишем корабль игрока:

/// Состояние корабля.
pub struct Ship {
  /// Положение по горизонтали.
  position: f32,
  /// Скорость по горизонтали.
  speed: f32,
  /// Скорость по вертикали (с которой, относительно корабля, движутся астероиды)
  vertical_speed: f32,
}

Способ его инициализации по умолчанию:

impl Default for Ship {
  fn default() -> Self {
    Self {
      position: screen_width() / 2.0, // Изначально корабль находится по центру окна.
      speed: 0.0,
      vertical_speed: 100.0,
    }
  }
}

Константные параметры корабля:

impl Ship {
  // Параметры корабля.
  const SHIP_WIDTH: f32 = 25.0;
  const SHIP_HEIGHT: f32 = 50.0;
  const SHIP_OFFSET: f32 = 30.0;
}

Обновление его состояния:

/// Логика обновления корабля.
pub fn update(&mut self, elapsed_time: f64) {
  const ACCELERATION: f32 = 200.0;
  const VERTICAL_ACCELERATION: f32 = 50.0;
  const DECELERATION: f32 = 180.0;
  let elapsed_time = elapsed_time as f32;

  // Замедляем корабль по горизонтали.
  self.speed /= DECELERATION * elapsed_time;

  // Если нажата А, то ускоряем корабль влево.
  if is_key_down(KeyCode::A) {
    self.speed -= ACCELERATION * elapsed_time;
  }

  // Если нажата D, то ускоряем корабль вправо.
  if is_key_down(KeyCode::D) {
    self.speed += ACCELERATION * elapsed_time;
  }

  // Перемещаем корабль.
  self.position += self.speed;

  // Не даём кораблю выйти за пределы окна.
  self.position = self.position.clamp(
    Self::SHIP_WIDTH / 2.0,
    screen_width() - Self::SHIP_WIDTH / 2.0,
  );

  // Ускоряем корабль по вертикали для повышения сложности игры со временем.
  self.vertical_speed += VERTICAL_ACCELERATION * elapsed_time;
}

И способ его отображения на экране в виде треугольника:

/// Отображаем корабль.
pub fn draw(&self) {
  // Вычисляем точки треугольника.
  let top = Vec2::new(
    self.position,
    screen_height() - Self::SHIP_HEIGHT / 2.0 - Self::SHIP_OFFSET,
  );
  let left = Vec2::new(
    self.position - Self::SHIP_WIDTH / 2.0,
    screen_height() - Self::SHIP_OFFSET,
  );
  let right = Vec2::new(
    self.position + Self::SHIP_WIDTH / 2.0,
    screen_height() - Self::SHIP_OFFSET,
  );

  // Отображаем треугольник.
  draw_triangle(top, right, left, WHITE)
}

Добавим корабль в игру:

/// Состояние игрового процесса.
struct Game {
  /// Время предыдущего обновления состояния игры.
  last_update: f64,
  /// Корабль игрока.
  ship: Ship,
}

impl Default for Game {
  /// Логика создания новой игры.
  fn default() -> Self {
    let time = get_time(); // Текущее время со старта приложения.
    Self {
      last_update: time,
      ship: Ship::default(),
    }
  }
}

impl Game {
  /// Логика обновления игрового процесса.
  pub fn update(&mut self) -> Option<f64> {
    if is_key_pressed(KeyCode::Escape) {
      // Если нажат Escape - выходим в меню.
      return Some(get_time() - self.start_time);
    }

    let elapsed_time = self.elapsed_time(); // Время, прошедшее с предыдущего кадра.

    self.ship.update(elapsed_time); // Обновляем состояние корабля.

    self.last_update = get_time(); // Запоминаем время завершения обновления кадра.
    None // Игра продолжается.
  }

  /// Время, прошедшее с последнего обновления.
  fn elapsed_time(&self) -> f64 {
    get_time() - self.last_update
  }

  /// Отображаем игру.
  pub fn draw(&self, best_time: f64) {
    self.ship.draw(); // Отображаем корабль.
  }
}

Теперь, при запуске игры у нас должен появится белый треугольный кораблик, который можно перемещать клавишами A и D:

Помимо корабля, отобразим рекордное и текущее время. Для вычисления текущего времени, поместим в структуру Game поле start_time, содержащее время начала игры.

/// Отображаем текст с лучшим и текущим временем.
fn draw_time(&self, best_time: f64) {
  let font_size = 24.0;
  let text = format!("Best time: {:.2}", best_time);
  let text_size = measure_text(&text, None, font_size as _, 1.0);
  draw_text(&text, 0.0, screen_height(), font_size, BLACK);

  let time = self.game_time();
  let text = format!("Your time: {:.2}", time);

  // Если текущее время лучше рекордного, отображаем его зелёным цветом.
  let color = if time > best_time { GREEN } else { BLACK };

  draw_text(
    &text,
    0.0,
    screen_height() - text_size.height,
    font_size,
    color,
  );
}

/// Время в текущей игре.
fn game_time(&self) -> f64 {
  get_time() - self.start_time
}

Добавим вызов self.draw_time() в Game::draw() и у нас должны появится надписи в левом нижнем углу экрана:

Для полноценной игры осталось добавить астероиды, от которых должен уворачиваться игрок. Опишем состояние астероида следующей структурой:

/// Состояние астероида.
struct Asteroid {
  position: Vec2,
  speed: Vec2,
  radius: f32,
}

Создавать астероиды будем выше верхнего края экрана со случайным положением по горизонтали и случайной скоростью:

impl Default for Asteroid {
  fn default() -> Self {
    // Располагаем астероид случайно, немного выше видимого экрана.
    let x = f32::gen_range(0.0, screen_width());
    let y = -2.0 * Self::MAX_RADIUS;

    // Задаём случайную скорость астероиду.
    let speed_x = f32::gen_range(0.0, Self::MAX_SPEED);
    let speed_y = f32::gen_range(0.0, Self::MAX_SPEED);

    Self {
      position: Vec2::new(x, y),
      speed: Vec2::new(speed_x, speed_y),
      radius: f32::gen_range(Self::MIN_RADIUS, Self::MAX_RADIUS),
    }
  }
}

Астероид должен уметь обновлять своё состояние, отображаться на экране и проверять, не вышел ли он далеко за край экрана. Последнее пригодиться для вычисления астероидов, которые можно удалить из игры.

impl Asteroid {
  // Параметры астероидов
  const MIN_RADIUS: f32 = 25.0;
  const MAX_RADIUS: f32 = 100.0;
  const MAX_SPEED: f32 = 200.0;

  /// Проверка выхода астероида далеко за границы экрана.
  pub fn out_of_bounds(&self) -> bool {
    let (x, y) = (self.position.x, self.position.y);
    let left = -3.0 * Self::MAX_RADIUS;
    let right = screen_width() + 3.0 * Self::MAX_RADIUS;
    let bottom = screen_height() + 3.0 * Self::MAX_RADIUS;
    x < left || x > right || y > bottom
  }

  /// Обновление состояния астероида.
  pub fn update(&mut self, elapsed_time: f64, ship_speed: f32) {
    let elapsed_time = elapsed_time as f32;
    self.position += self.speed * elapsed_time;
    // Так как всё движется по вертикали в системе отсчёта корабля,
    // учтём его скорость.
    self.position.y += ship_speed * elapsed_time;
  }

  /// Отображение астероида.
  pub fn draw(&self) {
    // Отображаем астероид в виде красного круга.
    draw_circle(self.position.x, self.position.y, self.radius, LIGHTGRAY);
  }
}

Осталось только включить астероиды в игру. Добавим в структуру Game вектор астероидов и таймер их появления. Теперь игра и её инициализация выглядят так:

/// Состояние игрового процесса.
struct Game {
  /// Время, когда игра запустилась.
  start_time: f64,
  /// Время предыдущего обновления состояния игры.
  last_update: f64,
  /// Корабль игрока.
  ship: Ship,
  /// Таймер появления астероидов.
  asteroid_timer: f64,
  /// Вектор астероидов.
  asteroids: Vec<Asteroid>,
}

impl Default for Game {
  /// Логика создания новой игры.
  fn default() -> Self {
    let time = get_time(); // Текущее время со старта приложения.
    Self {
      start_time: time,
      last_update: time,
      ship: Ship::default(),
      asteroid_timer: 0.0,
      asteroids: Vec::with_capacity(100), // Создаём пустой вектор,
      // способный вместить в себя до 100 астероидов без дополнительных аллокаций.
    }
  }
}

В метод обновления игры добавим порождение новых астероидов, обновление существующих и удаление лишних. Лишними астероидами считаем те, которые улетели далеко за пределы экрана:

self.asteroid_timer += elapsed_time; // Обновляем таймер появления астероидов.
if self.asteroid_timer > 0.5 {
  // Если астероид не появлялся уже полсекунды,
  self.asteroid_timer = 0.0; // сбрасываем таймер
  self.asteroids.push(Asteroid::default()); // и добавляем новый астероид.
}

// Забываем астероиды, вышедшие за пределы экрана.
self.asteroids.retain(|asteroid| !asteroid.out_of_bounds());

// Обновляем состояние астероиндов.
for asteroid in &mut self.asteroids {
  asteroid.update(elapsed_time, self.ship.vertical_speed());
}

В метод отображения игры добавим отображение астероидов:

/// Отображаем игру.
pub fn draw(&self, best_time: f64) {
  self.draw_time(best_time); // Отображаем текст с лучшим и текущим временем.
  self.ship.draw(); // Отображаем корабль.

  // Отображаем астероиды.
  for asteroid in &self.asteroids {
    asteroid.draw();
  }
}

Теперь в игре должен появиться поток астероидов, несущихся на корабль. Но при столкновении с ними ничего не происходит. Исправим это. Добавим кораблю возможность детектировать столкновения с астероидами:

/// Столкнулся ли корабль с кругом с центром в `point` и радиусом `radius`.
pub fn is_collapse(&self, point: Vec2, radius: f32) -> bool {
  // Вычисляем приблизительный радиус корабля.
  let ship_radius = (Self::SHIP_WIDTH + Self::SHIP_HEIGHT) / 4.0;

  // Вычисляем положение центра корабля.
  let ship_center = Vec2::new(self.position, screen_height() - Self::SHIP_OFFSET);

  // Проверяем, не пересекаются ли радиусы корабля и круга.
  (point - ship_center).length() < radius + ship_radius
}

И добавим проверку на столкновение в цикл обновления астероидов в методе Game::update():

// Обновляем состояние астероиндов.
for asteroid in &mut self.asteroids {
  asteroid.update(elapsed_time, self.ship.vertical_speed());
  
  if self.ship.is_collapse(asteroid.position, asteroid.radius) {
    // Если астероид столкнулся с кораблём, то завершаем игру.
    return Some(self.game_time());
  }
}

Наконец, игра готова. Теперь можно попытаться установить рекорд! В обычном режиме мне удалось продержаться не более 35 секунд. Но игра позволяет немного жульничать не меняя код. Как? Жду ваши варианты в комментариях. А также приглашаю всех на бесплатный вебинар, в рамках которого я расскажу о том, какие проблемы решает Rust. Регистрируйтесь по ссылке.

Репозиторий с кодом

Комментарии (6)


  1. SabMakc
    14.04.2022 09:38
    +2

    Спасибо, интересная статья!

    А под разные платформы пробовали собирать? Мобильные/браузер? Был бы очень интересен опыт именно кросс-платформенного использования. Обычно при попытке кросс-платформенной компиляции подобных проектов возникает множество различных подводных камней...

    Мысль вслух: возможно, стоящая идея для следующей статьи?

    P.S. В расчете положения разве не надо учитывать прошедшее время?

    // Перемещаем корабль.
    self.position += self.speed * elapsed_time;


    1. F3kilo Автор
      14.04.2022 13:06
      +1

      Насчёт кроссплатформенной сборки - не пробовал. Цель была скорее в том, чтобы показать, что Rust не так страшен, как его малюют и на нём легко и приятно писать мини-игры. Если найду время - попробую собрать и запустить в WASM. Отпишусь в комментарии.

      В процитированной строке время учитывается. Для вычисления позиции скорость умножается на время (elapsed_time).


      1. SabMakc
        14.04.2022 13:16

        Я привел "исправленную" строку, в оригинале умножения на elapsed_time нет...

        И это исправление потребует изменения параметров скорости ) Потому как очень медленным становится изменение позиции...

        Или это происходит из-за замедления - не совсем понимаю, зачем деление:

            // Замедляем корабль по горизонтали.
            self.speed /= DECELERATION * elapsed_time;


        1. F3kilo Автор
          15.04.2022 08:13

          Хм... Действительно. Спасибо за замечание. Поправлю.


    1. ozkriff
      14.04.2022 13:42
      +2

      А под разные платформы пробовали собирать? Мобильные/браузер?

      Если интересно, у меня есть небольшая опенсорсная поделка на макрокваде - https://github.com/ozkriff/zemeroth. Вот (немного устаревшая) wasm версия на итче. Вот андроид порт и заметки про него. С ios Федя (автор квадов) когда-то экспериментировал, но дальше pov дело пока не долшло.

      И вот тут - https://github.com/ozkriff/awesome-quads - я еще всяке макроквадные ссылки старался собирать, может кому пригодится. Если есть какие сложности-вопросы, то можно в дискорде обсудить с автором и другими пользователями.


      1. SabMakc
        14.04.2022 14:28

        Спасибо, очень интересно )