Правене на игри с GGEZ
20 декември 2022
Административни неща
- Домашно 3: https://fmi.rust-lang.bg/tasks/3
- Инструкции за проектите: https://fmi.rust-lang.bg/guides/projects
- Последна лекция за годината, връщаме се на 5ти януари
Инсталация на ggez
Стабилната версия в момента е 0.7.0 и изглежда като да работи без проблеми.
[dependencies]
ggez = "0.8.1"
Ако случайно ударите на бъгове, винаги има опцията да изберете конкретна git ревизия или branch:
[dependencies]
ggez = { git = "https://github.com/ggez/ggez", rev = "50918f133785e1ee585c022d94a0176ea0e73887" }
ggez = { git = "https://github.com/ggez/ggez", branch = "devel" }
Инсталиране от път
Допълнително, ако някой ден се озовете в ситуация, в която искате да оправите бъг локално и да ползвате тази версия, можете да си инсталирате пакет от локален път:
[dependencies]
ggez = { path = "/home/andrew/src/ggez" }
Скелет на играта
Фреймуърка очаква да дефинирате ваш тип, който да имплементира трейта ggez::event::EventHandler
:
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-а:
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
, е по-удобно да използваме друга:
// ...
// 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
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
- Метода връща
GameResult<()>
, така че успешен край ще еOk(())
, а неуспешен край вероятно ще дойде от грешка в някоя от функциите за чертане, звуци, и т.н. - Всичко се случва в цикъл, който ще се викне 60 пъти (или колкото искаме) в секунда, плавно (надяваме се).
- Затова имаме нужда от
seconds
, илиtime_delta
, или както го наречете -- изминалото време в този цикъл, като части от секундата. Умножаваме тази стойност по всякакво движение, за получим равномерна промяна. - Състоянието става на тези квантове време, така че няма как да правим продължителна промяна на състоянието -- анимации няма как да станат с "линеен" код. Променяме състоянието (играча е в състояние "стреляне", "движение", и т.н.), и движим позицията му както подобава.
Update
Най-простата форма на update
би могла да изглежда така:
self.position.x += self.velocity.x * seconds;
self.position.y += self.velocity.y * seconds;
Променяме velocity
в зависимост от, например, задържан клавиш-стрелкичка, или в зависимост от AI-а на противниците, или както си пожелаем. Имаме пълната мощ на библиотеката nalgebra, която вероятно няма да ни трябва за много сложни неща:
#[derive(Debug)]
pub struct Enemy {
position: Point2<f32>,
velocity: Vector2<f32>,
// ... и каквото още ни трябва ...
}
Update
За нещастие, за да се поддържа стабилност на алгебрата, се ползва библиотеката mint, която е доста минимална, така че не можем да събираме точки и вектори примерно -- сметките се правят или по координати, или си имплементираме помощни средства. Или правим операции с nalgebra::Point2
и ги конвертираме до mint::Point2<f32>
с .into()
като ги подаваме на ggez.
nalgebra = { version = "0.29.0", features = ["mint"] }
Алтернативна библиотека за алгебра, която май се използва в примерите днешно време, е glam, която също има features = ["mint"]
за съвместимост.
Input
Има още два метода, които могат да се имплементират за event::EventHandler
:
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
:
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
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
Не ни трябва нищо сложно за тази конкретна игра. За всеки противник и всеки изстрел, проверяваме дали изстрела е в противника:
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, който можем да варираме:
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:
#[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:
#[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 }
}
Категории игри
Обекти, които се движат по екрана и имат контакт:
- Имате "играч", "противници", "бонуси", "препятствия"…
- На
update
, обновявате къде се намират спрямо някакви правила - На
draw
, просто ги рисувате - Имате някакъв "collision detection", в който преценявате какъв контакт имат, всичко със всичко -- прегради, куршуми, видимост
Категории игри
Игри на дъска с обекти, които движете в определена форма:
- Имате някакви обекти, които са на някаква "дъска", "поле"
- Цъкате ги или имате някаква друга интеракция с тях
- В идеалния случай имате отделно логически и визуални координати. От първото се изчислява второто при рисуване.
- Проверява се за някакво специално условие и играта се променя
Съвети
- Карайте стъпка по стъпка и няма да имате проблеми. Правете "актьорите" един по един, движете ги, проверявайте дали всичко е наред.
- Пишете си функции за дебъгване -- за чертаене на кутийка около противника, например, да видите дали collision-а работи като хората. За проверка къде са координатите на мишката, etc.
- Извличайте константи с добри имена:
PLAYER_MOVE_SPEED
,GRAVITY_ACCELERATION
са добри константи, които може лесно да промените за дебъгване и натаманяване.THIRTY_TWO
иFIVE_HUNDRED
не са. - Удобно е "скъпи" операции да не се случват при конструиране, а с отделен метод. Но трябва да жонглирате
Option
-и, така че е tradeoff. - При проблеми с performance, първо пробвайте да пуснете кода с
--release
.
Ресурси
- Shooter играта: rust-shooter
- Memory играта (недовършена): rust-memory-game
- Примерите от документацията (astroblasto): examples
Ресурси
- Shooter играта: rust-shooter
- Memory играта (недовършена): rust-memory-game
- Примерите от документацията (astroblasto): examples
- Звукови ефекти: Freesound
- Картинки: Kenney
- Анимации: keyframe
Ресурси
- Shooter играта: rust-shooter
- Memory играта (недовършена): rust-memory-game
- Примерите от документацията (astroblasto): examples
- Звукови ефекти: Freesound
- Картинки: Kenney
- Анимации: keyframe
- Лекция от RustFest Zurich: Beyonce Brawles
- По-генерална помощ за gamedev (множко за простичък проект, но интересно четиво in general): Game Programming Patterns
Други варианти за игри
ECS
- Bevy: https://bevyengine.org/
- also see, bevy_crossterm
- Godot (само че там има GUI и custom scripting език): godot-rust
- Amethyst: https://amethyst.rs/
Други варианти за игри
ECS
- Bevy: https://bevyengine.org/
- also see, bevy_crossterm
- Godot (само че там има GUI и custom scripting език): godot-rust
- Amethyst: https://amethyst.rs/
Минималистични, подобно на GGEZ
- Coffee: https://github.com/hecrj/coffee
- Tetra: https://github.com/17cupsofcoffee/tetra
- GGEZ, ама за браузъра: https://github.com/ggez/good-web-game
Други варианти за игри
ECS
- Bevy: https://bevyengine.org/
- also see, bevy_crossterm
- Godot (само че там има GUI и custom scripting език): godot-rust
- Amethyst: https://amethyst.rs/
Минималистични, подобно на GGEZ
- Coffee: https://github.com/hecrj/coffee
- Tetra: https://github.com/17cupsofcoffee/tetra
- GGEZ, ама за браузъра: https://github.com/ggez/good-web-game
Сравнение
- Мнението на автора на GGEZ за съществуващите библиотеки: https://wiki.alopex.li/AGuideToRustGameFrameworks2019
Още инструменти
Property testing:
- Quickcheck: https://crates.io/crates/quickcheck
- Proptest (може би по-лесно за customization): https://crates.io/crates/proptest
Ако ви е бавна компилацията, сменянето на linker-а може да помогне, вижте "If you are using Rust" секцията: https://github.com/rui314/mold