Игровой процесс

Игрок может двигаться сам и перемещает коробки по игровому полю. У множества (хотя и не у всех!) игр есть какая-то цель — и цель игр, похожих на 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");
    }
}
}

Теперь в игре есть базовая обратная связь для игрока:

  • Подсчёт количества шагов
  • Сообщения о том, что игрок победил

Вот как она выглядит:

Sokoban play

Впереди ещё много других улучшений!

КОД: Увидеть весь код из данной главы можно здесь.