Правене на игри с GGEZ

17 декември 2024

Административни неща

Rust-shooter

Пълен код: https://github.com/AndrewRadev/rust-shooter

Инсталация на ggez

Стабилната версия в момента е 0.9.3 и изглежда като да работи без проблеми.

1 2
[dependencies]
ggez = "0.9.3"

Ако случайно ударите на бъгове, винаги има опцията да изберете конкретна git ревизия или branch:

1 2 3
[dependencies]
ggez = { git = "https://github.com/ggez/ggez", rev = "50918f133785e1ee585c022d94a0176ea0e73887" }
ggez = { git = "https://github.com/ggez/ggez", branch = "devel" }

Инсталиране от път

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

1 2
[dependencies]
ggez = { path = "/home/andrew/src/ggez" }

Скелет на играта

Фреймуърка очаква да дефинирате ваш тип, който да имплементира трейта ggez::event::EventHandler:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
struct MainState { /* ... */ }

impl event::EventHandler for MainState {
    fn update(&mut self, ctx: &mut Context) -> GameResult<()> {
        // Променяме състоянието на играта
        Ok(())
    }

    fn draw(&mut self, ctx: &mut Context) -> GameResult<()> {
        let dark_blue = graphics::Color::from_rgb(26, 51, 77);
        let mut canvas = graphics::Canvas::from_frame(ctx, dark_blue);

        // Рисуваме неща: canvas.draw(<drawable>, <draw params>)

        canvas.finish(ctx)?;
        Ok(())
    }
}

Скелет на играта

В main функцията създаваме инстанция на нашия тип и "контекст" (за рисуване/звуци) с конфигурация, и стартираме event loop-а:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
pub fn main() {
    // Конфигурация:
    let conf = Conf::new().
        window_mode(WindowMode {
            min_width: 1024.0,
            min_height: 768.0,
            ..Default::default()
        });

    // Контекст и event loop
    let (mut ctx, event_loop) = ContextBuilder::new("shooter", "FMI").
        default_conf(conf.clone()).
        build().
        unwrap();

    // ... Подготвяне на ресурси, вижте следващия слайд

    // Пускане на главния loop
    let state = MainState::new(&mut ctx, conf).unwrap();
    event::run(ctx, event_loop, state);
}

Зареждане на ресурси

За да може библиотеката да си намери картинки и звуци при компилация, добре е да добавим локалната директория "resources" (или както искаме да я наречем). Когато разпространяваме играта, тя ще търси по default папка до exe-то, която се казва "resources", но подкарвайки я с cargo run, е по-удобно да използваме друга:

1 2 3 4 5 6 7 8 9 10 11 12 13 14
// ...
// let (mut ctx, event_loop) = ...

if let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") {
    let mut path = path::PathBuf::from(manifest_dir);
    path.push("resources");
    ctx.fs.mount(&path, true);
}

let font_data = graphics::FontData::from_path(&ctx, "/DejaVuSerif.ttf").unwrap();
ctx.gfx.add_font("MainFont", font_data);

// let state = MainState::new(&mut ctx, &conf).unwrap();
// ...

Update

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
fn update(&mut self, ctx: &mut Context) -> GameResult<()> {
    if self.game_over { return Ok(()); }
    const DESIRED_FPS: u32 = 60;

    while ctx.time.check_update_time(DESIRED_FPS) {
        // let seconds = 1.0 / (DESIRED_FPS as f32);
        let seconds = ctx.time.delta().as_secs_f32();

        self.time_until_next_enemy -= seconds;
        if self.time_until_next_enemy <= 0.0 {
            // Създаваме следващия противник
            // self.time_until_next_enemy = ...;
        }

        // Обновяваме позиция на играча, на изстрелите, ...
    }
}

Update

Update

Най-простата форма на update би могла да изглежда така:

1 2
self.position.x += self.velocity.x * seconds;
self.position.y += self.velocity.y * seconds;

Променяме velocity в зависимост от, например, задържан клавиш-стрелкичка, или в зависимост от AI-а на противниците, или както си пожелаем. Имаме пълната мощ на библиотеката nalgebra, която вероятно няма да ни трябва за много сложни неща:

1 2 3 4 5 6
#[derive(Debug)]
pub struct Enemy {
    position: Point2<f32>,
    velocity: Vector2<f32>,
    // ... и каквото още ни трябва ...
}

Update

За нещастие, за да се поддържа стабилност на алгебрата, се ползва библиотеката mint, която е доста минимална, така че не можем да събираме точки и вектори примерно -- сметките се правят или по координати, или си имплементираме помощни средства. Или правим операции с nalgebra::Point2 и ги конвертираме до mint::Point2<f32> с .into() като ги подаваме на ggez.

1
nalgebra = { version = "0.29.0", features = ["mint"] }

Алтернативна библиотека за алгебра, която май се използва в примерите днешно време, е glam, която също има features = ["mint"] за съвместимост.

Input

Има още два метода, които могат да се имплементират за event::EventHandler:

1 2 3 4 5 6 7 8 9 10 11 12 13
use ggez::input::keyboard;

fn key_down_event(&mut self, ctx: &mut Context, input: keyboard::KeyInput, _repeat: bool) -> GameResult<()> {
    match input.keycode {
        Some(keyboard::KeyCode::Space) => self.input.fire = true,
        Some(keyboard::KeyCode::Left) => self.input.movement = -1.0,
        Some(keyboard::KeyCode::Right) => self.input.movement = 1.0,
        Some(keyboard::KeyCode::Escape) => ctx.request_quit(),
        _ => (), // Do nothing
    }

    Ok(())
}

И еквивалентния за key up …

Input

Има още два метода, които могат да се имплементират за event::EventHandler:

1 2 3 4 5 6 7 8 9 10 11 12 13
use ggez::input::keyboard;

fn key_up_event(&mut self, _ctx: &mut Context, input: keyboard::KeyInput) -> GameResult<()> {
    match input.keycode {
        Some(keyboard::KeyCode::Space) => self.input.fire = false,
        Some(keyboard::KeyCode::Left | keyboard::KeyCode::Right) => {
            self.input.movement = 0.0
        },
        _ => (), // Do nothing
    }

    Ok(())
}

Drawing

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
fn draw(&mut self, ctx: &mut Context) -> GameResult<()> {
    let dark_blue = graphics::Color::from_rgb(26, 51, 77);
    let mut canvas = graphics::Canvas::from_frame(ctx, dark_blue);

    if self.game_over {
        // Рисуваме "край на играта"
        canvas.finish(ctx)?;
        return Ok(())
    }

    // Рисуваме противници, играч, куршум, и т.н.

    canvas.finish(ctx)?;
    Ok(())
}

Drawing

Просто викане на canvas.draw с Drawable неща. Когато имаме координатите и състоянието на противници, играч, изстрели, сцена, фон, и прочее, всичко се свежда до това да извикаме методи, които казват на графичната система какво да нарисува и къде.

Collision detection

Не ни трябва нищо сложно за тази конкретна игра. За всеки противник и всеки изстрел, проверяваме дали изстрела е в противника:

1 2 3 4 5 6 7 8 9 10
for enemy in &mut self.enemies {
    for shot in &mut self.shots {
        if enemy.bounding_rect(ctx).contains(shot.pos) {
            shot.is_alive = false;
            enemy.is_alive = false;
            self.score += 1;
            let _ = self.assets.boom_sound.play(ctx);
        }
    }
}

Тестване

Инициализиране на контекст може да се направи само веднъж, което може да затрудни тестването. Решението е decoupling -- вместо конкретен тип, използваме trait, който можем да варираме:

1 2 3 4 5
pub trait Sprite: Debug {
    fn draw(&mut self, center: Point2<f32>, canvas: &mut graphics::Canvas);
    fn width(&self, ctx: &mut Context) -> f32;
    fn height(&self, ctx: &mut Context) -> f32;
}

Тестване

В истинския код, имаме нещо истински използваемо, което използва assets, fonts, drawing:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
#[derive(Debug)]
pub struct TextSprite {
    text: graphics::Text,
}

impl TextSprite {
    pub fn new(label: &str) -> GameResult<TextSprite> {
        let mut text = graphics::Text::new(label);
        text.set_font("MainFont");
        text.set_scale(graphics::PxScale::from(32.0));
        Ok(TextSprite { text })
    }
}

impl Sprite for TextSprite {
    fn draw(&mut self, top_left: Point2<f32>, canvas: &mut graphics::Canvas) {
        canvas.draw(&self.text, graphics::DrawParam::default().dest(top_left))
    }
    fn width(&self, ctx: &mut Context) -> f32 { self.text.dimensions(ctx).unwrap().w }
    fn height(&self, ctx: &mut Context) -> f32 { self.text.dimensions(ctx).unwrap().h }
}

Тестване

В тестовете, спокойно можем да си сложим един "фалшив" sprite:

1 2 3 4 5 6 7 8 9 10 11 12
#[derive(Debug)]
struct MockSprite {
    width: f32,
    height: f32,
}

impl Sprite for MockSprite {
    fn draw(&mut self, _center: Point2<f32>, _canvas: &mut graphics::Canvas) {}

    fn width(&self, _ctx: &mut Context) -> f32 { self.width }
    fn height(&self, _ctx: &mut Context) -> f32 { self.height }
}

Категории игри

Обекти, които се движат по екрана и имат контакт:

Категории игри

Игри на дъска с обекти, които движете в определена форма (rust-memory-game):

Категории игри

Игри на дъска с обекти, които движете в определена форма:

Съвети

Ресурси

Ресурси

Ресурси

Други варианти за игри

ECS

Други варианти за игри

ECS

Минималистични, подобно на GGEZ

Други варианти за игри

ECS

Минималистични, подобно на GGEZ

Сравнение

Още инструменти

Въпроси