Игровой процесс
Игрок может двигаться сам и перемещает коробки по игровому полю. У множества (хотя и не у всех!) игр есть какая-то цель — и цель игр, похожих на 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"); } } }
Теперь в игре есть базовая обратная связь для игрока:
- Подсчёт количества шагов
- Сообщения о том, что игрок победил
Вот как она выглядит:
Впереди ещё много других улучшений!
КОД: Увидеть весь код из данной главы можно здесь.