Глава 1: Базовая игра
Добро пожаловать в Rust Sokoban!
Что такое Rust Sokoban?
Rust Sokoban — это расширенное руководство по созданию копии игры Sokoban на Rust. Мы будем использовать существующий игровой 2D-движок и готовые ресурсы, чтобы в конце получить полностью рабочую игру.
Кто создавал эту книгу?
Эту книгу написала @oliviff при помощи замечательных участников: Blaine, Ivan, cobans, Hector, Matt, Guilhermo и Christian.
Почему Rust?
Я начала изучать Rust в марте 2019-го и с тех пор я делаю на нём игру. За это время я много узнала о Rust, о создании игр, написала несколько постов в блог и многое узнала о игровой экосистеме в Rust. В этой книге я пишу обо всём этом и надеюсь, что она поможет большому числу людей создавать игры на Rust.
Необходимо ли знание Rust для чтения?
Нет, вы можете не знать Rust! Эта книга должна стать хорошим практическим введением в создание игр на Rust. Она объяснит синтаксис и базовые концепции языка и даст вам достаточно информации, чтобы в дальнейшем вы могли самостоятельно углубиться в интересные темы. Я рекомендую изучить эту книгу по шагам. Если вам встретится что-то, в чем вы хотели бы разобраться, то рядом, скорее всего, будет ссылка на более детальное объяснение. Эта книга не будет инструкцией по всему языку, она лишь мягкое введение в Rust при помощи небольших весёлых задач.
Форматирование книги
Ниже вы можете увидеть примеры ссылок, встречающихся в книге. Они ведут на ресурс, на котором вы можете узнать подробнее о представленной концепции, связанной с Rust или разработкой игр.
ЕЩЁ: Узнать больше можно здесь.
Вы также можете встретить ссылки, указывающие на полную версию кода из текущей главы. Иногда включён будет не весь код, так что проверьте его на наличие импортов, другого кода и прочего.
КОД: Увидеть весь код из данной главы можно здесь.
Ресурсы
Если вы обнаружите, что нуждаетесь в помощи или хотите задать вопрос, то вот вам несколько хороших для этого мест:
- Язык программирования Rust
- Rust на примерах
- Rust in motion course
- r/rust
- r/rustgamedev
- #rustlang
- @rustlang
- @rust_gamedev
Одна из лучших вещей в Rust — это люди, стоящие за ним. У нас есть потрясающее сообщество, и вы можете обратиться к любому из нас.
Теперь, когда с введением покончено, давайте начнём создавать нашу первую игру на Rust (технически для меня она будет вторая, но надеюсь, для вас — первая).
Создано @oliviff с 🦀 и 🧡
Настройка проекта
Давайте установим rustup, который проинсталлирует Rust и его компилятор. Теперь проверим, что всё корректно установлено, используя следующие две команды. Версии не слишком важны, так что можете не беспокоиться, если у вас будет другая.
$ rustc --version
rustc 1.40.0
$ cargo --version
cargo 1.40.0
Создание проекта
Cargo — пакетный менеджер Rust, который мы будем использовать для создания проекта нашей игры. Перейдите в директорию, где она будет жить, и выполните следующую команду — она создаст новый проект с названием rust-sokoban
.
$ cargo init rust-sokoban
После выполнения команды вы должны увидеть такую структуру директорий:
├── src
│ └── main.rs
└── Cargo.toml
Теперь в этой директории мы можем запустить cargo run
, после чего увидим что-то похожее:
$ cargo run
Compiling rust-sokoban v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 1.30s
Running `../rust-sokoban/target/debug/rust-sokoban`
Hello, world!
Создание игры
Настало время превратить наш базовый "Hello, World!" в игру! Мы будем использовать ggez — один из популярных 2D-движков для создания игр.
Помните файл Cargo.toml
, который мы видели в нашей директории? Этот файл используется для управления зависимостями, так что если мы захотим использовать какие-нибудь крейты, мы должны добавить их туда. Давайте добавим ggez как одну из наших зависимостей.
ЕЩЁ: Узнать больше о Cargo и toml-файлах можно здесь.
[dependencies]
ggez = "0.7"
Теперь снова запустим команду cargo run
— вы должны видеть что-то похожее. Её выполнение займёт больше времени, чем обычно, так как она загружает новые зависимости с crates.io, затем компилирует их и, наконец, линкует с нашей библиотекой.
cargo run
Updating crates.io index
Downloaded ....
....
Compiling ....
....
Finished dev [unoptimized + debuginfo] target(s) in 2m 15s
Running `.../rust-sokoban/target/debug/rust-sokoban`
Hello, world!
Обратите внимание: если вы используете Ubuntu, то, возможно, вам потребуется установить некоторые дополнительные системные зависимости. Если этот шаг потерпел неудачу и вы видите ошибки, связанные с
alsa
иlibudev
, то установите их при помощи командыsudo apt-get install libudev-dev libasound2-dev
.
Теперь давайте опробуем ggez
в главном файле и настроим окно. Это лишь простейший пример программы на ggez
, в которой создаётся окно — и больше ничего. Скопируйте это в main.rs
и запустите.
use ggez::{conf, event, Context, GameResult}; use std::path; // This struct will hold all our game state // For now there is nothing to be held, but we'll add // things shortly. struct Game {} // This is the main event loop. ggez tells us to implement // two things: // - updating // - rendering impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { // TODO: update game logic here Ok(()) } fn draw(&mut self, _context: &mut Context) -> GameResult { // TODO: update draw here Ok(()) } } pub fn main() -> GameResult { // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game {}; // Run the main event loop event::run(context, event_loop, game) }
Вы должны увидеть что-то похожее:
Базовые концепции и синтаксис
Теперь, когда у нас есть базовое окно, давайте погрузимся в код и поймём основные концепции и синтаксис Rust.
Импортирование
Если вы уже изучали другие языки программирования, то эта концепция должна быть вам знакома. Для того, чтобы добавить типы и пространства имён в область видимости из наших зависимостей (или крейтов), мы используем слово use
.
#![allow(unused)] fn main() { // это импортирует `conf`, `event`, `Context` и `GameResult` из пакета ggez use ggez::{conf, event, Context, GameResult}; }
Объявление структуры
#![allow(unused)] fn main() { // This struct will hold all our game state // For now there is nothing to be held, but we'll add // things shortly. struct Game {} }
ЕЩЁ: Узнать больше о структурах вы можете здесь.
Реализация типажа
В других языках аналогом типажей являются интерфейсы, которые позволяют нам привязывать типам определённое поведение. В нашем случае мы хотим реализовать требуемое типажом EventHandler
поведение для структуры Game
.
#![allow(unused)] fn main() { // This is the main event loop. ggez tells us to implement // two things: // - updating // - rendering impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { // TODO: update game logic here Ok(()) } fn draw(&mut self, _context: &mut Context) -> GameResult { // TODO: update draw here Ok(()) } } }
ЕЩЁ: Узнать больше о типажах вы можете здесь.
Функции
Также мы изучим, как в Rust объявляются функции.
#![allow(unused)] fn main() { fn update(&mut self, _context: &mut Context) -> GameResult { // TODO: update game logic here Ok(()) } }
Вам может быть интересно, что означает self
. В данном случае self
означает, что функция update
является методом, т. е. принадлежит экземпляру структуры Game
и не может быть вызвана в статическом контексте.
ЕЩЁ: Узнать больше о функциях вы можете здесь.
Mut-синтаксис
Ещё вы можете задаться вопросом, что значат &mut
в &mut self
функции update
. Изменяемость (mutablitiy) объекта просто говорит о том, можно ли изменять его или нет. Ознакомьтесь со следующим примером объявления переменных:
#![allow(unused)] fn main() { let a = 10; // a не может быть изменена, так как она не объявлена изменяемой let mut b = 20; // b может быть изменена, так как она объявлена изменяемой }
Теперь вернёмся к функции update
. Если mut
используется вместе с self
, то оно ссылается на экземпляр структуры, к которой относится функция. Возьмём другой пример:
#![allow(unused)] fn main() { // Простая структура X с переменной num внутри struct X { num: u32 } // Блок реализации для X impl X { fn a(&self) { self.num = 5 } // a не может изменить экземпляр структуры X, так как // используется &self. Это не скомпилируется fn b(&mut self) { self.num = 5 } // b может изменять экземпляр структуры X, так как // используется &mut self. Эта часть скомпилируется } }
ЕЩЁ: Узнать больше про изменяемость вы можете здесь (в этой лекции используется Java, но эти концепции можно применить к любым языкам), а прочитать больше о переменных и изменяемости в Rust можно здесь.
После небольшого введения в синтаксис Rust мы готовы двигаться дальше. Увидимся в следующей главе!
КОД: Увидеть весь код из данной главы можно здесь.
Entity Component System
В этой главе мы более детально обсудим Sokoban и то, как мы спроектируем нашу игру.
Sokoban
Если вы раньше не встречались с Sokoban на GameBoy, то ниже вы можете увидеть, как выглядит игра. У нас есть стены и коробки, а цель игры состоит в том, чтобы игрок передвинул все коробки на правильные места.
ECS
ECS (Entity Component System) — это архитектурный шаблон создания игр, который следует принципу композиции, а не наследования. Мы будем активно использовать ECS в этом проекте — как и в большинстве игр на Rust — так что давайте потратим немного времени на ознакомление с его ключевыми концепциями:
- Components (компоненты) — структуры, содержащие только данные. Это могут быть разные характеристики сущностей: позиция, отображаемость, передвигаемость и так далее. Главное — это чистые данные, без поведения.
- Entities (сущности) — могут содержать множество компонентов. Например, игрок может характеризоваться позицией, отображаемостью и передвигаемостью, в то время как пол уровня — только позицией и отображаемостью, так как он неподвижен. Сущности — это просто контейнеры с одним или несколькими компонентами и уникальным идентификатором.
- Systems (системы) — содержат поведение и логику, использующие сущности и компоненты. Например, у вас может быть система рендеринга, которая проходит по всем сущностям, имеющим компонент отображения, чтобы их нарисовать. Ключевым здесь является то, что компоненты сами по себе не имеют никакого поведения — они используют системы для интерпретации и обработки данных.
Если это для вас всё ещё звучит как бессмыслица — не волнуйтесь. В следующей главе мы обсудим несколько практических примеров, применимых к нашей игре.
Архитектура Sokoban
Основываясь на описанном выше, мы теперь знаем, как должна работать наша игра. Нам нужны несколько типов "вещей": стены, игрок, полы, коробки и места для них. Это будут наши сущности.
Теперь мы должны определить, из чего будут сделаны наши сущности — какие компоненты нам нужны. Итак, прежде всего нам нужно знать, где что находится на карте — для этого нам нужен какой-нибудь позиционный компонент. Далее, некоторые (но не все) сущности могут перемещаться. Игрок может двигаться — как и коробки, если он их толкает. И, наконец, нам нужен способ отрисовки каждой сущности — так что нам нужен компонент отображения.
Вот как выглядит наша идея о сущностях и компонентах:
- Игрок: позиционируемый, отображаемый, передвигаемый
- Стена: позиционируемая, отображаемая
- Пол уровня: позиционируемый, отображаемый
- Коробка: позиционируемая, отображаемая, передвигаемая
- Место коробки: позиционируемое, отображаемое
На первый взгляд мыслить в ECS парадигме может быть сложно, но не огорчайтесь, если вы ничего не поняли или привыкли пользоваться чем-то другим.
Specs
Наконец, добавим пакет ECS. Их достаточно много, но в этой книге мы будем использовать specs.
[dependencies]
ggez = "0.7"
specs = { version = "0.17.0", features = ["specs-derive"] }
Скоро мы начнём создавать сущности и компоненты!
КОД: Увидеть весь код из данной главы можно здесь.
Компоненты и сущности
В этой главе мы создадим наши компоненты и увидим, как создавать сущности и регистрировать всё правильно, чтобы specs
был доволен.
Определение компонентов
Начнём с определения компонентов. Ранее мы обсуждали компоненты позиции, отображения и перемещения. На данном этапе мы опустим перемещение. Также нам необходимы некоторые компоненты для идентификации каждой сущности — например, нам нужен компонент стены, чтобы мы могли идентифицировать сущность как стену по его наличию.
Достаточно очевидно, что компонент позиции будет хранить координаты x, y и z, которые скажут нам о положении чего-либо на карте. В компоненте отображения мы будем хранить строковый путь, указывающий на изображение, которое мы сможем отрисовать. Все остальные компоненты — маркерные, не содержащие (пока что) данных.
#![allow(unused)] fn main() { #[derive(Debug, Component, Clone, Copy)] #[storage(VecStorage)] pub struct Position { x: u8, y: u8, z: u8, } #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { path: String, } #[derive(Component)] #[storage(VecStorage)] pub struct Wall {} #[derive(Component)] #[storage(VecStorage)] pub struct Player {} #[derive(Component)] #[storage(VecStorage)] pub struct Box {} #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot {} }
Вместе с уже знакомым кодом на Rust тут мы видим новый синтаксис. Здесь, в #[storage(VecStorage)]
, мы используем могущественную особенность Rust — процедурные макросы
. Макросы этого типа по сути являются функциями, которые во время компиляции принимают один синтаксис и производят из него другой.
ЕЩЁ: Узнать больше о процедурных макросах вы можете здесь.
Регистрация компонентов
Чтобы specs
не ругался, мы должны заранее сообщить ему, какие компоненты мы будем использовать. Давайте создадим функцию, которая будет регистрировать компоненты в specs
.
#![allow(unused)] fn main() { pub fn register_components(world: &mut World) { world.register::<Position>(); world.register::<Renderable>(); world.register::<Player>(); world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); } }
Создание сущностей
Сущность — это просто числовой идентификатор, привязанный к набору компонентов. Таким образом, создание сущностей — это просто указание, какие компоненты они содержат.
Теперь создание сущностей выглядит так:
#![allow(unused)] fn main() { pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .build(); } }
Ресурсы
Вы могли заметить, что при создании сущностей мы ссылаемся на ресурсы, которые будем использовать. Вы можете создать свои собственные ресурсы или загрузить те, которые использую я. Они находятся ниже (просто сохраните их как изображения из контекстного меню).
Давайте добавим изображения в наш проект. Мы создадим директорию resources
, которая будет содержать все наши ресурсы. На данный момент у нас будут только изображения, но в будущем появятся другие типы ресурсов, такие как конфигурационные файлы или аудио (об этом мы узнаем в главе 3.3 — Звуки и события). Мы также добавим директорию images
и поместим туда наши изображения. Если хотите — можете использовать другую структуру каталогов, но будьте внимательны в дальнейшем везде, где мы используем пути к изображениям.
├── resources
│ └── images
│ ├── box.png
│ ├── box_spot.png
│ ├── floor.png
│ ├── player.png
│ └── wall.png
├── src
│ └── main.rs
└── Cargo.toml
Создание мира
Наконец, соберём всё вместе. Мы должны создать объект specs::World
, чтобы добавить его в структуру Game
. Он будет первым, что мы инициализируем в нашем main
. Ниже находится полный код, который при запуске выведет то же самое пустое окно — но мы сделали огромный прогресс в настройке игровых компонентов и сущностей! Далее мы перейдём к отрисовке, чтобы наконец увидеть что-то на экране!
// Rust sokoban // main.rs use ggez::{conf, event, Context, GameResult}; use specs::{Builder, Component, VecStorage, World, WorldExt}; use std::path; // Components #[derive(Debug, Component, Clone, Copy)] #[storage(VecStorage)] pub struct Position { x: u8, y: u8, z: u8, } #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { path: String, } #[derive(Component)] #[storage(VecStorage)] pub struct Wall {} #[derive(Component)] #[storage(VecStorage)] pub struct Player {} #[derive(Component)] #[storage(VecStorage)] pub struct Box {} #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot {} // This struct will hold all our game state // For now there is nothing to be held, but we'll add // things shortly. struct Game { world: World, } impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { Ok(()) } fn draw(&mut self, _context: &mut Context) -> GameResult { Ok(()) } } // Register components with the world pub fn register_components(world: &mut World) { world.register::<Position>(); world.register::<Renderable>(); world.register::<Player>(); world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); } // Create a wall entity pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .build(); } pub fn main() -> GameResult { let mut world = World::new(); register_components(&mut world); // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game { world }; // Run the main event loop event::run(context, event_loop, game) }
Обратите внимание, что сейчас при запуске в консоли выводится сообщение о неиспользуемых импортах и/или полях. Не беспокойтесь, мы исправим это в следующих главах.
КОД: Увидеть весь код из данной главы можно здесь.
Система отрисовки
Пришло время для нашей первой системы — системы отрисовки. Она должна уметь отображать все наши сущности на экране.
Настройка
Первым делом мы определим структуру RenderingSystem
, которая будет нужна для доступа к контексту ggez
чтобы запускать отрисовку.
#![allow(unused)] fn main() { pub struct RenderingSystem<'a> { context: &'a mut Context, } }
Здесь мы встречаемся с новым синтаксисом. 'a
— это аннотация жизненного цикла. Она нужна для того, чтобы компилятор мог знать, как долго ссылка в RenderingSystem
будет доступна.
ЕЩЁ: Узнать больше про жизненные циклы вы можете здесь.
Теперь давайте реализуем типаж System
для нашей системы отрисовки. Пока ничего нового особенно нет — мы просто ведём подготовительные работы. Определение SystemData
автоматически означает, что у нас будет доступ к хранилищу позиций и отрисовываемым компонентам. Это хранилище открыто только для чтения, поэтому у нас будет только неизменяемый доступ. Но это-то нам как раз и нужно!
#![allow(unused)] fn main() { // System implementation impl<'a> System<'a> for RenderingSystem<'a> { // Data type SystemData = (ReadStorage<'a, Position>, ReadStorage<'a, Renderable>); fn run(&mut self, data: Self::SystemData) { let (positions, renderables) = data; // implementation here } } }
После чего запустим нашу систему отрисовки в цикле рисования. Это значит, что каждый раз, когда игра будет обновляться, мы будем отрисовывать последнее состояние всех наших сущностей.
#![allow(unused)] fn main() { impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { let mut rs = RenderingSystem { context }; rs.run_now(&self.world); } Ok(()) } } }
После запуска игры всё должно скомпилироваться успешно, но, скорее всего, ничего пока не произойдёт — потому что у нас нет никакой реализации системы отрисовки и никаких сущностей.
Реализация системы отрисовки
Ниже — реализация системы отрисовки. Она делает следующее:
- Очищает экран (мы должны быть уверены, что на нем не осталось никаких отработанных состояний с предыдущего кадра).
- Получает все отрисовываемые сущности и сортирует их по z (мы должны убедиться, что одни объекты рисуются поверх других — например, что пол находится за игроком, иначе мы не сможем его увидеть).
- Проходит по всем отсортированным сущностям и отрисовывает каждую как изображение.
- И напоследок отображает всё на экране.
#![allow(unused)] fn main() { fn run(&mut self, data: Self::SystemData) { let (positions, renderables) = data; // Clearing the screen (this gives us the background colour) graphics::clear(self.context, graphics::Color::new(0.95, 0.95, 0.95, 1.0)); // Get all the renderables with their positions and sort by the position z // This will allow us to have entities layered visually. let mut rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); rendering_data.sort_by_key(|&k| k.0.z); // Iterate through all pairs of positions & renderables, load the image // and draw it at the specified position. for (position, renderable) in rendering_data.iter() { // Load the image let image = Image::new(self.context, renderable.path.clone()).expect("expected image"); let x = position.x as f32 * TILE_WIDTH; let y = position.y as f32 * TILE_WIDTH; // draw let draw_params = DrawParam::new().dest(Vec2::new(x, y)); graphics::draw(self.context, &image, draw_params).expect("expected render"); } // Finally, present the context, this will actually display everything // on the screen. graphics::present(self.context).expect("expected to present"); } }
Добавление тестовых сущностей
Давайте создадим несколько тестовых сущностей, чтобы убедиться, что всё работает так, как надо.
#![allow(unused)] fn main() { pub fn initialize_level(world: &mut World) { create_player( world, Position { x: 0, y: 0, z: 0, // we will get the z from the factory functions }, ); create_wall( world, Position { x: 1, y: 0, z: 0, // we will get the z from the factory functions }, ); create_box( world, Position { x: 2, y: 0, z: 0, // we will get the z from the factory functions }, ); } }
И теперь соберём всё в единое целое и запустим. Вы должны увидеть что-то такое — и это суперздорово! Теперь у нас есть система отрисовки, и мы наконец-то можем видеть что-то на экране. В дальнейшем мы займёмся работой над геймплеем, чтобы сделать из нашей заготовки настоящую игру.
Итоговый код находится ниже.
Обратите внимание: это очень простая реализация отрисовки — она не справится с большим количеством сущностей. Более продвинутая реализация с использованием пакетной отрисовки находится в Главе 3 — Пакетная отрисовка.
// Rust sokoban // main.rs use glam::Vec2; use ggez::{conf, event, Context, GameResult, graphics::{self, DrawParam, Image}}; use specs::{ join::Join, Builder, Component, ReadStorage, RunNow, System, VecStorage, World, WorldExt, }; use std::path; const TILE_WIDTH: f32 = 32.0; // Components #[derive(Debug, Component, Clone, Copy)] #[storage(VecStorage)] pub struct Position { x: u8, y: u8, z: u8, } #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { path: String, } #[derive(Component)] #[storage(VecStorage)] pub struct Wall {} #[derive(Component)] #[storage(VecStorage)] pub struct Player {} #[derive(Component)] #[storage(VecStorage)] pub struct Box {} #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot {} // Systems pub struct RenderingSystem<'a> { context: &'a mut Context, } // System implementation impl<'a> System<'a> for RenderingSystem<'a> { // Data type SystemData = (ReadStorage<'a, Position>, ReadStorage<'a, Renderable>); fn run(&mut self, data: Self::SystemData) { let (positions, renderables) = data; // Clearing the screen (this gives us the background colour) graphics::clear(self.context, graphics::Color::new(0.95, 0.95, 0.95, 1.0)); // Get all the renderables with their positions and sort by the position z // This will allow us to have entities layered visually. let mut rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); rendering_data.sort_by_key(|&k| k.0.z); // Iterate through all pairs of positions & renderables, load the image // and draw it at the specified position. for (position, renderable) in rendering_data.iter() { // Load the image let image = Image::new(self.context, renderable.path.clone()).expect("expected image"); let x = position.x as f32 * TILE_WIDTH; let y = position.y as f32 * TILE_WIDTH; // draw let draw_params = DrawParam::new().dest(Vec2::new(x, y)); graphics::draw(self.context, &image, draw_params).expect("expected render"); } // Finally, present the context, this will actually display everything // on the screen. graphics::present(self.context).expect("expected to present"); } } // This struct will hold all our game state // For now there is nothing to be held, but we'll add // things shortly. struct Game { world: World, } // This is the main event loop. ggez tells us to implement // two things: // - updating // - rendering impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { let mut rs = RenderingSystem { context }; rs.run_now(&self.world); } Ok(()) } } // Register components with the world pub fn register_components(world: &mut World) { world.register::<Position>(); world.register::<Renderable>(); world.register::<Player>(); world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); } // Create a wall entity pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .build(); } // Initialize the level pub fn initialize_level(world: &mut World) { create_player( world, Position { x: 0, y: 0, z: 0, // we will get the z from the factory functions }, ); create_wall( world, Position { x: 1, y: 0, z: 0, // we will get the z from the factory functions }, ); create_box( world, Position { x: 2, y: 0, z: 0, // we will get the z from the factory functions }, ); } pub fn main() -> GameResult { let mut world = World::new(); register_components(&mut world); initialize_level(&mut world); // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game { world }; // Run the main event loop event::run(context, event_loop, game) }
КОД: Увидеть весь код из данной главы можно здесь.
Глава 2: Геймплей
Добро пожаловать в Главу 2 — мы рады, что вы зашли так далеко! В этой главе мы будем работать над геймплеем и оживим нашу игру!
Загрузка карты
В предыдущей главе мы остановились на том, что создали несколько сущностей и протестировали систему отрисовки. Теперь пришло время отрисовать подходящую карту. Для этого мы создадим текстовую конфигурацию карты, которую будем использовать для загрузки.
Конфигурация карты
Первым делом попробуем загрузить уровень, основанный на 2D-карте, которая выглядит следующим образом:
N N W W W W W W
W W W . . . . W
W . . . B . . W
W . . . . . . W
W . P . . . . W
W . . . . . . W
W . . S . . . W
W . . . . . . W
W W W W W W W W
где:
. — пустое место
W — стена (wall)
P — игрок (player)
B — коробка (box)
S — место для коробки (box spot)
N — используется для внешних границ карты (nothing)
Напишем для этого следующую строку. Однажды мы будем загружать это из файла, но для простоты пока остановимся на строковой константе в коде.
#![allow(unused)] fn main() { pub fn initialize_level(world: &mut World) { const MAP: &str = " N N W W W W W W W W W . . . . W W . . . B . . W W . . . . . . W W . P . . . . W W . . . . . . W W . . S . . . W W . . . . . . W W W W W W W W W "; load_map(world, MAP.to_string()); } }
Ниже — реализация загрузки карты:
#![allow(unused)] fn main() { pub fn load_map(world: &mut World, map_string: String) { // read all lines let rows: Vec<&str> = map_string.trim().split('\n').map(|x| x.trim()).collect(); for (y, row) in rows.iter().enumerate() { let columns: Vec<&str> = row.split(' ').collect(); for (x, column) in columns.iter().enumerate() { // Create the position at which to create something on the map let position = Position { x: x as u8, y: y as u8, z: 0, // we will get the z from the factory functions }; // Figure out what object we should create match *column { "." => create_floor(world, position), "W" => { create_floor(world, position); create_wall(world, position); } "P" => { create_floor(world, position); create_player(world, position); } "B" => { create_floor(world, position); create_box(world, position); } "S" => { create_floor(world, position); create_box_spot(world, position); } "N" => (), c => panic!("unrecognized map item {}", c), } } } } }
Пожалуй, самая интересная концепция Rust — это match
. Здесь мы используем только базовые функции сопоставления с образцом: мы просто смотрим значение каждого токена из конфигурации карты. Но позже с помощью этого инструмента мы сможем создавать куда более сложные условия или типы образцов.
ЕЩЁ: Узнать больше про сопоставление с образцами вы можете здесь.
Теперь давайте запустим игру и посмотрим, как выглядит наша карта:
Окончательная версия кода:
// Rust sokoban // main.rs use glam::Vec2; use ggez::{conf, event, Context, GameResult, graphics::{self, DrawParam, Image}}; use specs::{ join::Join, Builder, Component, ReadStorage, RunNow, System, VecStorage, World, WorldExt, }; use std::path; const TILE_WIDTH: f32 = 32.0; // Components #[derive(Debug, Component, Clone, Copy)] #[storage(VecStorage)] pub struct Position { x: u8, y: u8, z: u8, } #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { path: String, } #[derive(Component)] #[storage(VecStorage)] pub struct Wall {} #[derive(Component)] #[storage(VecStorage)] pub struct Player {} #[derive(Component)] #[storage(VecStorage)] pub struct Box {} #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot {} // Systems pub struct RenderingSystem<'a> { context: &'a mut Context, } // System implementation impl<'a> System<'a> for RenderingSystem<'a> { // Data type SystemData = (ReadStorage<'a, Position>, ReadStorage<'a, Renderable>); fn run(&mut self, data: Self::SystemData) { let (positions, renderables) = data; // Clearing the screen (this gives us the backround colour) graphics::clear(self.context, graphics::Color::new(0.95, 0.95, 0.95, 1.0)); // Get all the renderables with their positions and sort by the position z // This will allow us to have entities layered visually. let mut rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); rendering_data.sort_by_key(|&k| k.0.z); // Iterate through all pairs of positions & renderables, load the image // and draw it at the specified position. for (position, renderable) in rendering_data.iter() { // Load the image let image = Image::new(self.context, renderable.path.clone()).expect("expected image"); let x = position.x as f32 * TILE_WIDTH; let y = position.y as f32 * TILE_WIDTH; // draw let draw_params = DrawParam::new().dest(Vec2::new(x, y)); graphics::draw(self.context, &image, draw_params).expect("expected render"); } // Finally, present the context, this will actually display everything // on the screen. graphics::present(self.context).expect("expected to present"); } } // This struct will hold all our game state // For now there is nothing to be held, but we'll add // things shortly. struct Game { world: World, } // This is the main event loop. ggez tells us to implement // two things: // - updating // - rendering impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { let mut rs = RenderingSystem { context }; rs.run_now(&self.world); } Ok(()) } } // Register components with the world pub fn register_components(world: &mut World) { world.register::<Position>(); world.register::<Renderable>(); world.register::<Player>(); world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); } // Create a wall entity pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .build(); } // Initialize the level pub fn initialize_level(world: &mut World) { const MAP: &str = " N N W W W W W W W W W . . . . W W . . . B . . W W . . . . . . W W . P . . . . W W . . . . . . W W . . S . . . W W . . . . . . W W W W W W W W W "; load_map(world, MAP.to_string()); } pub fn load_map(world: &mut World, map_string: String) { // read all lines let rows: Vec<&str> = map_string.trim().split('\n').map(|x| x.trim()).collect(); for (y, row) in rows.iter().enumerate() { let columns: Vec<&str> = row.split(' ').collect(); for (x, column) in columns.iter().enumerate() { // Create the position at which to create something on the map let position = Position { x: x as u8, y: y as u8, z: 0, // we will get the z from the factory functions }; // Figure out what object we should create match *column { "." => create_floor(world, position), "W" => { create_floor(world, position); create_wall(world, position); } "P" => { create_floor(world, position); create_player(world, position); } "B" => { create_floor(world, position); create_box(world, position); } "S" => { create_floor(world, position); create_box_spot(world, position); } "N" => (), c => panic!("unrecognized map item {}", c), } } } } pub fn main() -> GameResult { let mut world = World::new(); register_components(&mut world); initialize_level(&mut world); // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game { world }; // Run the main event loop event::run(context, event_loop, game) }
КОД: Увидеть весь код из данной главы можно здесь.
Передвижение игрока
Игра не будет игрой, если игрок просто стоит на месте, не так ли? Поэтому здесь мы научимся перехватывать входящие события.
Входящие события
Первый шаг к тому, чтобы наш игрок двигался, — это прослушивание входящих событий. Если мы посмотрим на этот пример в ggez, то увидим, что у нас есть возможность подписаться на любые события от мыши и клавиатуры. Но сейчас нам нужен только key_down_event
.
Начнём прослушивать события нажатия клавиш. Для начала подключим ещё несколько модулей:
#![allow(unused)] fn main() { // Rust sokoban // main.rs use glam::Vec2; use ggez::{conf, Context, GameResult, event::{self, KeyCode, KeyMods}, graphics::{self, DrawParam, Image}}; use specs::{ join::Join, Builder, Component, ReadStorage, RunNow, System, VecStorage, World, WorldExt, Write, WriteStorage, }; }
Затем добавим этот код в блок event::EventHandler
нашей игры:
#![allow(unused)] fn main() { impl event::EventHandler<ggez::GameError> for Game { // ... fn key_down_event( &mut self, _context: &mut Context, keycode: KeyCode, _keymod: KeyMods, _repeat: bool, ) { println!("Key pressed: {:?}", keycode); } // ... } }
Если сейчас мы это запустим, в консоли появятся следующие строки:
Key pressed: Left
Key pressed: Left
Key pressed: Right
Key pressed: Up
Key pressed: Down
Key pressed: Left
Если вы ещё не знакомы с нотацией {:?}
, которая использовалась для вывода строк, — то это просто удобный способ, которым Rust позволяет нам выводить информацию об объектах в консоль. Его можно использовать для отладки. В нашем случае мы смогли вывести объект KeyCode
(который является перечислением), потому что тип KeyCode
реализует типаж Debug
с помощью макроса Debug
(помните, мы уже обсуждали макросы в главе 1.3? Вернитесь и перечитайте, если вам нужно освежить память). Если бы KeyCode
не реализовывал Debug
, мы бы не смогли использовать этот синтаксис и получили бы ошибку при компиляции. Но благодаря этому мы избавлены от необходимости писать код с нуля, чтобы преобразовать коды клавиш в строку — можно просто положиться на встроенный функционал.
Ресурсы
Следующим шагом мы добавим ресурсы — так specs
сможет обмениваться состояниями внутри систем, которые не являются частью вашего мира. Мы будем использовать ресурсы для моделирования очереди нажатий клавиш, потому что этот тип взаимодействия немного не вписывается в ECS — нашу существующую модель сущностей и компонентов.
#![allow(unused)] fn main() { // Resources #[derive(Default)] pub struct InputQueue { pub keys_pressed: Vec<KeyCode>, } }
Затем мы добавляем новые нажатия клавиш в очередь, когда вызывается событие key_down_event
:
#![allow(unused)] fn main() { impl event::EventHandler<ggez::GameError> for Game { // ... fn key_down_event( &mut self, _context: &mut Context, keycode: KeyCode, _keymod: KeyMods, _repeat: bool, ) { println!("Key pressed: {:?}", keycode); let mut input_queue = self.world.write_resource::<InputQueue>(); input_queue.keys_pressed.push(keycode); } // ... } }
И напоследок нам нужно зарегистрировать наши ресурсы в specs
— так же, как мы сделали с компонентами:
// Регистрация ресурсов pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()) } // Регистрация ресурсов в `main` pub fn main() -> GameResult { let mut world = World::new(); register_components(&mut world); register_resources(&mut world); initialize_level(&mut world); // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game { world }; // Run the main event loop event::run(context, event_loop, game)
Система ввода
С помощью этого кода мы получили ресурс, который представляет собой непрерывную очередь сигналов о нажатиях клавиш. Далее мы начнём обрабатывать её в системе:
#![allow(unused)] fn main() { pub struct InputSystem {} impl<'a> System<'a> for InputSystem { // Data type SystemData = ( Write<'a, InputQueue>, WriteStorage<'a, Position>, ReadStorage<'a, Player>, ); fn run(&mut self, data: Self::SystemData) { let (mut input_queue, mut positions, players) = data; for (position, _player) in (&mut positions, &players).join() { // Get the first key pressed if let Some(key) = input_queue.keys_pressed.pop() { // Apply the key to the position match key { KeyCode::Up => position.y -= 1, KeyCode::Down => position.y += 1, KeyCode::Left => position.x -= 1, KeyCode::Right => position.x += 1, _ => (), } } } } } }
После чего нам остаётся только запустить систему в цикле обновлений:
#![allow(unused)] fn main() { fn update(&mut self, _context: &mut Context) -> GameResult { // Run input system { let mut is = InputSystem {}; is.run_now(&self.world); } Ok(()) } }
Эта система ввода довольно простая: сначала она получает информацию обо всех игроках и их позициях (он у нас будет всего один, но коду не нужно об этом знать — в теории у нас может быть несколько игроков, которых мы захотим контролировать одним и тем же устройством ввода). Затем для каждой комбинации игрока и его позиции система берёт первую нажатую клавишу и удаляет её из очереди ввода. После чего она сопоставляет её с требуемым перемещением: если мы нажмём клавишу вверх
, то нам нужно сдвинуться на одну клетку вверх — и так далее. И в конце она обновляет позицию игрока.
Круто, правда? Ниже — то, как это должно выглядеть. Ничего страшного, что мы пока можем ходить через стены и коробки насквозь, — мы починим это в следующем разделе, когда будем добавлять перемещаемые компоненты.
КОД: Увидеть весь код из данной главы можно здесь.
Перемещение коробок
В предыдущей главе мы подарили нашему игроку свободу движений, но он пока не умеет взаимодействовать с окружением и проходит через стены и коробки. В этом разделе мы добавим ещё немного логики для того, чтобы игрок понимал, куда и как ему можно перемещаться.
Компоненты передвижения
Для начала нам нужно немного доработать наш код. Если вы помните, в предыдущей главе нас заботило только то, куда должен двигаться игрок, — теперь же нам нужно двигать ещё и коробки. Кроме того, в будущем мы можем захотеть добавить и другие виды перемещаемых объектов, поэтому давайте попробуем соорудить что-нибудь, держа это в уме. Что мы сделаем согласно духу ECS? Мы создадим компоненты-маркеры, которые будут подсказывать нам, какие сущности можно перемещать, а какие нельзя. Например, игроки и коробки перемещаемы, а стены — нет. Места для коробок к этой классификации, конечно, относиться не будут: с одной стороны они не двигаются, а с другой — они не должны затрагивать перемещение игрока. Поэтому места для коробок не будут иметь ни одного маркера, ни второго.
Ниже описаны два наших новых компонента. Здесь нет ничего такого, чего мы бы уже не знали, — за исключением пары деталей:
- Мы используем
NullStorage
, что немного более эффективно, чемVecStorage
, потому что у этих компонентов нет никаких полей — они используются только в качестве маркеров. - Мы реализуем
Default
, потому что это необходимо для использованияNullStorage
. - Оба этих компонента мы добавляем в функцию
register_components
.
#![allow(unused)] fn main() { #[derive(Component, Default)] #[storage(NullStorage)] pub struct Movable; #[derive(Component, Default)] #[storage(NullStorage)] pub struct Immovable; world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); world.register::<Movable>(); world.register::<Immovable>(); } pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()) } }
После этого мы добавляем:
with(Movable)
— игрокам и коробкам,with(Immovable)
— стенам,- и ничего не делаем с полом и местами для коробок (как уже ранее упоминалось, они не должны быть частью нашей системы движения по причине того, что никак на неё не влияют).
#![allow(unused)] fn main() { .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .with(Immovable) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .with(Movable) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .with(Movable) .build(); } // Initialize the level pub fn initialize_level(world: &mut World) { const MAP: &str = " }
Ограничения движений
Теперь давайте придумаем несколько примеров, чтобы проиллюстрировать ограничения для движений. Это поможет нам понять, как мы должны изменить реализацию системы ввода, чтобы использовать маркеры Movable
и Immovable
правильно.
Сценарии:
(player, floor)
и нажата клавишаRIGHT
-> игрок должен сдвинуться вправо.(player, wall)
и нажата клавишаRIGHT
-> игрок не должен двигаться вправо.(player, box, floor)
и нажата клавишаRIGHT
-> игрок должен сдвинуться вправо, коробка тоже должна сдвинуться вправо.(player, box, wall)
и нажата клавишаRIGHT
-> игрок и коробка должны оставаться на месте.(player, box, box, floor)
и нажата клавишаRIGHT
-> игрок и обе коробки должны сдвинуться вправо.(player, box, box, wall)
и нажата клавишаRIGHT
-> игрок и обе коробки должны оставаться на месте.
Основываясь на этом, мы можем сделать несколько наблюдений:
- Обнаружение перемещения и столкновений должно происходить для всех объектов одновременно. Возьмём сценарий 6 в качестве примера: если мы будем обрабатывать один объект за раз, мы сначала сдвинем игрока, затем первую коробку, а на второй мы поймём, что её мы сдвинуть не можем, — и нам придётся отменять все предыдущие изменения, что, конечно, неправильно. Поэтому для каждого ввода мы должны понимать, какие объекты участвуют в перемещении, и на основании этого уже решать, возможно оно или нет.
- Группа перемещаемых объектов вместе с пустым местом может двигаться (пустое место в этом случае означает что-то, что не относится к перемещаемым или неперемещаемым сущностям).
- Группа перемещаемых сущностей вместе с неперемещаемой двигаться не может.
- Несмотря на то, что все сценарии были написаны для движения вправо, эти правила должны работать с любым перемещением, а нажатая клавиша должна влиять только на то, как именно мы находим всю группу.
Учитывая всё вышесказанное, начнём реализовывать логику. Давайте подумаем над частями, которые нам нужны. Вот некоторые идеи для старта:
- Нужно найти все подвижные и неподвижные сущности — так мы сможем понять, участвуют они в движении или нет.
- Понять, в какую сторону двигаться, основываясь на нажатой клавише — мы сделали нечто похожее в прошлом разделе: просто немного +1/-1 операций, основанных на перечислении клавиш.
- Пройти через все позиции между игроком и концом карты по конкретной оси, основываясь на направлении. Например, если мы двигаемся вправо, то нам нужно пройти от координаты игрока по оси x —
player.x
до ширины карты —map_width
, если мы двигаемся вверх — от 0 доplayer.y
. - Для каждой сущности в этой последовательности нам нужно:
- если сущность перемещаема — запомнить её и продолжить,
- если сущность неперемещаема — остановиться и ничего не перемещать,
- если сущность не является ни той, ни другой — переместить все сущности, которые мы запомнили ранее.
Ниже — новая реализация системы ввода. Она довольно громоздкая, но она стоит того:
#![allow(unused)] fn main() { // Data type SystemData = ( Write<'a, InputQueue>, Entities<'a>, WriteStorage<'a, Position>, ReadStorage<'a, Player>, ReadStorage<'a, Movable>, ReadStorage<'a, Immovable>, ); fn run(&mut self, data: Self::SystemData) { let (mut input_queue, entities, mut positions, players, movables, immovables) = data; let mut to_move = Vec::new(); for (position, _player) in (&positions, &players).join() { // Get the first key pressed if let Some(key) = input_queue.keys_pressed.pop() { // get all the movables and immovables let mov: HashMap<(u8, u8), Index> = (&entities, &movables, &positions) .join() .map(|t| ((t.2.x, t.2.y), t.0.id())) .collect::<HashMap<_, _>>(); let immov: HashMap<(u8, u8), Index> = (&entities, &immovables, &positions) .join() .map(|t| ((t.2.x, t.2.y), t.0.id())) .collect::<HashMap<_, _>>(); // Now iterate through current position to the end of the map // on the correct axis and check what needs to move. let (start, end, is_x) = match key { KeyCode::Up => (position.y, 0, false), KeyCode::Down => (position.y, MAP_HEIGHT, false), KeyCode::Left => (position.x, 0, true), KeyCode::Right => (position.x, MAP_WIDTH, true), _ => continue, }; let range = if start < end { (start..=end).collect::<Vec<_>>() } else { (end..=start).rev().collect::<Vec<_>>() }; for x_or_y in range { let pos = if is_x { (x_or_y, position.y) } else { (position.x, x_or_y) }; // find a movable // if it exists, we try to move it and continue // if it doesn't exist, we continue and try to find an immovable instead match mov.get(&pos) { Some(id) => to_move.push((key, id.clone())), None => { // find an immovable // if it exists, we need to stop and not move anything // if it doesn't exist, we stop because we found a gap match immov.get(&pos) { Some(_id) => to_move.clear(), None => break, } } } } } } // Now actually move what needs to be moved for (key, id) in to_move { let position = positions.get_mut(entities.entity(id)); if let Some(position) = position { match key { KeyCode::Up => position.y -= 1, KeyCode::Down => position.y += 1, KeyCode::Left => position.x -= 1, KeyCode::Right => position.x += 1, _ => (), } } } } } }
Теперь, если мы запустим код, мы увидим, что игра работает как надо! Мы больше не можем проходить через стены — зато можем двигать коробки, которые остановятся, если упрутся в стену.
Полный код находится ниже:
// Rust sokoban // main.rs use glam::Vec2; use ggez::{ conf, Context, GameResult, event::{self, KeyCode, KeyMods}, graphics::{self, DrawParam, Image}}; use specs::{ join::Join, Builder, Component, ReadStorage, RunNow, System, VecStorage, World, WorldExt, Write, WriteStorage, NullStorage, Entities, world::Index }; use std::collections::HashMap; use std::path; const TILE_WIDTH: f32 = 32.0; const MAP_WIDTH: u8 = 8; const MAP_HEIGHT: u8 = 9; // Components #[derive(Debug, Component, Clone, Copy)] #[storage(VecStorage)] pub struct Position { x: u8, y: u8, z: u8, } #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { path: String, } #[derive(Component)] #[storage(VecStorage)] pub struct Wall {} #[derive(Component)] #[storage(VecStorage)] pub struct Player {} #[derive(Component)] #[storage(VecStorage)] pub struct Box {} #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot {} #[derive(Component, Default)] #[storage(NullStorage)] pub struct Movable; #[derive(Component, Default)] #[storage(NullStorage)] pub struct Immovable; // Resources #[derive(Default)] pub struct InputQueue { pub keys_pressed: Vec<KeyCode>, } // Systems pub struct RenderingSystem<'a> { context: &'a mut Context, } // System implementation impl<'a> System<'a> for RenderingSystem<'a> { // Data type SystemData = (ReadStorage<'a, Position>, ReadStorage<'a, Renderable>); fn run(&mut self, data: Self::SystemData) { let (positions, renderables) = data; // Clearing the screen (this gives us the backround colour) graphics::clear(self.context, graphics::Color::new(0.95, 0.95, 0.95, 1.0)); // Get all the renderables with their positions and sort by the position z // This will allow us to have entities layered visually. let mut rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); rendering_data.sort_by_key(|&k| k.0.z); // Iterate through all pairs of positions & renderables, load the image // and draw it at the specified position. for (position, renderable) in rendering_data.iter() { // Load the image let image = Image::new(self.context, renderable.path.clone()).expect("expected image"); let x = position.x as f32 * TILE_WIDTH; let y = position.y as f32 * TILE_WIDTH; // draw let draw_params = DrawParam::new().dest(Vec2::new(x, y)); graphics::draw(self.context, &image, draw_params).expect("expected render"); } // Finally, present the context, this will actually display everything // on the screen. graphics::present(self.context).expect("expected to present"); } } pub struct InputSystem {} // System implementation impl<'a> System<'a> for InputSystem { // Data type SystemData = ( Write<'a, InputQueue>, Entities<'a>, WriteStorage<'a, Position>, ReadStorage<'a, Player>, ReadStorage<'a, Movable>, ReadStorage<'a, Immovable>, ); fn run(&mut self, data: Self::SystemData) { let (mut input_queue, entities, mut positions, players, movables, immovables) = data; let mut to_move = Vec::new(); for (position, _player) in (&positions, &players).join() { // Get the first key pressed if let Some(key) = input_queue.keys_pressed.pop() { // get all the movables and immovables let mov: HashMap<(u8, u8), Index> = (&entities, &movables, &positions) .join() .map(|t| ((t.2.x, t.2.y), t.0.id())) .collect::<HashMap<_, _>>(); let immov: HashMap<(u8, u8), Index> = (&entities, &immovables, &positions) .join() .map(|t| ((t.2.x, t.2.y), t.0.id())) .collect::<HashMap<_, _>>(); // Now iterate through current position to the end of the map // on the correct axis and check what needs to move. let (start, end, is_x) = match key { KeyCode::Up => (position.y, 0, false), KeyCode::Down => (position.y, MAP_HEIGHT, false), KeyCode::Left => (position.x, 0, true), KeyCode::Right => (position.x, MAP_WIDTH, true), _ => continue, }; let range = if start < end { (start..=end).collect::<Vec<_>>() } else { (end..=start).rev().collect::<Vec<_>>() }; for x_or_y in range { let pos = if is_x { (x_or_y, position.y) } else { (position.x, x_or_y) }; // find a movable // if it exists, we try to move it and continue // if it doesn't exist, we continue and try to find an immovable instead match mov.get(&pos) { Some(id) => to_move.push((key, id.clone())), None => { // find an immovable // if it exists, we need to stop and not move anything // if it doesn't exist, we stop because we found a gap match immov.get(&pos) { Some(_id) => to_move.clear(), None => break, } } } } } } // Now actually move what needs to be moved for (key, id) in to_move { let position = positions.get_mut(entities.entity(id)); if let Some(position) = position { match key { KeyCode::Up => position.y -= 1, KeyCode::Down => position.y += 1, KeyCode::Left => position.x -= 1, KeyCode::Right => position.x += 1, _ => (), } } } } } // This struct will hold all our game state // For now there is nothing to be held, but we'll add // things shortly. struct Game { world: World, } // This is the main event loop. ggez tells us to implement // two things: // - updating // - rendering impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { // Run input system { let mut is = InputSystem {}; is.run_now(&self.world); } Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { let mut rs = RenderingSystem { context }; rs.run_now(&self.world); } Ok(()) } fn key_down_event( &mut self, _context: &mut Context, keycode: KeyCode, _keymod: KeyMods, _repeat: bool, ) { println!("Key pressed: {:?}", keycode); let mut input_queue = self.world.write_resource::<InputQueue>(); input_queue.keys_pressed.push(keycode); } } // Register components with the world pub fn register_components(world: &mut World) { world.register::<Position>(); world.register::<Renderable>(); world.register::<Player>(); world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); world.register::<Movable>(); world.register::<Immovable>(); } pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()) } // Create a wall entity pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .with(Immovable) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .with(Movable) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .with(Movable) .build(); } // Initialize the level pub fn initialize_level(world: &mut World) { const MAP: &str = " N N W W W W W W W W W . . . . W W . . . B . . W W . . . . . . W W . P . . . . W W . . . . . . W W . . S . . . W W . . . . . . W W W W W W W W W "; load_map(world, MAP.to_string()); } pub fn load_map(world: &mut World, map_string: String) { // read all lines let rows: Vec<&str> = map_string.trim().split('\n').map(|x| x.trim()).collect(); for (y, row) in rows.iter().enumerate() { let columns: Vec<&str> = row.split(' ').collect(); for (x, column) in columns.iter().enumerate() { // Create the position at which to create something on the map let position = Position { x: x as u8, y: y as u8, z: 0, // we will get the z from the factory functions }; // Figure out what object we should create match *column { "." => create_floor(world, position), "W" => { create_floor(world, position); create_wall(world, position); } "P" => { create_floor(world, position); create_player(world, position); } "B" => { create_floor(world, position); create_box(world, position); } "S" => { create_floor(world, position); create_box_spot(world, position); } "N" => (), c => panic!("unrecognized map item {}", c), } } } } pub fn main() -> GameResult { let mut world = World::new(); register_components(&mut world); register_resources(&mut world); initialize_level(&mut world); // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game { world }; // Run the main event loop event::run(context, event_loop, game) }
КОД: Увидеть весь код из данной главы можно здесь.
Модули
Файл main
заметно подрос — и вы уже понимаете, что если проект будет продолжать развиваться, его поддержка будет не самым приятным делом. К счастью, в Rust есть концепция модулей, которая позволит нам разделять функционал по принципу разделения файлов.
Пока давайте посмотрим на структуру директорий. Рано или поздно, когда у нас появятся новые компоненты и системы, мы захотим создать больше, чем один файл, — но это хорошая отправная точка.
├── resources
│ └── images
│ ├── box.png
│ ├── box_spot.png
│ ├── floor.png
│ ├── player.png
│ └── wall.png
├── src
│ ├── systems
│ │ ├── input_system.rs
│ │ ├── mod.rs
│ │ └── rendering_system.rs
│ ├── components.rs
│ ├── constants.rs
│ ├── entities.rs
│ ├── main.rs
│ ├── map.rs
│ └── resources.rs
└── Cargo.toml
ЕЩЁ: Узнать больше о модулях и о том, как управлять растущим проектом, можно здесь.
Начнём с того, что переместим все компоненты в отдельный файл. Это не должно вызвать необходимости в каких-либо дополнительных изменениях — за исключением того, что некоторые поля придётся сделать публичными. Всё дело в том, что пока у нас был только один файл, всё в этом файле имело свободный доступ друг к другу. Это было проще для понимания, но теперь, когда мы начали разделять компоненты, нам нужно обращать больше внимания на области видимости. Пока мы просто сделаем поля публичными, чтобы всё снова заработало, — но есть способ получше, и его мы позже обсудим. Кроме того, мы переместили регистрацию компонентов в самый низ файла, чтобы сделать добавление новых удобнее.
#![allow(unused)] fn main() { // components.rs use specs::{Component, NullStorage, VecStorage, World, WorldExt}; // Components #[derive(Debug, Component, Clone, Copy)] #[storage(VecStorage)] pub struct Position { pub x: u8, pub y: u8, pub z: u8, } #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { pub path: String, } #[derive(Component)] #[storage(VecStorage)] pub struct Wall {} #[derive(Component)] #[storage(VecStorage)] pub struct Player {} #[derive(Component)] #[storage(VecStorage)] pub struct Box {} #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot {} #[derive(Component, Default)] #[storage(NullStorage)] pub struct Movable; #[derive(Component, Default)] #[storage(NullStorage)] pub struct Immovable; pub fn register_components(world: &mut World) { world.register::<Position>(); world.register::<Renderable>(); world.register::<Player>(); world.register::<Wall>(); world.register::<Box>(); world.register::<BoxSpot>(); world.register::<Movable>(); world.register::<Immovable>(); } }
Затем ресурсы:
#![allow(unused)] fn main() { // resources.rs use ggez::event::KeyCode; use specs::World; // Resources #[derive(Default)] pub struct InputQueue { pub keys_pressed: Vec<KeyCode>, } pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()) } }
Теперь мы сделаем отдельный файл для констант. Пока размеры карты намертво запечатаны в коде — они нужны нам для перемещения, чтобы знать, когда мы достигнем краёв. Но наш проект будет расти, и однажды мы сделаем размеры карты динамическими — они будут считаться при её загрузке.
#![allow(unused)] fn main() { // constants.rs pub const TILE_WIDTH: f32 = 32.0; pub const MAP_WIDTH: u8 = 8; pub const MAP_HEIGHT: u8 = 9; }
Код создания сущностей мы тоже вынесем в отдельный файл:
#![allow(unused)] fn main() { // entities.rs use crate::components::*; use specs::{Builder, World, WorldExt}; // Create a wall entity pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/wall.png".to_string(), }) .with(Wall {}) .with(Immovable) .build(); } pub fn create_floor(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 5, ..position }) .with(Renderable { path: "/images/floor.png".to_string(), }) .build(); } pub fn create_box(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/box.png".to_string(), }) .with(Box {}) .with(Movable) .build(); } pub fn create_box_spot(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: "/images/box_spot.png".to_string(), }) .with(BoxSpot {}) .build(); } pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: "/images/player.png".to_string(), }) .with(Player {}) .with(Movable) .build(); } }
Теперь очередь кода загрузки карты:
#![allow(unused)] fn main() { // map.rs use crate::components::Position; use crate::entities::*; use specs::World; pub fn load_map(world: &mut World, map_string: String) { // read all lines let rows: Vec<&str> = map_string.trim().split('\n').map(|x| x.trim()).collect(); for (y, row) in rows.iter().enumerate() { let columns: Vec<&str> = row.split(' ').collect(); for (x, column) in columns.iter().enumerate() { // Create the position at which to create something on the map let position = Position { x: x as u8, y: y as u8, z: 0, // we will get the z from the factory functions }; // Figure out what object we should create match *column { "." => create_floor(world, position), "W" => { create_floor(world, position); create_wall(world, position); } "P" => { create_floor(world, position); create_player(world, position); } "B" => { create_floor(world, position); create_box(world, position); } "S" => { create_floor(world, position); create_box_spot(world, position); } "N" => (), c => panic!("unrecognized map item {}", c), } } } } }
Наконец, мы создадим отдельные файлы для систем: RenderingSystem
будет в rendering_system.rs
, а InputSystem
— в input_system.rs
. Просто копируйте и вставляйте — и не забудьте удалить весь лишний импорт.
Кое-что интересное о системах: они представляют из себя директории с несколькими файлами внутри. Если мы остановимся на этом и попробуем запустить RenderingSystem
или InputSystem
из main
, мы получим ошибки при компиляции. Нам нужно добавить файл mod.rs
в папку systems
и объяснить Rust, что именно мы хотим экспортировать из этой папки. Всё, что делает этот кусочек кода, — сообщает, что мы хотим дать доступ к типам RenderingSystem
и InputSystem
из внешнего мира (мира вне этой папки):
#![allow(unused)] fn main() { // systems/mod.rs mod input_system; mod rendering_system; pub use self::input_system::InputSystem; pub use self::rendering_system::RenderingSystem; }
Отлично. Теперь, после всего, что мы сделали, наш упрощённый файл main
выглядит вот так. Обратите внимание на декларации use
и mod
после импортирования из сторонних библиотек — опять же, это нужно для того, чтобы Rust понимал, что мы хотим использовать эти модули.
// main.rs // Rust sokoban // main.rs use ggez::{conf, event::{self, KeyCode, KeyMods}, Context, GameResult}; use specs::{RunNow, World, WorldExt}; use std::path; mod components; mod constants; mod entities; mod map; mod resources; mod systems; use crate::components::*; use crate::map::*; use crate::resources::*; use crate::systems::*; struct Game { world: World, } impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { // Run input system { let mut is = InputSystem {}; is.run_now(&self.world); } Ok(()) } fn draw(&mut self, context: &mut Context) -> GameResult { // Render game entities { let mut rs = RenderingSystem { context }; rs.run_now(&self.world); } Ok(()) } fn key_down_event( &mut self, _context: &mut Context, keycode: KeyCode, _keymod: KeyMods, _repeat: bool, ) { println!("Key pressed: {:?}", keycode); let mut input_queue = self.world.write_resource::<InputQueue>(); input_queue.keys_pressed.push(keycode); } } // Initialize the level pub fn initialize_level(world: &mut World) { const MAP: &str = " N N W W W W W W W W W . . . . W W . . . B . . W W . . . . . . W W . P . . . . W W . . . . . . W W . . S . . . W W . . . . . . W W W W W W W W W "; load_map(world, MAP.to_string()); } pub fn main() -> GameResult { let mut world = World::new(); register_components(&mut world); register_resources(&mut world); initialize_level(&mut world); // Create a game context and event loop let context_builder = ggez::ContextBuilder::new("rust_sokoban", "sokoban") .window_setup(conf::WindowSetup::default().title("Rust Sokoban!")) .window_mode(conf::WindowMode::default().dimensions(800.0, 600.0)) .add_resource_path(path::PathBuf::from("./resources")); let (context, event_loop) = context_builder.build()?; // Create the game state let game = Game { world }; // Run the main event loop event::run(context, event_loop, game) }
Не стесняйтесь — попробуйте запустить игру. Всё должно работать точно так же, как раньше. Единственное отличие в том, что код стал гораздо чище, и теперь нам будет намного проще добавлять новые интересные механики.
КОД: Увидеть весь код из данной главы можно здесь.
Игровой процесс
Игрок может двигаться сам и перемещает коробки по игровому полю. У множества (хотя и не у всех!) игр есть какая-то цель — и цель игр, похожих на Sokoban, заключается в размещении коробок на правильные места. Ничто не мешает игроку делать это прямо сейчас, но игра никак не проверяет его успех. Игрок может достичь цели и даже этого не понять! Давайте обновим игру, чтобы за этим следить.
Подумаем, что нам нужно, чтобы добавить в игру проверку условий успеха и сообщить пользователю о том, что уровень пройден:
- Ресурс для отслеживания состояния игры.
- Игра в процессе или завершена?
- Как много шагов сделал игрок?
- Система для проверки того, достиг ли игрок своей цели.
- Система для обновления числа сделанных шагов.
- Пользовательский интерфейс, который отображает состояние игры.
Ресурсы игрового процесса
Мы решили использовать resource
для того, чтобы отслеживать состояние игры — потому что оно не ассоциировано с какой-либо сущностью. Начнём с того, что определим ресурс игрового процесса — Gameplay
.
#![allow(unused)] fn main() { // resources.rs #[derive(Default)] pub struct Gameplay { pub state: GameplayState, pub moves_count: u32 } }
У Gameplay
есть два поля: состояние — state
и число шагов — moves_count
. Они используются для того, чтобы отслеживать состояние игры (игрок ещё играет или уже выиграл?) и число сделанных шагов. state
описана перечислением (enum
) следующим образом:
#![allow(unused)] fn main() { // resources.rs pub enum GameplayState { Playing, Won } }
Внимательный читатель заметит, что мы использовали макрос, чтобы унаследовать типаж Default
для ресурса Gameplay
, но не для перечисления GameplayState
. Причина проста: если мы хотим использовать Gameplay
как ресурс, оно должно реализовывать Default
.
Итак, что дальше? Так как макросы Rust не могут наследовать Default
для перечислений автоматически, мы должны реализовать Default
для Gameplay
своими руками.
#![allow(unused)] fn main() { // resources.rs impl Default for GameplayState { fn default() -> Self { Self::Playing } } }
Определив ресурсы, зарегистрируем их в нашем мире:
#![allow(unused)] fn main() { // resources.rs pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()); world.insert(Gameplay::default()); } }
Теперь после того, как начнётся игра, ресурс Gameplay
будет выглядеть так:
#![allow(unused)] fn main() { Gameplay { state: GameplayState::Playing, moves_count: 0 } }
Система подсчёта шагов
Мы можем инкрементировать поле moves_count
в ресурсе Gameplay
, чтобы отслеживать число сделанных шагов. У нас уже есть система пользовательского ввода — InputSystem
, поэтому просто адаптируем её для этой задачи.
Поскольку нам нужно изменять ресурс Gameplay
, мы должны зарегистрировать его в InputSystem
. Добавим Write<'a, Gameplay>
в определение типов SystemData
.
#![allow(unused)] fn main() { // input_system.rs use crate::components::*; use crate::constants::*; use crate::resources::{InputQueue, Gameplay}; use ggez::event::KeyCode; use specs::{world::Index, Entities, Join, ReadStorage, System, Write, WriteStorage}; use std::collections::HashMap; pub struct InputSystem {} // System implementation impl<'a> System<'a> for InputSystem { // Data type SystemData = ( Write<'a, InputQueue>, Write<'a, Gameplay>, Entities<'a>, WriteStorage<'a, Position>, ReadStorage<'a, Player>, ReadStorage<'a, Movable>, ReadStorage<'a, Immovable>, ); fn run(&mut self, data: Self::SystemData) { let (mut input_queue, mut gameplay, entities, mut positions, players, movables, immovables) = data; ... }
Так как мы уже сделали всё нужное для того, чтобы изменять позицию игрока в ответ на нажатие клавиш, мы можем использовать этот же код, чтобы понять, когда нужно инкрементировать счётчик шагов:
#![allow(unused)] fn main() { // input_system.rs ... // We've just moved, so let's increase the number of moves if to_move.len() > 0 { gameplay.moves_count += 1; } // Now actually move what needs to be moved for (key, id) in to_move { let position = positions.get_mut(entities.entity(id)); if let Some(position) = position { match key { KeyCode::Up => position.y -= 1, KeyCode::Down => position.y += 1, KeyCode::Left => position.x -= 1, KeyCode::Right => position.x += 1, _ => (), } } } } } }
Система игрового процесса
Теперь давайте внедрим этот ресурс в новую систему — GamePlayStateSystem
. Она будет непрерывно проверять, все ли коробки на нужных местах. Как только они окажутся там, где задумано, игра будет выиграна!
Помимо ресурса Gameplay
, этой системе нужен доступ к чтению содержимого Position
, Box
и BoxSpot
.
Система использует Join
, чтобы создать вектор из Box
и Position
. Этот вектор размечен как HashMap
и содержит позицию каждой коробки на игровом поле.
Дальше система снова использует метод Join
, чтобы создать последовательность из сущностей, у которых есть оба компонента: и BoxSpot
, и Position
. Система проходит по этой последовательности, и если у каждого места для коробки найдётся коробка с той же позицией — игра пройдена, игрок победил. Иначе она всё ещё идёт.
#![allow(unused)] fn main() { // gameplay_state_system.rs use specs::{Join, ReadStorage, System, Write}; use std::collections::HashMap; use crate::{ components::{Box, BoxSpot, Position}, resources::{Gameplay, GameplayState}, }; pub struct GameplayStateSystem {} impl<'a> System<'a> for GameplayStateSystem { // Data type SystemData = ( Write<'a, Gameplay>, ReadStorage<'a, Position>, ReadStorage<'a, Box>, ReadStorage<'a, BoxSpot>, ); fn run(&mut self, data: Self::SystemData) { let (mut gameplay_state, positions, boxes, box_spots) = data; // get all boxes indexed by position let boxes_by_position: HashMap<(u8, u8), &Box> = (&positions, &boxes) .join() .map(|t| ((t.0.x, t.0.y), t.1)) .collect::<HashMap<_, _>>(); // loop through all box spots and check if there is a corresponding // box at that position for (_box_spot, position) in (&box_spots, &positions).join() { if boxes_by_position.contains_key(&(position.x, position.y)) { // continue } else { gameplay_state.state = GameplayState::Playing; return; } } // If we made it this far, then all box spots have boxes on them, and the // game has been won gameplay_state.state = GameplayState::Won; } } }
Наконец, запустим нашу систему игрового процесса в главном цикле обновлений:
#![allow(unused)] fn main() { // main.rs impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, _context: &mut Context) -> GameResult { // Run input system { let mut is = InputSystem {}; is.run_now(&self.world); } // Run gameplay state system { let mut gss = GameplayStateSystem {}; gss.run_now(&self.world); } Ok(()) } // ... } }
Игровой интерфейс
Последний шаг заключается в создании обратной связи для пользователя, чтобы тот понимал, в каком состоянии находится сейчас игра. Для этого потребуется ресурс, который будет отслеживать её состояние, и система, которая будет это состояние обновлять. Под эту задачу мы можем адаптировать уже существующие GameplayState
и RenderingSystem
.
Для начала мы реализуем типаж Display
для GameplayState
, чтобы мы могли вывести состояние игры в виде текста. Мы будем использовать выражение соответствия, чтобы разрешить GameplayState
отрисовать текст "Играем" — "Playing" или "Победили" — "Won".
#![allow(unused)] fn main() { // resources.rs impl Display for GameplayState { fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { fmt.write_str(match self { GameplayState::Playing => "Playing", GameplayState::Won => "Won" })?; Ok(()) } } }
Затем мы добавим метод draw_text
в систему RenderingSystem
, чтобы она могла вывести GameplayState
на экран...
#![allow(unused)] fn main() { // rendering_systems.rs impl RenderingSystem<'_> { pub fn draw_text(&mut self, text_string: &str, x: f32, y: f32) { let text = graphics::Text::new(text_string); let destination = Vec2::new(x, y); let color = Some(Color::new(0.0, 0.0, 0.0, 1.0)); let dimensions = Vec2::new(0.0, 20.0); graphics::queue_text(self.context, &text, dimensions, color); graphics::draw_queued_text( self.context, graphics::DrawParam::new().dest(destination), None, graphics::FilterMode::Linear, ) .expect("expected drawing queued text"); } } }
...и после этого добавим ресурс Gameplay
в систему RenderingSystem
. Для того, чтобы мы могли вызвать draw_text
, RenderingSystem
должна иметь возможность читать ресурс Gameplay
.
#![allow(unused)] fn main() { // rendering_system.rs impl<'a> System<'a> for RenderingSystem<'a> { // Data type SystemData = (Read<'a, Gameplay>, ReadStorage<'a, Position>, ReadStorage<'a, Renderable>); fn run(&mut self, data: Self::SystemData) { let (gameplay, positions, renderables) = data; // Clearing the screen (this gives us the backround colour) graphics::clear(self.context, graphics::Color::new(0.95, 0.95, 0.95, 1.0)); // Get all the renderables with their positions and sort by the position z // This will allow us to have entities layered visually. let mut rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); rendering_data.sort_by_key(|&k| k.0.z); // Iterate through all pairs of positions & renderables, load the image // and draw it at the specified position. for (position, renderable) in rendering_data.iter() { // Load the image let image = Image::new(self.context, renderable.path.clone()).expect("expected image"); let x = position.x as f32 * TILE_WIDTH; let y = position.y as f32 * TILE_WIDTH; // draw let draw_params = DrawParam::new().dest(Vec2::new(x, y)); graphics::draw(self.context, &image, draw_params).expect("expected render"); } // Render any text self.draw_text(&gameplay.state.to_string(), 525.0, 80.0); self.draw_text(&gameplay.moves_count.to_string(), 525.0, 100.0); // Finally, present the context, this will actually display everything // on the screen. graphics::present(self.context).expect("expected to present"); } } }
Теперь в игре есть базовая обратная связь для игрока:
- Подсчёт количества шагов
- Сообщения о том, что игрок победил
Вот как она выглядит:
Впереди ещё много других улучшений!
КОД: Увидеть весь код из данной главы можно здесь.
Глава 3: Продвинутый геймплей
Добро пожаловать в главу 3 — мы рады, что вы зашли так далеко! В ней мы будем работать над более продвинутым игровым процессом!
Цветные коробки
Пришло время немного разнообразить нашу игру! Пока процесс всё ещё простоват: ставь себе коробки на правильные места. Давайте сделаем её чуть более увлекательной, добавив коробкам цвет. Мы начнём с красной и синей — но вы можете их раскрасить так, как вам будет угодно! Теперь, чтобы выиграть, вам нужно будет поставить коробку на место с тем же цветом.
Ресурсы
Для начала давайте добавим новые ресурсы. Сохраните эти картинки с помощью контекстного меню — или создайте свои!
Структура директорий должна выглядеть схожим образом (не забудьте о том, что мы удалили стандартные коробку и место для коробки)
├── resources
│ └── images
│ ├── box_blue.png
│ ├── box_red.png
│ ├── box_spot_blue.png
│ ├── box_spot_red.png
│ ├── floor.png
│ ├── player.png
│ └── wall.png
├── src
│ ├── systems
│ │ ├── gameplay_state_system.rs
│ │ ├── input_system.rs
│ │ ├── mod.rs
│ │ └── rendering_system.rs
│ ├── components.rs
│ ├── constants.rs
│ ├── entities.rs
│ ├── main.rs
│ ├── map.rs
│ └── resources.rs
├── Cargo.lock
├── Cargo.toml
Изменения компонентов
Теперь добавим перечисление для цветов (если двух вам окажется мало, то добавлять новые нужно будет именно здесь):
#![allow(unused)] fn main() { // components.rs pub enum BoxColour { Red, Blue, } }
После чего используем это перечисление и в коробке, и в месте для неё:
#![allow(unused)] fn main() { // components.rs #[derive(Component)] #[storage(VecStorage)] pub struct Box { pub colour: BoxColour, } #[derive(Component)] #[storage(VecStorage)] pub struct BoxSpot { pub colour: BoxColour, } }
Создание сущностей
Давайте добавим цвет как параметр при создании коробок и мест для них и убедимся в том, что мы назначаем правильный ресурс — согласно цвету в перечислении.
Чтобы не ошибиться, прописывая пути к ресурсам, нам нужны понятные имена. Для этого мы будем называть изображения в виде "/images/box_{}.png"
, где {}
— это цвет коробки, которую мы создаём. Трудность в том, что сейчас наши цвета хранятся в перечислении и компилятор не имеет понятия, как преобразовать BoxColour::Red
в строку "red"
. А ведь это было бы очень удобно — просто написать colour.to_string()
и получить нужный цвет. К счастью, у Rust есть отличный способ, которым мы можем это осуществить. Для этого нам будет нужно реализовать типаж Display
для перечисления BoxColour
. Вот как это должно выглядеть — мы просто определяем способ, которым нужно преобразовать элементы перечисления в строки:
#![allow(unused)] fn main() { // components.rs impl Display for BoxColour { fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { fmt.write_str(match self { BoxColour::Red => "red", BoxColour::Blue => "blue", })?; Ok(()) } } }
Теперь добавим перечисление colour
в код создания сущностей и воспользуемся великолепным colour.to_string()
, который мы только что сделали.
#![allow(unused)] fn main() { // entities.rs pub fn create_box(world: &mut World, position: Position, colour: BoxColour) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable { path: format!("/images/box_{}.png", colour), }) .with(Box { colour }) .with(Movable) .build(); } pub fn create_box_spot(world: &mut World, position: Position, colour: BoxColour) { world .create_entity() .with(Position { z: 9, ..position }) .with(Renderable { path: format!("/images/box_spot_{}.png", colour), }) .with(BoxSpot { colour }) .build(); } }
Карта
Теперь немного изменим код генерации карты, чтобы добавить цветные коробки и места для них:
- "BB" — синяя коробка (blue box)
- "RB" — красная коробка (red box)
- "BS" — синее место (blue spot)
- "RS" — красное место (red spot)
#![allow(unused)] fn main() { // map.rs use crate::components::{BoxColour, Position}; use crate::entities::*; use specs::World; pub fn load_map(world: &mut World, map_string: String) { // read all lines let rows: Vec<&str> = map_string.trim().split('\n').map(|x| x.trim()).collect(); for (y, row) in rows.iter().enumerate() { let columns: Vec<&str> = row.split(' ').collect(); for (x, column) in columns.iter().enumerate() { // Create the position at which to create something on the map let position = Position { x: x as u8, y: y as u8, z: 0, // we will get the z from the factory functions }; // Figure out what object we should create match *column { "." => create_floor(world, position), "W" => { create_floor(world, position); create_wall(world, position); } "P" => { create_floor(world, position); create_player(world, position); } "BB" => { create_floor(world, position); create_box(world, position, BoxColour::Blue); } "RB" => { create_floor(world, position); create_box(world, position, BoxColour::Red); } "BS" => { create_floor(world, position); create_box_spot(world, position, BoxColour::Blue); } "RS" => { create_floor(world, position); create_box_spot(world, position, BoxColour::Red); } "N" => (), c => panic!("unrecognized map item {}", c), } } } } }
И обновим её код в main
.
#![allow(unused)] fn main() { // main.rs // Initialize the level pub fn initialize_level(world: &mut World) { const MAP: &str = " N N W W W W W W W W W . . . . W W . . . BB . . W W . . RB . . . W W . P . . . . W W . . . . RS . W W . . BS . . . W W . . . . . . W W W W W W W W W "; load_map(world, MAP.to_string()); } }
Игровой процесс
Теперь, когда самая тяжёлая часть позади, мы можем проверить работу нашего кода. Вы увидите, что почти всё работает — за исключением одного досадного бага. Уровень считается пройденным даже если вы ставите красную коробку на синее место — и наоборот. Давайте это исправим.
Прежде мы выяснили, что данные хранятся в компонентах, а поведение свойственно системам — таковы принципы ECS. Сейчас нам нужно реализовать какое-то поведение, а потому логично предположить, что это будет система. Помните, как мы писали систему для проверки того, выиграл игрок или нет? Так вот — мы снова возвращаемся к ней.
Немного изменим функцию запуска, чтобы проверять совпадение цвета коробки и места для неё:
#![allow(unused)] fn main() { // gameplay_state_system.rs fn run(&mut self, data: Self::SystemData) { let (mut gameplay_state, positions, boxes, box_spots) = data; // get all boxes indexed by position let boxes_by_position: HashMap<(u8, u8), &Box> = (&positions, &boxes) .join() .map(|t| ((t.0.x, t.0.y), t.1)) .collect::<HashMap<_, _>>(); // loop through all box spots and check if there is a corresponding // box at that position. since we now have different types of boxes // we need to make sure the right type of box is on the right // type of spot. for (box_spot, position) in (&box_spots, &positions).join() { if let Some(the_box) = boxes_by_position.get(&(position.x, position.y)) { if the_box.colour == box_spot.colour { // continue } else { // return, haven't won yet return; } } else { gameplay_state.state = GameplayState::Playing; return; } } // If we made it this far, then all box spots have boxes on them, and the // game has been won gameplay_state.state = GameplayState::Won; } } }
Теперь, если вы скомпилируете этот код, он должен ругаться на то, что мы сравниваем два перечисления с помощью оператора ==
. По умолчанию Rust не знает, как это делать, так что мы должны его научить. И лучший способ — реализовать сравнение с помощью типажа PartialEq
.
#![allow(unused)] fn main() { // components.rs #[derive(PartialEq)] pub enum BoxColour { Red, Blue, } }
Самое время обсудить аннотации derive
. Мы уже использовали их ранее, но не погружались в то, для чего они на самом деле нужны. Атрибуты derive
можно применить к структурам или перечислениям — они позволят добавить стандартные реализации типажей к нашим типам. Например, здесь мы сообщаем Rust, что хотим добавить PartialEq
к перечислению BoxColour
.
Так выглядит стандартная реализация сравнения в PartialEq
. Она просто проверяет что-то на равенство самому себе. Если это так, то сравнение успешно, иначе — нет. Не переживайте, если это не особенно внесло ясность.
#![allow(unused)] fn main() { pub trait PartialEq { fn eq(&self, other: &Self) -> bool; fn ne(&self, other: &Self) -> bool { !self.eq(other) }; } }
С помощью строчки #[derive(PartialEq)]
мы поставили Rust в известность, что BoxColour
теперь реализует этот типаж. И это значит, что если мы попытаемся выполнить box_colour_1 == box_colour_2
, Rust будет использовать именно эту реализацию, которая просто проверяет, являются ли colour_1
и colour_2
одним и тем же объектом. Это не самое изящное сравнение на свете, но к нашему случаю оно вполне подойдёт.
ЕЩЁ: Узнать больше о
PartialEq
можно здесь, а о наследуемых типажах — здесь.
Теперь мы можем скомпилировать наш код и вкусить плоды тяжёлых трудов: игра работает и поздравляет с победой только в том случае, если мы её действительно заслужили!
КОД: Увидеть весь код из данной главы можно здесь.
Анимация
В этом разделе мы попробуем добавить анимации в нашу игру. Мы начнём с пары простых — но не стесняйтесь экспериментировать с более сложными. В этом вам помогут идеи из этого туториала. Мы добавим две анимации: моргания игрока и небольшого дрожания коробок на месте.
Что такое анимация?
Анимация — это набор кадров, проигрываемых через определённые интервалы. Именно это и создаёт иллюзию движения. Почти как видео (видео — тоже просто последовательность изображений), только с гораздо более низкой частотой кадров.
Например, чтобы заставить игрока моргать, нам нужно три кадра в анимации:
- игрок с открытыми глазами,
- игрок с прикрытыми глазами,
- игрок с закрытыми глазами.
Если мы проиграем эти три кадра один за другим, то заметим, что персонаж действительно будто моргает. Можете попробовать это прямо сейчас: откройте картинки и начните быстро между ними переключаться.
Но есть пара загвоздок:
- Наборы анимаций должны создаваться с учётом задуманной частоты кадров — в нашем случае это будет 250 миллисекунд, то есть нам нужно 4 кадра в секунду.
- Анимации должны сочетаться друг с другом. Представьте, что у нас есть два игрока с разным цветом глаз. Мы должны убедиться, что упомянутые выше три кадра из разных наборов сочетаются друг с другом, иначе наши игроки будут моргать с разной частотой.
- Разработка анимации с большим количеством кадров — тяжёлая работа, поэтому мы будем использовать только ключевые кадры, чтобы сделать её простой.
Как это будет работать?
Так как это будет работать в нашей игре? Нам нужно:
- Переписать отрисовываемый компонент, чтобы использовать множество кадров. Мы можем создать новый компонент, который поддерживает отрисовку анимаций, и оставить тот, что уже есть, для статичных изображений, но пока объединим эти две логики в одном.
- Изменить содержимое сущности игрока, чтобы иметь возможность обрабатывать множество кадров.
- Добавить контроль времени в наш цикл отрисовки — позже мы обсудим это в деталях, поэтому не волнуйтесь, если что-то непонятно.
- Заставить нашу систему отрисовки учитывать общее число кадров и время, а также кадр, который должен в нужное время быть отрисован.
Наборы
Добавим новые наборы анимаций для игрока. Они должны выглядеть так. Обратите внимание, что мы создали конвенцию именований для последовательности кадров. Это не обязательно, однако в дальнейшем облегчит задачу контроля последовательности кадров.
├── resources
│ └── images
│ ├── box_blue.png
│ ├── box_red.png
│ ├── box_spot_blue.png
│ ├── box_spot_red.png
│ ├── floor.png
│ ├── player_1.png
│ ├── player_2.png
│ ├── player_3.png
│ └── wall.png
Отрисовка
Теперь обновим наш компонент для отрисовки, чтобы он использовал несколько кадров. Вместо одного-единственного пути у нас будет список путей — это не должно вызвать трудностей.
Также давайте добавим две новые функции, чтобы создать два типа отрисовываемых объектов: с одним путём и с несколькими. Эти две функции — ассоциированные функции, потому что они ассоциированы со структурой Renderable
. Они являются эквивалентом статических функций в других языках, потому что не оперируют экземплярами объектов. Вы заметили, что эти функции не используют &self
или &mut self
в качестве первого аргумента? Это значит, что мы можем вызвать их прямо из контекста структуры, а не из её экземпляра. Они также похожи на функции-фабрики, потому что скрывают логику и требуют валидации перед тем, как создать объект.
ЕЩЁ: Узнать больше про ассоциированные функции вы можете здесь.
#![allow(unused)] fn main() { // components.rs #[derive(Component)] #[storage(VecStorage)] pub struct Renderable { paths: Vec<String>, } impl Renderable { pub fn new_static(path: String) -> Self { Self { paths: vec![path] } } pub fn new_animated(paths: Vec<String>) -> Self { Self { paths } } } }
Далее, нам нужен способ, чтобы определять, анимированный это объект или статичный. Мы можем оставить переменные путей публичными и позволить системе отрисовки подсчитывать длину пути — и, основываясь на нем, делать какие-то выводы. Но есть более правильный способ. Мы можем создать перечисление для типов отрисовки и добавить метод получения этого типа в объект. Таким образом мы скрываем логику типа отрисовки в объекте отрисовки и можем держать переменные путей приватными. Вы можете добавить эти строчки куда угодно в components.rs
, но лучше это сделать следом за объявлением renderable
.
#![allow(unused)] fn main() { // components.rs pub enum RenderableKind { Static, Animated, } }
Теперь давайте добавим функцию, которая будет определять вид отрисовываемого объекта, основываясь на внутреннем пути.
#![allow(unused)] fn main() { // components.rs impl Renderable { pub fn new_static(path: String) -> Self { Self { paths: vec![path] } } pub fn new_animated(paths: Vec<String>) -> Self { Self { paths } } pub fn kind(&self) -> RenderableKind { match self.paths.len() { 0 => panic!("invalid renderable"), 1 => RenderableKind::Static, _ => RenderableKind::Animated, } } } }
И наконец, поскольку мы сделали переменные путей приватными, нам нужно разрешить пользователям отрисовываемого объекта получать конкретный путь из нашего списка. Для статичных объектов это будет нулевой путь (единственный), а для анимированных мы позволим нашей системе отрисовки решать, изображение какого пути должно быть отрисовано в конкретный момент времени. Хитрость в том, что если будет запрошен путь, индекс которого больше, чем размер нашего списка, мы просто воспользуемся этим размером, чтобы получить индекс, который не выходит за границы.
#![allow(unused)] fn main() { // components.rs impl Renderable { //... pub fn path(&self, path_index: usize) -> String { // If we get asked for a path that is larger than the // number of paths we actually have, we simply mod the index // with the length to get an index that is in range. self.paths[path_index % self.paths.len()].clone() } } }
Создание сущностей
Теперь внесём изменения в процесс создания сущности игрока, чтобы использовать несколько путей. Отметим, что мы используем функцию new_animated
, чтобы построить отрисовываемый объект.
#![allow(unused)] fn main() { // entities.rs pub fn create_player(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable::new_animated(vec![ "/images/player_1.png".to_string(), "/images/player_2.png".to_string(), "/images/player_3.png".to_string(), ])) .with(Player {}) .with(Movable) .build(); } }
После этого обновим всё остальное, чтобы использовать функцию new_static
— вот как мы делаем это для создания объекта стены. Не стесняйтесь поступить так же с другим статическими сущностями.
#![allow(unused)] fn main() { // entities.rs pub fn create_wall(world: &mut World, position: Position) { world .create_entity() .with(Position { z: 10, ..position }) .with(Renderable::new_static("/images/wall.png".to_string())) .with(Wall {}) .with(Immovable) .build(); } }
Время
Ещё один компонент, который нам понадобится, — это отслеживание времени. При чем здесь время и как оно влияет на частоту кадров? Главная идея вот в чем: ggez
контролирует частоту вызовов системы отрисовки, а это зависит от частоты кадров — которая, в свою очередь, зависит от того, как много работы мы выполняем в каждой итерации цикла игры. Это нам контролировать не под силу, и в секунду у нас может выйти 60 итераций, или 57, или даже 30. Это значит, что у нас не получится использовать для нашей анимации частоту кадров — вместо этого нам нужно сделать её зависимой от времени.
Именно поэтому нам нужно отслеживать изменение времени, или дельту — то, сколько времени прошло между предыдущей итерацией и последующей. И поскольку дельта времени гораздо меньше, чем интервал кадра анимации (который мы выбрали равным 250 мс), нам нужно сделать дельту накапливаемой, или кумулятивной — мы должны знать, сколько времени прошло с момента запуска игры.
ЕЩЁ: Узнать больше о дельте времени, частоте кадров и цикле игры можно здесь, здесь или здесь.
Пришло время добавить ресурс для времени. Время не вписывается в нашу компонентную модель, потому что это просто глобальное состояние, которое нужно сохранить.
#![allow(unused)] fn main() { // resources.rs #[derive(Default)] pub struct Time { pub delta: Duration, } }
И не забудьте его зарегистрировать.
#![allow(unused)] fn main() { // resources.rs pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()); world.insert(Gameplay::default()); world.insert(Time::default()); } }
А теперь обновим это время в главном цикле игры. К счастью, ggez
предоставляет функцию получения дельты времени, так что всё, что нам нужно делать, — просто накапливать её.
#![allow(unused)] fn main() { // main.rs impl event::EventHandler<ggez::GameError> for Game { fn update(&mut self, context: &mut Context) -> GameResult { // Run input system { let mut is = InputSystem {}; is.run_now(&self.world); } // Run gameplay state system { let mut gss = GameplayStateSystem {}; gss.run_now(&self.world); } // Get and update time resource { let mut time = self.world.write_resource::<Time>(); time.delta += timer::delta(context); } Ok(()) } }
Система отрисовки
Теперь изменим систему отрисовки. Мы будем получать тип из отрисовываемого объекта. Если он статичен, то мы просто используем первый кадр — иначе мы определяем, какой кадр нужен, основываясь на дельте времени.
Сначала добавим функцию, чтобы скрыть логику получения нужного изображения.
#![allow(unused)] fn main() { // rendering_system.rs impl RenderingSystem<'_> { //... pub fn get_image(&mut self, renderable: &Renderable, delta: Duration) -> Image { let path_index = match renderable.kind() { RenderableKind::Static => { // We only have one image, so we just return that 0 } RenderableKind::Animated => { // If we have multiple, we want to select the right one based on the delta time. // First we get the delta in milliseconds, we % by 1000 to get the milliseconds // only and finally we divide by 250 to get a number between 0 and 4. If it's 4 // we technically are on the next iteration of the loop (or on 0), but we will let // the renderable handle this logic of wrapping frames. ((delta.as_millis() % 1000) / 250) as usize } }; let image_path = renderable.path(path_index); Image::new(self.context, image_path).expect("expected image") } } }
И затем используем новую функцию get_image
внутри функции run
(нам также нужно добавить время в определение SystemData
и в несколько импортов, но на этом — всё).
#![allow(unused)] fn main() { // rendering_system.rs impl<'a> System<'a> for RenderingSystem<'a> { // Data type SystemData = ( Read<'a, Gameplay>, Read<'a, Time>, ReadStorage<'a, Position>, ReadStorage<'a, Renderable>, ); fn run(&mut self, data: Self::SystemData) { let (gameplay, time, positions, renderables) = data; // Clearing the screen (this gives us the backround colour) graphics::clear(self.context, graphics::Color::new(0.95, 0.95, 0.95, 1.0)); // Get all the renderables with their positions and sort by the position z // This will allow us to have entities layered visually. let mut rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); rendering_data.sort_by_key(|&k| k.0.z); // Iterate through all pairs of positions & renderables, load the image // and draw it at the specified position. for (position, renderable) in rendering_data.iter() { // Load the image let image = self.get_image(renderable, time.delta); //... } //... } } }
Анимация коробки
Теперь, когда мы узнали, как это сделать, применим тот же метод и для анимации коробок. Всё, что нам нужно, — это добавить новые наборы и поправить создание сущностей. После этого всё должно работать как надо. Вот набор, который использовала я, — не бойтесь переделать его на свой вкус!
Подведём итоги
Это была большая глава, но я надеюсь, что вы не заскучали! Сейчас наша игра должна выглядеть так:
КОД: Увидеть весь код из данной главы можно здесь.
Звуки и события
В этой главе мы добавим звуковые эффекты. Если кратко — мы хотим проигрывать звуки при следующих условиях:
- Когда игрок ударяется о стену или препятствие — чтобы дать понять, что невозможно пройти.
- Когда игрок помещает коробку на правильное место — чтобы сказать "ты всё правильно сделал".
- Если игрок поставил коробку не на своё место — чтобы оповестить, что ход был неправильным.
На самом деле проигрывание аудио не является сильно сложной штукой — ggez
позволяет работать с ним. На данном этапе большей проблемой будет правильное определение того, когда проигрывать звуки.
Давайте разберём на примере, когда коробка на правильном месте. Мы можем использовать нашу игровую систему состояний, чтобы пройтись по местам и коробкам — и проиграть звук, когда коробка находится там, где нужно. Но это не сработает, так как мы будем проходить по местам и коробкам много раз в секунду — и останемся в этом состоянии до тех пор, пока коробка не сдвинется. Из-за этого мы будем пытаться проиграть аудио несколько раз в секунду, а это явно не то, чего мы хотим. Мы можем попытаться сохранить какое-то состояние, чтобы знать, что мы проигрываем сейчас, но это неверный подход. Проблема в том, что мы просто не сможем сделать это обычной последовательной проверкой. Вместо этого нам нужна реактивная модель, которая позволит нам вовремя узнавать, что что-то только что произошло и нам необходимо отреагировать. То, что я здесь описала, — это модель событий: нам нужно запустить событие, когда коробка будет на нужном месте, и затем, когда мы получим это событие, проиграть аудио со своей стороны. Главное преимущество такого подхода в том, что мы можем переиспользовать систему событий для разных целей.
Инфраструктура событий: Как
Давайте поговорим о том, как мы можем реализовать события. Мы не будем использовать компоненты или сущности (хотя и могли бы). Вместо этого мы используем ресурс, очень похожий на входную очередь. Часть кода, необходимая для добавления событий в очередь, должна иметь доступ к этому ресурсу — и тогда мы получим систему, обрабатывающую события и выполняющую соответствующие действия.
Что является событием
Давайте более детально разберём, какие события нам необходимы:
- Игрок встретил препятствие. Это может быть событие самого игрока, которое создаётся из системы ввода, когда мы пытаемся переместиться, но не можем этого сделать.
- Коробка установлена на правильное или неправильное место. Мы можем представить это как одно событие с внутренним свойством, которое сообщит, корректна ли комбинация коробки и места. Если мы задумаемся глубже о том, как это сделать, у нас появится идея о событии перемещения сущности. Когда мы получим это событие, мы сможем проверить идентификатор сущности, которая только что сдвинулась, чтобы понять, коробка ли это и на каком месте она стоит: правильном, неправильном или любом другом. Это пример создания цепочки событий — событие из события.
Типы событий
Давайте перейдём к реализации событий. Мы будем использовать перечисление для определения различных типов событий. Мы уже использовали перечисления ранее (для указания типа отрисовки и цвета коробки), но сейчас мы используем всю силу перечислений Rust. Одной из самых интересных особенностей перечислений является то, что мы можем прикрепить свойства к каждому варианту перечисления.
Давайте взглянем на наше перечисление с событиями.
#![allow(unused)] fn main() { // events.rs #[derive(Debug)] pub enum Event { // Fired when the player hits an obstacle like a wall PlayerHitObstacle, // Fired when an entity is moved EntityMoved(EntityMoved), // Fired when the box is placed on a spot BoxPlacedOnSpot(BoxPlacedOnSpot), } }
Обратите внимание на находящиеся в скобках EntityMoved
и BoxPlacedOnSpot
. На самом деле это структуры, в которых находятся свойства. Давайте взглянем на них.
#![allow(unused)] fn main() { // events.rs pub type EntityId = u32; #[derive(Debug)] pub struct EntityMoved { pub id: EntityId, } #[derive(Debug)] pub struct BoxPlacedOnSpot { pub is_correct_spot: bool, } }
Ресурс очереди событий
Теперь добавим ресурс для очереди событий. У нас будут разные системы записи в эту очередь и одна система (система событий) для её потребления. По сути, это модель с несколькими производителями и одним потребителем.
#![allow(unused)] fn main() { // resources.rs #[derive(Default)] pub struct EventQueue { pub events: Vec<Event>, } }
И — как и всегда — зарегистрируем этот ресурс.
#![allow(unused)] fn main() { // resources.rs pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()); world.insert(Gameplay::default()); world.insert(Time::default()); world.insert(EventQueue::default()); } }
Отправка событий
Теперь, когда у нас есть способ постановки событий в очередь, давайте добавим два события в систему ввода: EntityMoved
и PlayerHitObstacle
.
#![allow(unused)] fn main() { // input_system.rs use crate::components::*; use crate::constants::*; use crate::events::{EntityMoved, Event}; use crate::resources::{EventQueue, Gameplay, InputQueue}; use ggez::event::KeyCode; use specs::{world::Index, Entities, Join, ReadStorage, System, Write, WriteStorage}; use std::collections::HashMap; pub struct InputSystem {} // System implementation impl<'a> System<'a> for InputSystem { // Data type SystemData = ( Write<'a, EventQueue>, Write<'a, InputQueue>, Write<'a, Gameplay>, Entities<'a>, WriteStorage<'a, Position>, ReadStorage<'a, Player>, ReadStorage<'a, Movable>, ReadStorage<'a, Immovable>, ); fn run(&mut self, data: Self::SystemData) { let ( mut events, mut input_queue, mut gameplay, entities, mut positions, players, movables, immovables, ) = data; let mut to_move = Vec::new(); for (position, _player) in (&positions, &players).join() { // Get the first key pressed if let Some(key) = input_queue.keys_pressed.pop() { // ... // ... // if it exists, we need to stop and not move anything // if it doesn't exist, we stop because we found a gap match immov.get(&pos) { Some(_id) => { to_move.clear(); events.events.push(Event::PlayerHitObstacle {}) } None => break, } } } } } } // We've just moved, so let's increase the number of moves if to_move.len() > 0 { gameplay.moves_count += 1; } // Now actually move what needs to be moved for (key, id) in to_move { let position = positions.get_mut(entities.entity(id)); if let Some(position) = position { match key { KeyCode::Up => position.y -= 1, KeyCode::Down => position.y += 1, KeyCode::Left => position.x -= 1, KeyCode::Right => position.x += 1, _ => (), } } // Fire an event for the entity that just moved events.events.push(Event::EntityMoved(EntityMoved { id })); } } } }
Для читаемости я опустила часть кода, но на самом деле мы просто добавили две строки в нужное место.
Потребление событий
Пришло время добавить способ потребления событий, который и станет системой событий. Эта система будет содержать логику того, что должно произойти при получении события.
Поговорим о том, как мы будем их обрабатывать:
Event::PlayerHitObstacle
-> это то место, где будет воспроизводиться звук. Мы вернёмся сюда когда будем добавлять аудио.Event::EntityMoved(EntityMoved { id })
-> здесь мы напишем логику проверки того, является ли передвигаемая сущность коробкой и помещена ли она на своё место.Event::BoxPlacedOnSpot(BoxPlacedOnSpot { is_correct_spot })
-> здесь тоже будет воспроизводиться звук, и мы также вернёмся сюда позже.
#![allow(unused)] fn main() { // event_system.rs use crate::{ audio::AudioStore, components::*, events::{BoxPlacedOnSpot, EntityMoved, Event}, resources::EventQueue, }; use specs::{Entities, Join, ReadStorage, System, Write}; use std::collections::HashMap; pub struct EventSystem<'a> { pub context: &'a mut ggez::Context, } // System implementation impl<'a> System<'a> for EventSystem<'a> { // Data type SystemData = ( Write<'a, EventQueue>, Write<'a, AudioStore>, Entities<'a>, ReadStorage<'a, Box>, ReadStorage<'a, BoxSpot>, ReadStorage<'a, Position>, ); fn run(&mut self, data: Self::SystemData) { let (mut event_queue, mut audio_store, entities, boxes, box_spots, positions) = data; let mut new_events = Vec::new(); for event in event_queue.events.drain(..) { println!("New event: {:?}", event); match event { // play sound here audio_store.play_sound(self.context, &"wall".to_string()); } Event::EntityMoved(EntityMoved { id }) => { // An entity was just moved, check if it was a box and fire // more events if it's been moved on a spot. if let Some(the_box) = boxes.get(entities.entity(id)) { let box_spots_with_positions: HashMap<(u8, u8), &BoxSpot> = (&box_spots, &positions) .join() .map(|t| ((t.1.x, t.1.y), t.0)) .collect::<HashMap<_, _>>(); if let Some(box_position) = positions.get(entities.entity(id)) { // Check if there is a spot on this position, and if there // is if it's the correct or incorrect type if let Some(box_spot) = box_spots_with_positions.get(&(box_position.x, box_position.y)) { new_events.push(Event::BoxPlacedOnSpot(BoxPlacedOnSpot { is_correct_spot: (box_spot.colour == the_box.colour), })); } } } } Event::BoxPlacedOnSpot(BoxPlacedOnSpot { is_correct_spot }) => { // play sound here } } } event_queue.events.append(&mut new_events); } } }
Аудиоресурсы
Теперь, когда у нас есть события, добавим звуковые ресурсы. Я взяла 3 аудио из этого набора, но вы можете выбрать свои.
Звук корректной постановки коробки
Добавим эти аудио в новую поддиректорию директории resources.
.
├── resources
│ ├── images
│ │ ├── box_blue_1.png
│ │ ├── box_blue_2.png
│ │ ├── box_red_1.png
│ │ ├── box_red_2.png
│ │ ├── box_spot_blue.png
│ │ ├── box_spot_red.png
│ │ ├── floor.png
│ │ ├── player_1.png
│ │ ├── player_2.png
│ │ ├── player_3.png
│ │ └── wall.png
│ └── sounds
│ ├── correct.wav
│ ├── incorrect.wav
│ └── wall.wav
├── Cargo.lock
└── Cargo.toml
Аудиохранилище
Теперь, чтобы проиграть аудио, необходимо загрузить wav-файлы. Чтобы исключить загрузку каждый раз, когда мы хотим их проиграть, мы создадим аудиохранилище и подгрузим его при запуске игры.
Мы будем использовать ресурс для аудиохранилища.
#![allow(unused)] fn main() { // audio.rs #[derive(Default)] pub struct AudioStore { pub sounds: HashMap<String, audio::Source>, } }
Зарегистрируем его.
#![allow(unused)] fn main() { // resources.rs pub fn register_resources(world: &mut World) { world.insert(InputQueue::default()); world.insert(Gameplay::default()); world.insert(Time::default()); world.insert(EventQueue::default()); world.insert(AudioStore::default()); } }
И добавим код инициализации.
#![allow(unused)] fn main() { // audio.rs pub fn initialize_sounds(world: &mut World, context: &mut Context) { let mut audio_store = world.write_resource::<AudioStore>(); let sounds = ["correct", "incorrect", "wall"]; for sound in sounds.iter() { let sound_name = sound.to_string(); let sound_path = format!("/sounds/{}.wav", sound_name); let sound_source = audio::Source::new(context, sound_path).expect("expected sound loaded"); audio_store.sounds.insert(sound_name, sound_source); } } }
Проигрывание аудио
Наконец, добавим возможность проигрывания аудио из хранилища.
#![allow(unused)] fn main() { // audio.rs impl AudioStore { pub fn play_sound(&mut self, context: &mut Context, sound: &String) { let _ = self .sounds .get_mut(sound) .expect("expected sound") .play_detached(context); } } }
И проиграем их с помощью системы событий.
#![allow(unused)] fn main() { // event_system.rs ); fn run(&mut self, data: Self::SystemData) { let (mut event_queue, mut audio_store, entities, boxes, box_spots, positions) = data; let mut new_events = Vec::new(); for event in event_queue.events.drain(..) { println!("New event: {:?}", event); match event { Event::PlayerHitObstacle => { // play sound here audio_store.play_sound(self.context, &"wall".to_string()); // ... } Event::BoxPlacedOnSpot(BoxPlacedOnSpot { is_correct_spot }) => { // play sound here let sound = if is_correct_spot { "correct" } else { "incorrect" }; audio_store.play_sound(self.context, &sound.to_string()) } } } }
Теперь давайте запустим игру и насладимся звуковыми эффектами!
КОД: Увидеть весь код из данной главы можно здесь.
Пакетная отрисовка
Во время игры вы могли поймать ощущение, что ввод тормозит. Предлагаю добавить FPS-счётчик, чтобы следить за скоростью отрисовки. FPS (Frames per second) — это количество кадров в секунду. Мы будем ориентироваться на 60 FPS.
Счётчик FPS
Начнём с добавления счётчика FPS. Эта задача состоит из двух частей:
- получение или вычисление значения FPS,
- отображение этого значения на экране.
Для первой ggez
предоставляет метод получения FPS. Для второй в нашей системе отрисовки уже есть способ работы с текстом, так что нам нужно просто вывести количество кадров в секунду. Объединим эти знания в следующем коде:
#![allow(unused)] fn main() { // rendering_system.rs fn run(&mut self, data: Self::SystemData) { ... // Render any text self.draw_text(&gameplay.state.to_string(), 525.0, 80.0); self.draw_text(&gameplay.moves_count.to_string(), 525.0, 100.0); let fps = format!("FPS: {:.0}", timer::fps(self.context)); self.draw_text(&fps, 525.0, 120.0); ... } }
Запустите игру и немного подвигайтесь туда-сюда. Вы увидите, что FPS значительно отличается от ожидаемых 60. У меня это что-то в районе 20-30, но это зависит от мощности вашей машины.
Что вызывает падение частоты кадров?
Вы можете задаться вопросом, что же такого мы натворили, чтобы игра работала так медленно? У нас довольно простая игра — и логика передвижения и обработки событий клавиш совсем не такая и сложная. Кроме того, у нас нет большого количества сущностей и компонентов, чтобы оправдать значительное падение частоты кадров. Чтобы его объяснить, нам нужно немного углубиться в принцип работы нашей системы отрисовки.
Сейчас перед тем, как отрисовать сущность, мы должны понять, какое изображение ей соответствует. Это значит, что если нам, например, нужно отрисовать 20 плиток пола, то мы загрузим 20 картинок пола и сделаем 20 вызовов отрисовки. Это очень дорогостоящий процесс — и именно он вызывает падение частоты кадров.
Как это можно исправить? Мы воспользуемся магией пакетной отрисовки. Используя эту технику, мы загрузим изображение только один раз и сообщим ggez
, что нам нужно отрисовать его на двадцати необходимых позициях. Это значительно ускорит процесс. В качестве примечания: некоторые движки делают это самостоятельно, без вашего вмешательства — но не 'ggez'. Здесь нам нужно позаботиться обо всём вручную.
Пакетная отрисовка
Вот что нам нужно сделать, чтобы реализовать пакетную отрисовку:
- Для каждой отрисовываемой сущности мы должны понять, какое изображение нам нужно отрисовать и какой параметр
DrawParam
нужно использовать. Дляggez
DrawParam
будет служить индикатором места отрисовки. - После чего мы должны сохранить пару
(image, DrawParams)
в удобный формат. - Для каждого изображения пройдём по
(image, DrawParams)
, отсортированным по z, и сделаем по одному вызову отрисовки
Прежде чем мы погрузимся в код отрисовки, мы должны сгруппировать и отсортировать нашу коллекцию. Для этого мы будем использовать крейт Itertools
. Мы могли бы реализовать группировку самостоятельно, но нет никакой причины заново изобретать велосипед. Добавим Itertools
как зависимость в наш проект.
// Cargo.toml
[dependencies]
ggez = "0.7"
glam = { version = "0.20.0", features = ["mint"] }
specs = { version = "0.17.0", features = ["specs-derive"] }
А также импортируем его в нашу систему отрисовки.
#![allow(unused)] fn main() { // rendering_system.rs use itertools::Itertools; }
Помните функцию get_image
, которую мы написали в главе "Анимация", чтобы понять, какое изображение нужно для каждого кадра? Мы сможем использовать её снова — нужно только убедиться, что мы не загружаем изображение, а просто возвращаем его путь.
#![allow(unused)] fn main() { // rendering_system.rs pub fn get_image(&mut self, renderable: &Renderable, delta: Duration) -> String { let path_index = match renderable.kind() { RenderableKind::Static => { // We only have one image, so we just return that 0 } RenderableKind::Animated => { // If we have multiple, we want to select the right one based on the delta time. // First we get the delta in milliseconds, we % by 1000 to get the milliseconds // only and finally we divide by 250 to get a number between 0 and 4. If it's 4 // we technically are on the next iteration of the loop (or on 0), but we will let // the renderable handle this logic of wrapping frames. ((delta.as_millis() % 1000) / 250) as usize } }; renderable.path(path_index) } }
Теперь определим формат, в котором мы будем хранить наши пакетные данные. Мы будем использовать HashMap<u8, HashMap<String, Vec<DrawParam>>>
, где:
- Первый ключ (
u8
) — это позиция z. Помните о том, что мы должны следить за ней и отрисовывать объекты начиная с самого большого z до самого маленького, чтобы соблюдать правильный порядок (например, чтобы полы были позади игрока). - Первое значение — это ещё одна
HashMap
, где второй ключ (String
) — это путь к изображению. - Наконец, второе значение — это
Vec<DrawParam>
, где хранятся все параметры, с которыми мы должны отрисовать это изображение.
Теперь напишем код, чтобы заполнить нашу rendering_batches
.
#![allow(unused)] fn main() { // rendering_system.rs fn run(&mut self, data: Self::SystemData) { ... // Get all the renderables with their positions. let rendering_data = (&positions, &renderables).join().collect::<Vec<_>>(); let mut rendering_batches: HashMap<u8, HashMap<String, Vec<DrawParam>>> = HashMap::new(); // Iterate each of the renderables, determine which image path should be rendered // at which drawparams, and then add that to the rendering_batches. for (position, renderable) in rendering_data.iter() { // Load the image let image_path = self.get_image(renderable, time.delta); let x = position.x as f32 * TILE_WIDTH; let y = position.y as f32 * TILE_WIDTH; let z = position.z; // Add to rendering batches let draw_param = DrawParam::new().dest(Vec2::new(x, y)); rendering_batches .entry(z) .or_default() .entry(image_path) .or_default() .push(draw_param); } ... } }
И наконец, отрисуем пакеты. Мы больше не можем использовать функцию draw(image)
, которой мы пользовались раньше, но, к счастью, у ggez
есть пакетный API — SpriteBatch. Также отметьте себе строчку sorted_by
— это функция из Itertools
.
#![allow(unused)] fn main() { // rendering_system.rs fn run(&mut self, data: Self::SystemData) { ... // Iterate spritebatches ordered by z and actually render each of them for (_z, group) in rendering_batches .iter() .sorted_by(|a, b| Ord::cmp(&a.0, &b.0)) { for (image_path, draw_params) in group { let image = Image::new(self.context, image_path).expect("expected image"); let mut sprite_batch = SpriteBatch::new(image); for draw_param in draw_params.iter() { sprite_batch.add(*draw_param); } graphics::draw(self.context, &sprite_batch, graphics::DrawParam::new()) .expect("expected render"); } } ... } }
Ну вот и всё! Снова запустите игру — и вы увидите новенькие и блестящие 60 FPS! Теперь всё должно работать гораздо плавнее.
КОД: Увидеть весь код из данной главы можно здесь.