Roast2D - 如何陷入及爬出游戏引擎陷阱
Roast2D
Roast2D 是一款受 high_impact 引擎启发,由 Rust 编写的适合快速开发的 2D 游戏引擎。 Roast2D 内置简单的物理碰撞检测,支持 LDTK 关卡编辑器,并且支持编译为 WASM 在浏览器中运行。
Examples
Breakout
Baloon platformer
这篇文章
这篇文章介绍了 Roast2D,一款因为我陷入了 游戏引擎陷阱 而开发出的 2D 引擎。
游戏引擎开发陷阱 是指业余游戏开发者经常陷入的一个状态:最初想要开发游戏,结果却变成了一直在开发游戏引擎,而且最终也没做出游戏。
文章最初叫 “我开发了一款 2D 游戏引擎并用它参加 GMTK Game Jam”,有点偏向于噱头,于是我更改了名字,并且决定持续维护这个文章更新 Roast2D 的特性,直到我放弃 Roast2D 的开发。
启发
我是个独立游戏以及桌游玩家,相比精美的画面,精巧的游戏机制对我吸引力更大。我想要制作类似陷阵之志、杀戮尖塔这种以机制吸引人的游戏,或者是类似魔塔、洞窟物语,没有复杂的机制但是让人欲罢不能的游戏。
基于使用 Rust 的经验,我自然的选择去学习 Bevy 引擎,但最终证明这是个错误的选择,对一个熟练的游戏开发者 Bevy 或许是个好的选择,但是对于新手来说,Bevy 不是这么容易掌握,尤其是当你想要打开引擎盖时,你看到的是复杂的集成电路,让人无从下手。Bevy 是个优秀的引擎,解决了 Rust 游戏开发中的很多问题,我借鉴了很多 Bevy ECS 的实现思路以及设计,但在学习一个新东西时最佳的方式是直接打开引擎盖看明白,而不是使用黑盒,Bevy 的抽象很复杂,对我来说和黑盒一样。我创建了一些游戏 prototypes, 但仍感觉无法完整掌握 Bevy,这些原型也因为可玩性不高被抛弃了。
在 Bevy 上的进展停滞不前时,我看到了 high_impact 引擎的文章 ,作者介绍了如何用 C 去重写 10 多年前 JavaScript 实现的 impact 引擎。这篇文章写的非常简单,但足够解释 high_impact 的设计,而且文章中提到远星物语是使用 js 版本的 impact 引擎开发的,这让我更感兴趣了。我花了些时间看了感兴趣的部分并很快弄懂了实现。
high_impact 的简单设计让我受到冲击,简单的技术也可以支持远星物语这么复杂的游戏,我意识到我应该从简单的技术开始,而非复杂的技术。
我决定自己开发一款简单的 2D 引擎。
Rust 需要一点点 ECS
Roast2D 最初设计受到 high_impact 的影响,使用 struct 来定义 Entity, 并通过 trait 来表示定义 Entity 的回调。
#[derive(Clone)]
pub struct Player {
can_jump: bool,
high_jump_time: f32,
normal: Vec2,
anim: Animation,
size: Vec2,
}
impl EntityType for Player {
fn load(eng: &mut Engine) -> Self;
fn init(&mut self, eng: &mut Engine, ent: &mut Entity);
fn update(&mut self, eng: &mut Engine, ent: &mut Entity);
// ...
}
像 high_impact 一样,Roast2D 中内置简单的 physics 和碰撞检测,因此 Player
在回调中会接收一个 Entity
的结构,这个结构中有 velocity
, accerate
, pos
, health
等通用的属性, 引擎在游戏的 loop update 中会读取这些值,更新 Entity 位置,根据物理特性去检查碰撞等。
这样足以实现简单的逻辑,但是 Rust 带来了很多特殊情况。 Rust 是个内存安全语言,语言保证同时只能持有一个可变引用。举个例子,引擎调用 Player
的 update
方法,那么这个时候因为 update
获取了 &mut self
,其他的代码就无法同时获取这个玩家的引用,那么假设我们需要在 update
中遍历所有的玩家该如何做?
- 选项 1,update 前把对象临时从状态中移除,这样在遍历 Player 时不包含当前 update 的 Player
- 选项 2,用
Borrow
包装对象,开发者可以动态检查是否已经被引用,如果被引用可以跳过对象
两个选项实际效果差不多,无论选择哪个处理方式都比较麻烦。
如果使用 ECS 则可以解决这个问题,Entity 仅是 id,回调方法不会保持对任何状态的引用,在处理回调时再去通过 Entity 获取 Component 数据的引用,引用也可以保持的尽可能短,避免 lifetime 冲突。ECS 本意并不是为了解决 Rust 生命周期,但是这种灵活的可组合行和模块化设计恰好可以避免复杂的状态访问。
Roast2D 的 ECS 设计类似 Bevy,但是实现非常简单。
#[derive(Component)]
pub struct Player {
color: Color,
}
impl Player {
pub fn init(w: &mut World, pos: Vec2) -> Ent {
let size = Vec2::new(128.0, 48.0);
let color = Color::rgb(0x37, 0x94, 0x6e);
let ent = w
.spawn()
.add(Transform::new(pos, size))
.add(Physics {
friction: Vec2::splat(FRICTION),
check_against: EntGroup::PROJECTILE,
physics: EntPhysics::ACTIVE,
..Default::default()
})
.add(Player { color })
.add(Hooks::new(PlayerHooks))
.id();
w.get_resource_mut::<CollisionSet>().unwrap().add(ent);
ent
}
}
#[derive(Default)]
pub struct PlayerHooks;
impl EntHooks for PlayerHooks {
fn update(&self, eng: &mut Engine, w: &mut World, ent: Ent);
// ...
}
所有的 Entity, Component, Resource 等状态都保存在 World
中。
使用 World#spawn
方法创建一个新的 Entity
, 然后调用 add
增加 Components,Roast2D 提供了 Transform
, Physics
以及 Hooks
等基础 Component 实现。大部分的 Entity 都需要这几个 Component。Hooks
接受 EntHooks
trait 实现,这个 trait 定义了引擎对 Entity 的回调方法。Player
Component 没有实际作用,仅作为一个标记使用。
Resource
类似 OOP 中的单例对象,在上面示例中,我们把 Entity 增加到 CollisionSet
, 引擎会检查 CollisionSet
中的 Entities 是否碰撞。
而实现这个 ECS 系统的代码非常简单,仅仅是 HashMap。
这个 ECS 系统看起来像模像样,足够解决上面提到的引用问题。我把这个实现叫做 Poor Man’s ECS。
关于 ECS 常见的 arche-type 实现以及 sparse table 的实现我推荐阅读 Archetypal ECS Considered Harmful? 这篇文章。
LDTK 关卡编辑器
LDTK 是一个开源的游戏关卡编辑器。
LDTK 并不和特定的游戏引擎绑定,在 LDTK 中支持定义 Entity,World, Level, Layer 等常用的概念,并支持导入 tileset 等资源,LDTK 最终输出一个后缀为 ldtk
的 JSON 文件。
Roast2D 支持读取 LDTK JSON 文件并自动加载 entity, tilemap。
使用 Roast2D 和 LDTK 时有几个约定:
- Collision layer, 如果 layer 类型为 IntGrid, 名称为
Collision
。Roast2D 会尝试将其作为 Collision Map 解析,0
代表 tile 无碰撞,1
代表 tile 会产生碰撞。 - Entities layer, layer 的类型为 Entity, 名称为
Entities
,layer 中包含的 Entity 名称必须和 Roast2D 中定义的 Component 类型名称一致,这样 Roast2D 会自动 spawn Entity 及 Component。
Collision detection
Entity 通过设置 Transform
, Physics
Components, 并把 ID 增加到 CollisionSet
来启用碰撞检测。
Roast2D 引擎在 game loop 中,会遍历 CollisionSet
中所有 Entities 并执行 Sweep and prune 算法,该算法减少无效的碰撞检测,仅对 x 轴或者 y 轴重合的 Entities 执行碰撞检测。
Roast2D 引擎仅支持正方形的碰撞检测,根据 Transform#angle
值我们使用两种碰撞检测:
angle
为默认值,或者为直角。此时 Entity 可以被视为一个不旋转的正方形,Entity 使用 AABB 碰撞检测angle
为其他角度。此时 Entity 被视为一个旋转的正方形,Entity 使用 Separating Axis Theorem 碰撞检测,SAT 支持检测斜面是否碰撞。
SDL2 和 WASM
平台相关的代码反而简单的多,核心需求是能在不同的平台上画出长方形,并在其中显示像素,我们用简单的 trait 来抽象这些方法。
pub trait Platform {
/// Return seconds since game started
fn now(&mut self) -> f32;
fn prepare_frame(&mut self);
fn end_frame(&mut self);
fn cleanup(&mut self);
fn draw(
&mut self,
texture: &Handle,
color: Color,
pos: Vec2,
size: Vec2,
uv_offset: Vec2,
uv_size: Option<Vec2>,
angle: f32,
flip_x: bool,
flip_y: bool,
);
fn create_texture(&mut self, handle: Handle, data: Vec<u8>, size: UVec2);
fn remove_texture(&mut self, handle_id: HandleId);
#[allow(async_fn_in_trait)]
async fn run<Setup: FnOnce(&mut Engine)>(
title: String,
width: u32,
height: u32,
vsync: bool,
setup: Setup,
) -> Result<()>
where
Self: Sized;
}
起初我决定只支持 SDL2 backend,但是随后发现 sdl2 rust crate 有很多小问题,比如无法编译到 wasm32-unknown-unknown
target,这意味着我们的游戏无法运行在浏览器上。
于是我决定增加 Web backend 支持,使用 Web canvas 接口实现 Platform
。
在 Rust 中可以通过 wasm-bindgen
crate 直接调用 canvas 接口,体验很好,基本 JavaScript 能做到的都可以直接用 Rust 做到,甚至不需要考虑 lifetime !所有的 Dom 对象都是可变的!
Web backend 本质是在调用 canvas 的 drawImage 接口去绘制图像,我花了很多时间处理 Canvas 中出现在 tile 边缘的神秘白线,剩下的事情都比较顺利。
在实现 Web backend 时,我已经有了一部分可以运行游戏代码,一边实现简单的接口一边可以看着游戏逐渐跑起来,是一种很神奇的体验。
Asset loading 资源管理
因为增加了 Web 支持, 加载图片等资源时没办法直接用简单的文件 io, 我决定模仿 Bevy 中资源加载的方式,提供一个 AssetManager 以及 load 接口,接口会立刻返回一个 Handle 实例表示对资源的引用,Handle 中仅仅保存了一个 ID 代表资源。
#[derive(Debug)]
pub enum AssetType {
Raw,
Texture,
}
impl AssetManager {
pub fn load<P: AsRef<Path>>(&mut self, path: P, asset_type: AssetType) -> Handle {
//...
}
pub fn get_raw(&self, handle: &Handle) -> Option<&Vec<u8>> {
//...
}
pub(crate) async fn fetch(&mut self) -> Result<Vec<FetchedTask>> {
//...
}
}
当调用 load
加载 Texture 类型的资源时,资源加载完成后,引擎会自动调用 Platform#create_texture
创建不同平台下的 Texture,在 SDL2 中会创建 SDLTexture
,而在 Web 中会创建一个 OffscreenCanvas
。
当调用 load
加载 Raw 类型的资源时,我们仅仅保存成 Vec<u8>
, 需要游戏代码通过 get_raw
接口从引擎获取结果并继续处理资源。
AssetManager 的 fetch
会在每一帧被调用,接口会检查是否有请求的资源,如果有则尝试加载。在 Web 中 fetch asset 通过 web worker 完成,在非 Web 环境中则通过标准库的 file io 完成。
游戏代码中需要保存 load
返回的 Handle 来引用资源
let handle = eng.assets.load_texture("demo.png");
let sprite = Sprite::new(handle, UVec2::splat(32));
当 handle 的所有引用都被删除时,AssetManager 会释放资源,如果是 texture 则会调用 Platform#remove_texture
:
impl Drop for StrongHandle {
fn drop(&mut self) {
let _ = self.drop_sender.send(DropEvent(self.id));
}
}
impl AssetManager {
pub(crate) async fn fetch(&mut self) -> Result<Vec<FetchedTask>> {
// ...
// remove dropped assets
while let Ok(event) = self.receiver.try_recv() {
self.assets.remove(&event.0);
let fetched_task = FetchedTask::RemoveTexture { handle: event.0 };
tasks.push(fetched_task);
}
// ...
}
}
代码很大程度上参考了 Bevy, 但是我只实现了非常简化的版本,去掉了和 reflect 相关的部分,并且尽量去掉了多余的抽象层。
Sound 音频接口
我不熟悉播放音频的接口该如何设计,因此选择不把音频集成到引擎中,不过游戏代码中可以直接使用 kira crate 来跨平台支持音频。游戏中可以通过 Roast2D 提供的 AssetManager 接口加载音频资源,并在资源加载完毕后交给 kira 处理。
这里留下了示例代码,在每次播放音频会去检查是否已经有缓存文件,如果没有则尝试检查 AssetManager 中资源是否加载。
match self.sounds_data.get(handle) {
Some(data) => {
log::debug!("Get sound {sound:?} cached");
Some(data.to_owned())
}
None => {
let Some(raw) = eng.assets.get_raw(handle).cloned() else {
log::debug!("Get sound {sound:?} not ready");
return None;
};
log::debug!("Get sound {sound:?} done");
let data = StaticSoundData::from_media_source(Cursor::new(raw)).unwrap();
self.sounds_data.insert(handle.to_owned(), data.clone());
Some(data)
}
}
Ending?
No, 公主仍在另外一个城堡