Я снова надкусил сладкое отравленное яблоко разработки игр. Rust набирает обороты среди разработчиков игр, и пришло время присоединиться к толпе. Мы сделаем небольшую игру на Rust, чтобы увидеть, заслужена ли шумиха.

Игра, которую мы собираемся сделать, представляет собой пошаговую 2D-стратегию. Я хотел начать с чего-то более классического, но подумал, что люди устали от типичных стартовых игр. В стратегических играх есть некоторые сложные моменты, но они упрощают другие области, так что, надеюсь, они сбалансируются.

Это будет серия статей. В этой первой статье мы настроим проект и создадим поле боя. Мы собираемся использовать движок Bevy. В будущих сериях мы можем изучить другие игровые движки или создать свой собственный с парой библиотек.

ПРИМЕЧАНИЕ. Для этого проекта я использую Linux (Ubuntu 22.10). Команды терминала, которые я включил, должны работать для вас, пока вы используете bash. Они также должны работать на других терминалах и в MacOS. Возможно, я обновлю эту серию статей, включив в нее Windows в будущем, хотя вы могли бы следить за WSL2 (я не пробовал запускать там приложения с графическим интерфейсом, так что терпите меня). В любом случае, команды довольно простые, поэтому у пользователей Windows не должно возникнуть много проблем.

Разработка игр на Rust: создание стратегической игры (Часть 1 — Поле битвы)

Наша пошаговая 2D-стратегия будет довольно простой. Это текущий список функций, которые я планирую включить:

  • Имеется статическое поле боя. Было бы круто изучить процедурную генерацию позже, но ничего не обещаю.
  • Есть разные единицы. Каждый юнит имеет различные характеристики (дальность, атака, защита, здоровье и т. д.).
  • Есть звуковые эффекты. Но музыки, наверное, нет (если только я не найду подходящий трек, который люди не будут ненавидеть через 5 минут).
  • Игра поддерживает 2 игроков.
  • Имеется простой пользовательский интерфейс, достаточный для понимания текущего состояния игры.

Как упоминалось ранее, мы будем использовать Bevy.

Почему Беви?

Короче говоря, потому что:

  • В настоящее время Bevy является самым активным игровым движком Rust и имеет самое большое сообщество. Он стал де-факто игровым движком в Rust.
  • Bevy — это движок на основе ECS. Парадигмы программирования похожи на мороженое: всем нравятся разные вкусы. Но я думаю, что интересно использовать неклассические движки, которые обеспечивают менее типичный способ работы и мышления. Это одна из вещей, которая заставляет нас расти как программистов.
  • Декларативный характер Bevy очень хорошо сочетается с моделью владения Rust. Помните, что Rust ближе к языкам машинного обучения, чем к C++, несмотря на то, что он находится где-то посередине.

Предостережение: Bevy в настоящее время развивается. Новые минорные версии 0.x содержат критические изменения. Мы будем использовать версию 0.10 специально, чтобы гарантировать запуск проекта через несколько месяцев.

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

Я создам папку в репозитории со всеми главами этой серии. Вместо этого я мог бы использовать ветки, но это усложняет процесс переноса улучшений. Вы можете найти эту главу в разделе 01-battlefield/strategy-game-rs.

Если вы хотите продолжить, я рекомендую иметь одну папку.

В терминале создайте новый проект Rust с именем strategy-game-rs и cd в нем:

cargo init strategy-game-rs

cd strategy-game-rs

Мы будем использовать Bevy версии 0.10. Bevy все еще находится на ранней стадии. Обратная совместимость не гарантируется. Поэтому мы должны защитить нашу игру от взлома, объявив хотя бы дополнительную версию.

cargo add [email protected]

Просто чтобы проверить, что все настроено правильно, мы можем запустить проект:

cargo run

Мы должны увидеть сообщение Hello, world!.

При настройке вышеуказанного я столкнулся с несколькими проблемами в Linux.

Если у вас возникли проблемы с alsa, установите отсутствующую библиотеку:

sudo apt install libasound2-dev

Если у вас возникли проблемы с libudev, установите отсутствующую библиотеку:

sudo apt install libudev-dev

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

Я не проверял на Windows или MacOS. Если эта серия наберет обороты, я попробую пройти хотя бы на Windows.

Создание окна с помощью Bevy

Первый маленький шаг к нашей еще не отмеченной наградами стратегической игре — создать окно с Беви.

Отредактируйте src/main.rs и замените существующий код следующим:

use bevy::prelude::*;

fn main() {
    App::new().run();
}

Код выше, похоже, ничего не делает. Это ожидаемо. Приложения Bevy ничего не отображают по умолчанию; даже не окно.

Попробуем посмотреть хотя бы консольный лог. Создайте функцию hello_bevy, которая печатает сообщение, и добавьте ее как system после new():

use bevy::prelude::*;

fn hello_bevy() {
    println!("Hello Bevy!");
}

fn main() {
    App::new().add_system(hello_bevy).run();
}

Если мы запустим эту программу, мы должны увидеть распечатку Hello Bevy!.

Поздравляем, мы написали нашу первую игру на Bevy!

Верно?

Читатели: «…»

Технически, да. С точки зрения разработчика игр, вероятно, нет. «Где наша графика?!» — слышу крики толпы.

Давайте покажем что-нибудь на экране для нашего удовольствия.

Bevy ничего не отображает по умолчанию, действуя как бессерверное приложение. Bevy — это декларативный движок, который позволяет включать функции с помощью плагинов.

Рендеринг также объявляется через плагины. Не буду вдаваться в подробности, но DefaultPlugins — это удобная подборка плагинов, общих для большинства игр. Среди прочего, он содержит окно, цикл событий и плагины ввода, которые создают и запускают для нас игровой цикл.

Чтобы показать наше окно, нам просто нужно добавить DefaultPlugins:

use bevy::prelude::*;

fn hello_bevy() {
    println!("Hello Bevy!");
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_system(hello_bevy)
        .run();
}

Если мы запустим нашу программу, Bevy создаст пустое окно и будет спамить терминал Hello Bevy!, пока мы его не закроем.

Достижение разблокировано, мы создали наше первое игровое окно!

Быстрое знакомство с Беви

Вот личное задание: объясните минимальные основы Bevy, имеющие отношение к этой серии статей, максимально кратко, как я могу:

  • Я не буду беспокоить вас информацией, не относящейся к этому проекту.
  • Вы можете продолжить, если вы никогда не работали с движком на основе ECS.

Не стесняйтесь пропустить этот раздел, если вы уже знакомы с Bevy или, по крайней мере, с движками на основе ECS. Вы также можете обратиться к официальному руководству, если хотите посмотреть подробнее. Я мог бы просто предоставить ссылку и положить этому конец, но было бы несправедливо не дать быстрого объяснения.

ЭКС

Сущности – это идентификатор, представляющий… сущность в игре. У них есть список компонентов, которые определяют сущность.

Компоненты — это данные, связанные с сущностью.

#[derive(Component)]
struct Stats { hp: i32, };

Ресурсы – это данные, не связанные с сущностью. Они действуют как синглтоны.

#[derive(Resource)]
struct Clock(Timer);

Системы — это функции, связанные с набором компонентов и ресурсов. Это простые функции, которые принимают запросы компонентов, ресурсы и команды в качестве параметров.

fn some_system(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    stats_query: Query<&mut Stats>,
) {
    ...
}

Миры содержат объекты, компоненты и ресурсы и предоставляют операции для их извлечения.

Команды

Команды не являются частью обычных ECS, но используются в Rust для управления сущностями, компонентами и ресурсами. Они могут создавать, добавлять, извлекать или удалять их.

Плагины

Подключаемые модули – это способ группировки систем и ресурсов в единое целое.

impl Plugin for MagicPlugin {
    fn build(&self, app: &mut App) {
        app.add_system(fireball_system)
            .add_system(iceball_system)
            .add_system(lightning_system);
    }
}

состояния

Состояния помогают реализовать различные состояния приложения (на каком экране мы находимся, какой игровой режим активен и т. д.).

enum GameState {
    Running,
    Paused,
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_state(AppState::Running);
}

События

События обеспечивают межсистемное взаимодействие. Они используют внутренние системы EventWriter и EventReader для отправки/получения данных.

struct HiEvent(String);

fn say_hi_system(mut hi: EventWriter<HiEvent>) {
    hi.send(HiEvent("What a nice day today!"));
}

fn print_hi_system(mut hi: EventReader<HiEvent>) {
    for event in hi.iter() {
        println!("Received Hi {:?}", event.0);
    }
}

Создание поля битвы

Мы собираемся создать поле битвы, где прольется вся кровь и слезы.

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

Наше поле боя будет выглядеть так:

Простите мне отсутствие художественных навыков, но я надеюсь, что это сработает.

Поскольку я не великий художник и не хочу тратить время на создание ужасно выглядящих ресурсов, я использую ресурсы Fantasy Battle Pack Мэтта Уокдена. Он выглядит великолепно, и вы можете скачать его бесплатно (на момент написания статьи). У него разрешительная лицензия, поэтому мы можем использовать его для собственного удовольствия. Я использую версию 26-10-22.

Набор тайлов, который мы собираемся использовать, расположен в Tiles/FullTileset.png. Это выглядит так:

Вы можете использовать другой пакет активов, если хотите. Концепции создания поля боя точно такие же. Было несколько хороших кандидатов, но в конечном итоге я выбрал пакет ресурсов, который хорошо выглядит и имеет несколько листов спрайтов вместо одного массивного.

ПРИМЕЧАНИЕ. Всю свою жизнь я пытался найти бесплатный пакет ресурсов без листов Sprite. Если вы не знаете, что такое листы спрайтов, то они представляют собой набор спрайтов, которые обычно связаны друг с другом. Например, разные спрайты для анимации персонажа. Я хотел их избежать, потому что так проще загружать отдельные файлы спрайтов, но у меня не получилось. Нам придется использовать листы спрайтов, которые в любом случае являются отраслевым стандартом, так что это не плохо, чтобы погрузиться в них, даже если это добавляет немного больше сложности.

Прежде чем мы начнем писать какой-либо код, нам нужно загрузить и извлечь ресурсы в папку assets в корне нашего проекта. Идем дальше и создаем папку assets в корне проекта (на том же уровне, что и src/). Затем извлеките пакет ресурсов в новую папку. Мы должны увидеть что-то вроде этого:

strategy-game-rs/
├── assets/
│ ├── Effects/
│ ├── License and Information.rtf
│ ├── Sprite Sheets/
│ ├── Tiles/
│ └── UI Elements/
├── Cargo.lock
├── Cargo.toml
├── src/
│ └── main.rs

Прежде чем приступить к работе на поле боя, мы можем почистить нашу программу, удалив функцию hello_bevy. Убедитесь, что src/main.rs имеет только следующее:

use bevy::prelude::*;

fn main() {
    App::new().add_plugins(DefaultPlugins).run();
}

Для создания поля боя нам понадобится:

  • Сетка, в которой мы будем хранить информацию.
  • Визуальное представление сетки.

Разделение логики и механизмов доставки обычно является хорошей практикой при разработке программного обеспечения. В этом случае механизм доставки является визуальным представлением сетки. У нас позже появятся другие механизмы доставки (например, звуковые эффекты, срабатывающие при определенных событиях).

Мы собираемся создать систему, которая будет создавать поле боя во время запуска. Я назвал это create_battlefield_system:

fn create_battlefield_system() {
    println!("creating battlefield");
}

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_startup_system(create_battlefield_system)
        .run();
}

ПРИМЕЧАНИЕ. Некоторые люди используют имя board вместо battlefield или grid, поэтому используйте то, которое вам больше нравится.

create_battlefield_system запустится один раз при запуске игры. Мы не хотим создавать поле боя дважды. Затем Bevy сделает свое волшебство, чтобы визуализировать его вместе с остальной графикой. В Bevy нет такой вещи, как функция render.

Рисование одной плитки

Теперь мы собираемся создать настоящее поле битвы. Мы собираемся начать с загрузки одного тайла, чтобы получить представление о том, как Bevy обрабатывает листы спрайтов.

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

  • ::default() полезен для создания экземпляра по умолчанию для структуры.
  • ..default() — это способ распространения значений по умолчанию, которые мы не хотим указывать при создании экземпляра структуры.

Наш create_battlefield_system будет принимать несколько параметров. Нам понадобится:

  • Команды для создания таких объектов, как камера или наборы листов спрайтов.
  • Asset Server для загрузки ресурсов.
  • Атласы текстур для преобразования листов спрайтов во что-то, чем мы можем удобно манипулировать с помощью кода.

Мы собираемся изменить нашу подпись create_battlefield_system, включив в нее несколько новых параметров:

fn create_battlefield_system(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut texture_atlases: ResMut<Assets<TextureAtlas>>,
) {
    ...
}

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

fn create_battlefield_system(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut texture_atlases: ResMut<Assets<TextureAtlas>>,
) {
    let tiles_handle = asset_server.load("Tiles/FullTileset.png");
}

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

fn create_battlefield_system(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut texture_atlases: ResMut<Assets<TextureAtlas>>,
) {
    let tiles_handle = asset_server.load("Tiles/FullTileset.png");
    let tiles_atlas =
        TextureAtlas::from_grid(tiles_handle, Vec2::new(16.0, 16.0), 20, 20, None, None);
}

Более подробно вы можете обратиться к документации, но мы лишь уточним:

  • Изображение: дескриптор плитки.
  • Размер плитки: параметр Vec2.
  • Количество столбцов и строк в листе спрайтов.

Наконец, нам нужно добавить этот атлас в список текстурных атласов:

fn create_battlefield_system(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut texture_atlases: ResMut<Assets<TextureAtlas>>,
) {
    let tiles_handle = asset_server.load("Tiles/FullTileset.png");
    let tiles_atlas =
        TextureAtlas::from_grid(tiles_handle, Vec2::new(16.0, 16.0), 20, 20, None, None);
    let tiles_atlas_handle = texture_atlases.add(tiles_atlas);
}

Метод add возвращает дескриптор, который мы можем использовать для ссылки на атлас.

Теперь, когда мы загрузили плитки, мы хотим отобразить одну из них. Bevy предоставляет несколько структур для этого:

  • Camera2dBundle: Это 2D-камера Беви.
  • SpriteSheetBundle: Тут немного сложнее. SpriteSheetBundle использует атлас текстур, спрайт и трансформацию.

Мы хотим отображать только одну плитку, поэтому добавим следующий код в create_battlefield_system:

fn create_battlefield_system(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut texture_atlases: ResMut<Assets<TextureAtlas>>,
) {
    let tiles_handle = asset_server.load("Tiles/FullTileset.png");
    let tiles_atlas =
        TextureAtlas::from_grid(tiles_handle, Vec2::new(16.0, 16.0), 20, 20, None, None);
    let tiles_atlas_handle = texture_atlases.add(tiles_atlas);
    
    commands.spawn(Camera2dBundle::default());

    commands.spawn(SpriteSheetBundle {
        texture_atlas: tiles_atlas_handle,
        sprite: TextureAtlasSprite::new(3),
        transform: Transform::from_scale(Vec3::splat(4.0)),
        ..default()
    });
}

Здесь мы визуализируем 4-й тайл (индекс 3) в атласе текстур и устанавливаем масштаб 4x, чтобы он не выглядел слишком маленьким. Нас не интересуют другие параметры, поэтому мы используем ..default(), чтобы позволить им принимать значения по умолчанию.

Если все прошло хорошо, мы должны увидеть что-то вроде этого:

Вот исходный код.

Рисование сетки 2x2

Мы собираемся визуализировать сетку из тайлов 2x2. Это следующий маленький шаг к рендерингу поля битвы нашей мечты. Мы хотим получить представление о том, как перебирать многомерную сетку, даже если она небольшая. Окончательная сетка будет работать так же, только с большим количеством данных.

Прежде всего, мы собираемся сделать рефакторинг. Мы можем извлечь несколько констант, чтобы избежать магических чисел:

fn create_battlefield_system(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut texture_atlases: ResMut<Assets<TextureAtlas>>,
) {
    const NUM_COLUMNS: usize = 20;
    const NUM_ROWS: usize = 20;
    const TILE_SIZE: f32 = 16.0;

    let tiles_handle = asset_server.load("Tiles/FullTileset.png");
    let tiles_atlas = TextureAtlas::from_grid(
        tiles_handle,
        Vec2::new(TILE_SIZE, TILE_SIZE),
        NUM_COLUMNS,
        NUM_ROWS,
        None,
        None,
    );
    
    ...
}

Мы также можем извлечь масштаб как константу:

fn create_battlefield_system(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut texture_atlases: ResMut<Assets<TextureAtlas>>,
) {
    ...
    
    const SCALE: f32 = 4.0;
    commands.spawn(SpriteSheetBundle {
        texture_atlas: tiles_atlas_handle,
        sprite: TextureAtlasSprite::new(3),
        transform: Transform::from_scale(Vec3::splat(SCALE)),
        ..default()
    });
}

Позже нам может понадобиться переместить новые константы в другую область видимости. На данный момент они могут оставаться локальными для create_battlefield_system.

С очищенной базой мы можем начать работать с нашей сеткой 2x2. Нам понадобится несколько новых понятий:

  • Типы плиток: мы будем отображать несколько разных плиток, чтобы наше поле боя не выглядело слишком скучно.
  • Tilemap: нам нужно определить положение тайлов на нашем 2D-поле боя.

На данный момент нам нужны только 4 разных плитки. Я выбрал первые 4 плитки в первом ряду листа спрайтов плиток.

Чтобы поддерживать типы тайлов, нам нужно отслеживать индекс в атласе текстур, который мы создали ранее. Есть несколько способов сделать это. Я создал перечисление TileType и структуру Tile, которые помогают выполнять преобразование между типом плитки и индексом:

use bevy::prelude::*;

enum TileType {
    Brown1,
    Brown2,
    Brown3,
    Brown4,
}

struct Tile {
    index: usize,
}

impl Tile {
    fn from_type(tile_type: TileType) -> Tile {
        match tile_type {
            TileType::Brown1 => Tile { index: 0 },
            TileType::Brown2 => Tile { index: 1 },
            TileType::Brown3 => Tile { index: 2 },
            TileType::Brown4 => Tile { index: 3 },
        }
    }
}

Мы реализовали структуру Tile с индексом, который можно создать из TileType. Сопоставление с образцом помогает нам не пропустить обработку любого типа плитки. index — это фактический индекс в листе спрайтов (20, 20). Наши коричневые плитки начинаются с (0, 0) и идут вверх до (0, 3) в первой строке.

Следующее, что нужно сделать, это отрендерить поле битвы внутри create_battlefield_system:

fn create_battlefield_system(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut texture_atlases: ResMut<Assets<TextureAtlas>>,
) {
    ...
    
    const SCALE: f32 = 4.0;
    let tile_map: [[Tile; 2]; 2] = [
        [
            Tile::from_type(TileType::Brown1),
            Tile::from_type(TileType::Brown3),
        ],
        [
            Tile::from_type(TileType::Brown2),
            Tile::from_type(TileType::Brown4),
        ],
    ];

    for (y, row) in tile_map.iter().enumerate() {
        for (x, col) in row.iter().enumerate() {
            commands.spawn(SpriteSheetBundle {
                texture_atlas: tiles_atlas_handle.clone(),
                sprite: TextureAtlasSprite::new(col.index),
                transform: Transform {
                    translation: Vec3 {
                        x: x as f32 * TILE_SIZE * SCALE,
                        y: y as f32 * TILE_SIZE * SCALE,
                        z: 0.0,
                    },
                    scale: Vec3::splat(SCALE),
                    ..default()
                },
                ..default()
            });
        }
    }
}

Мы определили tile_map как массив 2x2, содержащий по одной плитке каждого типа.

Затем мы используем итераторы для прохождения каждого измерения с помощью iter().enumerate(), получая как индекс (y или x), так и итератор (row, col). Мы создаем по одному SpriteSheetBundle для каждой плитки в позиции (x, y):

  • texture_atlas нужен дескриптор atlas, но он не может заимствовать его несколько раз, поэтому, если мы попытаемся использовать его как есть, компилятор Rust будет жаловаться. Самое простое решение — clone, чтобы на каждой итерации использовалась новая копия. Это не должно быть проблемой производительности, поскольку мы создаем поле боя только один раз.
  • sprite теперь получает индекс во внутреннем итераторе, col.index.
  • transform использует (x, y), которые являются фактическими пикселями тайла на листе спрайтов, и умножает его на размер тайла и масштаб.

Это должно быть результатом:

Еще одна вещь, которую я сделал, это переместил commands.spawn(Camera2dBundle::default()) вверху create_battlefield_system. Я предпочитаю, чтобы связанный код был вместе, когда это возможно.

Это окончательный код на данный момент и diff для этого раздела.

Рисование финального поля битвы

Теперь, когда мы знаем, как создать сетку 2x2, последнее, что нужно сделать, — это создать настоящее поле битвы. Поскольку мы нашли способ рендеринга 2D-сетки, это проще простого. Нам просто нужно добавить несколько плиток. Я выделил ниже те, которые мы собираемся использовать.

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

  • Цвет плитки.
  • Сочетание цветов и позиций.
  • Индекс столбца, начиная с 1.
  • Я пропустил зеленую плитку в коричнево-зеленом среднем ряду в столбце 5, так как она дублируется.
enum TileType {
    Brown1,
    Brown2,
    Brown3,
    Brown4,
    Green1,
    Green2,
    Green3,
    Green4,
    BrownGreenUpper1,
    BrownGreenUpper2,
    BrownGreenUpper3,
    BrownGreenUpper5,
    BrownGreenUpper7,
    BrownGreenMiddle1,
    BrownGreenMiddle3,
    BrownGreenMiddle4,
    BrownGreenMiddle6,
    BrownGreenLower1,
    BrownGreenLower2,
    BrownGreenLower3,
    BrownGreenLower5,
    BrownGreenLower7,
}

Нам нужно рассчитать индекс для каждой из новых плиток. Мы следуем типичной формуле y * NUM_COLUMNS + x.

impl Tile {
    fn from_type(tile_type: TileType) -> Tile {
        match tile_type {
            TileType::Brown1 => Tile { index: 0 },
            TileType::Brown2 => Tile { index: 1 },
            TileType::Brown3 => Tile { index: 2 },
            TileType::Brown4 => Tile { index: 3 },
            TileType::Green1 => Tile {
                index: 2 * NUM_COLUMNS,
            },
            TileType::Green2 => Tile {
                index: 2 * NUM_COLUMNS + 1,
            },
            TileType::Green3 => Tile {
                index: 2 * NUM_COLUMNS + 2,
            },
            TileType::Green4 => Tile {
                index: 2 * NUM_COLUMNS + 3,
            },
            TileType::BrownGreenUpper1 => Tile {
                index: 7 * NUM_COLUMNS,
            },
            TileType::BrownGreenUpper2 => Tile {
                index: 7 * NUM_COLUMNS + 1,
            },
            TileType::BrownGreenUpper3 => Tile {
                index: 7 * NUM_COLUMNS + 2,
            },
            TileType::BrownGreenUpper5 => Tile {
                index: 7 * NUM_COLUMNS + 4,
            },
            TileType::BrownGreenUpper7 => Tile {
                index: 7 * NUM_COLUMNS + 6,
            },
            TileType::BrownGreenMiddle1 => Tile {
                index: 8 * NUM_COLUMNS,
            },
            TileType::BrownGreenMiddle3 => Tile {
                index: 8 * NUM_COLUMNS + 2,
            },
            TileType::BrownGreenMiddle4 => Tile {
                index: 8 * NUM_COLUMNS + 3,
            },
            TileType::BrownGreenMiddle6 => Tile {
                index: 8 * NUM_COLUMNS + 5,
            },
            TileType::BrownGreenLower1 => Tile {
                index: 9 * NUM_COLUMNS,
            },
            TileType::BrownGreenLower2 => Tile {
                index: 9 * NUM_COLUMNS + 1,
            },
            TileType::BrownGreenLower3 => Tile {
                index: 9 * NUM_COLUMNS + 2,
            },
            TileType::BrownGreenLower5 => Tile {
                index: 9 * NUM_COLUMNS + 4,
            },
            TileType::BrownGreenLower7 => Tile {
                index: 9 * NUM_COLUMNS + 6,
            },
        }
    }
}

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

const NUM_COLUMNS: usize = 20;
const NUM_ROWS: usize = 20;

Оставшееся изменение заключается в том, чтобы поиграть с типами плиток и заполнить сетку 13x6. Я потратил довольно много времени (больше, чем я надеялся), придумывая прилично выглядящее поле битвы (по крайней мере, для моего художественного уровня). Тип плитки значения не имеет, так как все они шлифованные и не имеют специальной обработки.

Вот что я придумал, что соответствует изображению в начале этого раздела:

const SCALE: f32 = 4.0;
    const BATTLEFIELD_WIDTH_IN_TILES: usize = 13;
    const BATTLEFIELD_HEIGHT_IN_TILES: usize = 6;
    let tile_map: [[Tile; BATTLEFIELD_WIDTH_IN_TILES]; BATTLEFIELD_HEIGHT_IN_TILES] = [
        [
            Tile::from_type(TileType::Brown1),
            Tile::from_type(TileType::BrownGreenLower1),
            Tile::from_type(TileType::BrownGreenLower2),
            Tile::from_type(TileType::BrownGreenLower2),
            Tile::from_type(TileType::BrownGreenLower3),
            Tile::from_type(TileType::BrownGreenLower5),
            Tile::from_type(TileType::Brown2),
            Tile::from_type(TileType::BrownGreenLower1),
            Tile::from_type(TileType::BrownGreenLower3),
            Tile::from_type(TileType::BrownGreenLower5),
            Tile::from_type(TileType::BrownGreenLower1),
            Tile::from_type(TileType::BrownGreenLower3),
            Tile::from_type(TileType::Brown4),
        ],
        [
            Tile::from_type(TileType::Brown3),
            Tile::from_type(TileType::BrownGreenMiddle1),
            Tile::from_type(TileType::Green2),
            Tile::from_type(TileType::BrownGreenUpper2),
            Tile::from_type(TileType::Green1),
            Tile::from_type(TileType::Green3),
            Tile::from_type(TileType::BrownGreenLower2),
            Tile::from_type(TileType::Green3),
            Tile::from_type(TileType::Green2),
            Tile::from_type(TileType::Green1),
            Tile::from_type(TileType::BrownGreenMiddle3),
            Tile::from_type(TileType::BrownGreenMiddle1),
            Tile::from_type(TileType::BrownGreenLower3),
        ],
        [
            Tile::from_type(TileType::BrownGreenMiddle4),
            Tile::from_type(TileType::Green3),
            Tile::from_type(TileType::BrownGreenMiddle3),
            Tile::from_type(TileType::BrownGreenLower5),
            Tile::from_type(TileType::BrownGreenMiddle1),
            Tile::from_type(TileType::Green3),
            Tile::from_type(TileType::Green4),
            Tile::from_type(TileType::Green1),
            Tile::from_type(TileType::Green2),
            Tile::from_type(TileType::Green3),
            Tile::from_type(TileType::Green1),
            Tile::from_type(TileType::Green2),
            Tile::from_type(TileType::BrownGreenUpper3),
        ],
        [
            Tile::from_type(TileType::BrownGreenMiddle4),
            Tile::from_type(TileType::Green1),
            Tile::from_type(TileType::Green3),
            Tile::from_type(TileType::Green4),
            Tile::from_type(TileType::Green2),
            Tile::from_type(TileType::Green1),
            Tile::from_type(TileType::Green1),
            Tile::from_type(TileType::BrownGreenMiddle3),
            Tile::from_type(TileType::BrownGreenUpper7),
            Tile::from_type(TileType::BrownGreenUpper1),
            Tile::from_type(TileType::Green1),
            Tile::from_type(TileType::Green3),
            Tile::from_type(TileType::BrownGreenMiddle6),
        ],
        [
            Tile::from_type(TileType::Brown2),
            Tile::from_type(TileType::BrownGreenUpper1),
            Tile::from_type(TileType::Green4),
            Tile::from_type(TileType::Green1),
            Tile::from_type(TileType::Green3),
            Tile::from_type(TileType::Green2),
            Tile::from_type(TileType::BrownGreenUpper2),
            Tile::from_type(TileType::Green1),
            Tile::from_type(TileType::Green3),
            Tile::from_type(TileType::BrownGreenLower2),
            Tile::from_type(TileType::Green4),
            Tile::from_type(TileType::Green2),
            Tile::from_type(TileType::BrownGreenMiddle6),
        ],
        [
            Tile::from_type(TileType::Brown1),
            Tile::from_type(TileType::Brown4),
            Tile::from_type(TileType::BrownGreenUpper5),
            Tile::from_type(TileType::BrownGreenUpper1),
            Tile::from_type(TileType::BrownGreenUpper3),
            Tile::from_type(TileType::BrownGreenUpper1),
            Tile::from_type(TileType::BrownGreenLower7),
            Tile::from_type(TileType::BrownGreenUpper3),
            Tile::from_type(TileType::BrownGreenUpper5),
            Tile::from_type(TileType::BrownGreenUpper1),
            Tile::from_type(TileType::BrownGreenUpper2),
            Tile::from_type(TileType::BrownGreenUpper3),
            Tile::from_type(TileType::Brown2),
        ],
    ];

Мы также можем объявить две новые константы BATTLEFIELD_WIDTH_IN_TILES и BATTLEFIELD_HEIGHT_IN_TILES с соответствующими значениями прямо перед tile_map.

Я не буду вставлять окончательный код, потому что он длинноват, но вы можете найти его здесь с diff.

Если вы запустите проект, то увидите, что он рендерит поле боя, но выглядит не совсем правильно:

Есть несколько проблем:

  • Поле боя не центрировано.
  • Плитки выглядят размытыми и между ними есть кровотечение.
  • Окно слишком велико для того, что нам действительно нужно.

Давайте все исправим!

Улучшение поля боя

Прямо сейчас наше поле боя не помещается даже в окно. Это далеко от идеала.

Давайте сосредоточим поле боя. Существуют разные подходы. Мы могли бы:

  1. Переместите камеру в нижний левый угол (в настоящее время координаты камеры установлены в центре окна) или:
  2. Отцентрируйте поле боя и оставьте камеру как есть, или:
  3. Увеличить окно: если вы попробуете изменить размер и сделать окно шире, вы увидите все поле битвы.

Какой подход выбрать, зависит от вкуса и контекста. Некоторые игровые движки и фреймворки (например, Пико-8) идут по варианту 1. Вариант 3 также предполагает манипулирование размером окна. Проблема с этими подходами заключается в том, что мы должны установить стандартный фиксированный размер и не разрешать изменение размера, поскольку это не сильно улучшает наш игровой процесс.

Вариант 2 для нас является более чистым подходом, даже если он требует немного больше математики. В качестве плюса, если мы позже решим увеличить поле битвы, нам не нужно ничего менять, кроме размеров поля битвы, чтобы оно выглядело хорошо (по центру).

Чтобы центрировать поле боя, нам нужно переместить его половину ширины влево и половину высоты вниз. Что-то вроде этого в псевдокоде:

x - battlefield_width / 2 + tile_size / 2
y - battlefield_height / 2 + tile_size / 2

Обратите внимание, что нам нужно добавить половину размера плитки обратно. В противном случае он будет выглядеть немного не так.

Сделаем это на Rust. Нам нужно только изменить вектор translation на следующее:

    const HALF_TILE_SIZE: f32 = TILE_SIZE / 2.0;
    const HALF_BATTLEFIELD_WIDTH_IN_PIXELS: f32 =
        BATTLEFIELD_WIDTH_IN_TILES as f32 * TILE_SIZE / 2.0;
    const HALF_BATTLEFIELD_HEIGHT_IN_PIXELS: f32 =
        BATTLEFIELD_HEIGHT_IN_TILES as f32 * TILE_SIZE / 2.0;
    const WIDTH_CENTER_OFFSET: f32 = HALF_BATTLEFIELD_WIDTH_IN_PIXELS - HALF_TILE_SIZE;
    const HEIGHT_CENTER_OFFSET: f32 = HALF_BATTLEFIELD_HEIGHT_IN_PIXELS - HALF_TILE_SIZE;

    for (y, row) in tile_map.iter().enumerate() {
        for (x, col) in row.iter().enumerate() {
            commands.spawn(SpriteSheetBundle {
                texture_atlas: tiles_atlas_handle.clone(),
                sprite: TextureAtlasSprite::new(col.index),
                transform: Transform {
                    translation: Vec3 {
                        x: SCALE * (x as f32 * TILE_SIZE - WIDTH_CENTER_OFFSET),
                        y: SCALE * (y as f32 * TILE_SIZE - HEIGHT_CENTER_OFFSET),
                        z: 0.0,
                    },
                    scale: Vec3::splat(SCALE),
                    ..default()
                },
                ..default()
            });
        }
    }

Извлечение констант для расчетов необязательно. Мы могли бы просто встроить их, так как это не сильно повлияет на производительность. Но я предпочитаю давать им имена, чтобы было легче понять, что происходит.

Обратите внимание, что мы умножаем все на SCALE, чтобы все было на месте. Мы изменим это в ближайшее время, так как это не идеально.

Теперь наше поле битвы должно быть в центре экрана.

Плитки все еще выглядят так, как будто у них есть «кровотечение». Это потому, что мы масштабируем спрайты, а Bevy рендерит их с помощью алгоритма, который пытается их сгладить. Нам нужно сказать Bevy использовать алгоритм nearest и отключить сглаживание. Мы делаем это, добавляя к экземпляру App следующее:

  • Новый ресурс Msaa::Off для удаления псевдонимов.
  • ImagePlugin, использующий выборку nearest.
fn main() {
    App::new()
        .insert_resource(Msaa::Off)
        .add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
        .add_startup_system(create_battlefield_system)
        .run();
}

Это также должно исправить размытые спрайты.

Если мы запустим игру, мы должны увидеть что-то вроде этого:

ПРИМЕЧАНИЕ. Мы могли бы использовать плагин, который, вероятно, делает работу лучше, чем мое решение, но я не хотел вводить дополнительный ящик только для этой настройки.

Улучшение окна

Окно слишком большое. Я не уверен, насколько большим он будет позже, когда мы добавим больше функциональности. Возможно, нам придется уменьшить масштаб спрайтов, чтобы освободить место для пользовательского интерфейса. Но пока нам не нужно столько места. Мы можем установить довольно стандартное разрешение 960x540 с помощью WindowPlugin:

fn main() {
    App::new()
        .insert_resource(Msaa::Off)
        .add_plugins(
            DefaultPlugins
                .set(WindowPlugin {
                    primary_window: Some(Window {
                        resolution: WindowResolution::new(960.0, 540.0),
                        ..default()
                    }),
                    ..default()
                })
                .set(ImagePlugin::default_nearest()),
        )
        .add_startup_system(create_battlefield_system)
        .run();
}

Теперь окно выглядит меньше по сравнению с полем боя:

Мы также должны добавить правильный заголовок. Мы можем сделать это, добавив атрибут title:

fn main() {
    App::new()
        .insert_resource(Msaa::Off)
        .add_plugins(
            DefaultPlugins
                .set(WindowPlugin {
                    primary_window: Some(Window {
                        title: "Strategy Game in Rust".to_string(),
                        resolution: WindowResolution::new(960.0, 540.0),
                        ..default()
                    }),
                    ..default()
                })
                .set(ImagePlugin::default_nearest()),
        )
        .add_startup_system(create_battlefield_system)
        .run();
}

Последнее, что нужно сделать, это настроить масштаб один раз, а не везде. Мы можем сделать это, настроив разрешение окна:

fn main() {
    App::new()
        .insert_resource(Msaa::Off)
        .add_plugins(
            DefaultPlugins
                .set(WindowPlugin {
                    primary_window: Some(Window {
                        title: "Strategy Game in Rust".to_string(),
                        resolution: WindowResolution::new(960.0, 540.0)
                            .with_scale_factor_override(4.0),
                        ..default()
                    }),
                    ..default()
                })
                .set(ImagePlugin::default_nearest()),
        )
        .add_startup_system(create_battlefield_system)
        .run();
}

Мы можем либо переместить константу SCALE в main, либо встроить ее, так как она используется только один раз, а with_scale_factor_override читается достаточно хорошо, чтобы понять, что представляет собой магическое число (не все магические числа являются злом).

Не забудьте добавить window::WindowResolution вверху файла:

use bevy::{prelude::*, window::WindowResolution};

Также нам потребуется убрать масштаб на листе спрайтов transform:

for (y, row) in tile_map.iter().enumerate() {
        for (x, col) in row.iter().enumerate() {
            commands.spawn(SpriteSheetBundle {
                texture_atlas: tiles_atlas_handle.clone(),
                sprite: TextureAtlasSprite::new(col.index),
                transform: Transform {
                    translation: Vec3 {
                        x: x as f32 * TILE_SIZE - WIDTH_CENTER_OFFSET,
                        y: y as f32 * TILE_SIZE - HEIGHT_CENTER_OFFSET,
                        z: 0.0,
                    },
                    ..default()
                },
                ..default()
            });
        }
    }

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

Вот окончательный код и diff для этого раздела.

Заключение

Извините за длинную статью 🙂 Я хотел сделать поле боя, чтобы иметь прочную основу для остальной части этой серии, поэтому я не мог найти логического способа разделить его. Я надеюсь, что вам удалось следовать за мной, если вы решили.

В следующей статье мы будем рендерить некоторых персонажей (юнитов). Мы будем повторно использовать некоторые идеи для рендеринга спрайтов, так что это должна быть удобоваримая статья, прежде чем мы перейдем к анимации и игровой логике.