Rust sokoban 学习笔记

n2t 学好累,找点新玩具玩玩,闻闻味儿,找点乐子,etc… 参考 https://sokoban.iolivia.me

虽然只是找点乐子,但确实学到了点东西,把 rust 的模块化熟悉了一下,了解了一下 ECS,见识了一下 Rust 的框架的抽象能做到何种程度(在类型的花活上简直比隔壁 ts 还牛逼……),必可活用于下一次。

ECS

ECS,即 Entity-Component System,是一种游戏架构模式,它遵循组合优于继承的原则:

  • Component:持有实体的特定特征的纯数据结构,比如位置,是否可渲染,移动
  • Entity:Entity 由复数的 Component 组成,比如玩家可能包括位置,是否可渲染,移动等 Component,地面可能只包括位置和是否可渲染。Entity 差不多只是一个有着唯一标识符(似乎也是通过 Component 去标识,这些 Component 称为 Marker Component,不包含任何数据)的 Component 的容器
  • System:System 使用 Component 和 Entity,并包含利用这些数据的行为和逻辑。比如,一个渲染 System 可能会迭代所有可渲染(即包含相应 Component)的 Entity 并绘制它们。只有 System 包含行为,Component 利用 System 去执行操作(就像 Visitor)

推箱子游戏中包含如下 Entity,以及这些 Entity 由什么 Component 组成(把 Component 当成“配置”或字段?)

  • Player: Position, Renderable, Movable
  • Wall: Position, Renderable
  • Floor: Position, Renderable
  • Box: Position, Renderable, Movable
  • Box spot(箱子的目标位置): Position, Renderable

这书使用了下面的库:

  • ggez:一个 2D 游戏引擎,负责一些底层的玩意——绘制窗口,事件循环……
  • specs:ECS 库,所有业务都在这儿了(ECS 和游戏引擎是分开的,这点有点酷,也就是说能用其它的架构模式去料理这个游戏引擎)
  • glam:一个 3D 绘图库,用这玩意去绘图

跟着写代码的时候时刻区分哪些部分是 ggez 的,哪些部分是 specs 的,这还蛮有趣的,似乎是第一次把两个轮子结合在一起去做点什么,之前都是只借助内置库以及一个轮子。

定义 Component 和 Entity

之前学习迭代器的时候看到它的 collect 就很好奇它的实现方式,现在又看到相似的用法了。这玩意实际上实现上没有任何动态的部分——它接受一个实现特定 trait 的类型,在编译时直接找到相应 trait 的实现。这和 java 中的类似玩意不一样,那个需要利用上反射(根本原因是 Java 的接口和抽象类无法对 static 方法进行约束)或者得把相应方法引用给传进去。

下面是注册 Component,以及对 Entity 的定义,注意 Entity 并非是以类型的形式去定义,这允许 Entity 在运行时以及通过配置文件等方式去修改,况且使用类型去定义的话反而更麻烦,System 无法轻易找到哪些 Entity 包含它要的 Component:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#[derive(Debug, Component, Clone, Copy)]
#[storage(VecStorage)] // 这个似乎是说所有 Component 存储的方式
pub struct Position {
x: u8,
y: u8,
z: u8, // 用于确定谁显示在前面
}
// 其他 Component……
pub fn register_components(world: &mut World) {
world.register::<Position>();
world.register::<Renderable>();
// 下面的都是 Marker Component
world.register::<Player>();
world.register::<Wall>();
world.register::<Box>();
world.register::<BoxSpot>();
}
// Wall Entity 定义
pub fn create_wall(world: &mut World, position: Position) {
world
.create_entity()
.with(Position { z: 10, ..position })
.with(Renderable {
path: "/images/wall.png".to_string(),
})
.with(Wall {})
.build();
}
// 其他 Entity

Rendering System

开始定义 System,System 需要能够修改外界事物,因此需要包含 Context——ggez 提供的用于操作游戏状态的接口,以可变借用的形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub struct RenderingSystem<'a> {
context: &'a mut Context,
}

// Rendering System 需要知道 Component 的位置和是否可渲染。有此二 Component 的 Entity 才能被渲染
impl<'a> System<'a> for RenderingSystem<'a> {
// 这里的 ReadStorage 就是 Component 的集合,只读的显然
type SystemData = (ReadStorage<'a, Position>, ReadStorage<'a, Renderable>);

fn run(&mut self, data: Self::SystemData) {
let (positions, renderables) = data;
// todo...
}
}

然后,其业务就可以加到事件循环中了(每次循环都创建一次……但这玩意其实确实挺便宜):

1
2
3
4
5
6
7
8
9
10
impl event::EventHandler<ggez::GameError> for Game {
// ...
fn draw(&mut self, context: &mut Context) -> GameResult {
{
let mut rs = RenderingSystem { context };
rs.run_now(&self.world);
}
Ok(())
}
}

关于 Rendering System 的业务:

  1. 清空(上一帧)屏幕
  2. 获取所有 Position 和 Renderable(同时包含这两个 Component 的 Entity?),按 z 轴排序
  3. 根据 Renderable 中存储的路径加载图片,在 Position 中的相应位置绘制图片
  4. 展示图片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Rendering System 需要知道 Entity 的位置和是否可渲染。有此二 Component 的 Entity 才能被渲染
impl<'a> System<'a> for RenderingSystem<'a> {
// SystemData 即该 System 需要知晓的东西,这里规定需要知晓 Renderable 和 Position(及拥有这两个 Component 的 Entity)
type SystemData = (ReadStorage<'a, Position>, ReadStorage<'a, Renderable>);
fn run(&mut self, data: Self::SystemData) {
let (positions, renderables) = data;
// 清空上一帧(注意这里并非是直接操作 context,可能 context 中均是底层和原子的操作)
graphics::clear(self.context, graphics::Color::new(0.95, 0.95, 0.95, 1.0));
// join 操作保证得到的集合中,每个元素都是这样的 Entity 的这些 Component,它同时包含这些 Component
let mut entities = (&positions, &renderables).join().collect::<Vec<_>>();
entities.sort_by_key(|(p, _)| p.z);
for (position, renderable) in entities {
let image = Image::new(self.context, &renderable.path).expect("expected image");
let x = position.x as f32 * TILE_WIDTH;
let y = position.y as f32 * TILE_WIDTH;
let draw_params = DrawParam::new().dest(Vec2::new(x, y));
graphics::draw(self.context, &image, draw_params).expect("expected render");
}
graphics::present(self.context).expect("expected to present");
}
}

alt text

Input Event,Resource

该动起来了。要移动 Player,需要捕获输入事件,并通知 specs 去进行处理。

要捕获输入事件,只需要“重写”key_down_event即可:

1
2
3
4
5
6
7
8
9
fn key_down_event(
&mut self,
ctx: &mut Context,
keycode: event::KeyCode,
_keymods: event::KeyMods,
_repeat: bool,
) {
println!("Key pressed: {:?}", keycode);
}

如何去通知 specs 去处理呢?specs 中有一种名为 Resource 的概念,Resource 是可全局共享的数据或服务,它不属于任何单个 Entity,可被一个或多个 System 去使用。可以把键盘输入作为一种可全局访问的数据,即作为 Resource。另外的典型 Resource 包括游戏配置。实际上屏幕也可以作为 Resource…

要定义一个 Resource,只需要将其“注册”进去即可,这里虽然说是注册,但实际上是插了个实例。这玩意让我想到 React 的 Provider。

这时候就要问了——卧槽write_resource是怎么根据泛型得到我要的类型的资源的实例的?实际上有个TypeId::of::<T>()方法能获取T的 typeId,即该类型的唯一标识符,得到了标识符便能够得到实例,就像某种依赖注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[derive(Default)]
pub struct InputQueue {
pub keys_pressed: Vec<KeyCode>,
}

pub fn register_resources(world: &mut World) {
world.insert(InputQueue::default())
}
// ...
fn key_down_event(
&mut self,
ctx: &mut Context,
keycode: KeyCode,
_keymods: KeyMods,
_repeat: bool,
) {
println!("Key pressed: {:?}", keycode);
let mut input_queue = self.world.write_resource::<InputQueue>();
input_queue.keys_pressed.push(keycode);
}

Input System

Rendering System 只读取 Component,但 Input System 需要读取 Component 和 Resource,然后写 Component。一个很酷的地方是,Input System 不需要拥有任何字段,它只负责根据输入去更新 Player 的 Movement 即可。Rendering System 的业务放到 update 过程中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
pub struct InputSystem {}

impl<'a> System<'a> for InputSystem {
type SystemData = (
// 它要读写 InputQueue
Write<'a, InputQueue>,
// 它要读写 Position
WriteStorage<'a, Position>,
// 它要读 Player 以确定谁是 Player
ReadStorage<'a, Player>
);
fn run(&mut self, data: Self::SystemData) {
let (mut input_queue, mut position, players) = data;
let Some(key) = input_queue.keys_pressed.pop() else {
return;
};
for (Position { x, y, .. }, _) in (&mut position, &players).join() {
match key {
KeyCode::Up => *y -= 1,
KeyCode::Down => *y += 1,
KeyCode::Left => *x -= 1,
KeyCode::Right => *x += 1,
_ => ()
}
}
}
}

fn update(&mut self, context: &mut Context) -> GameResult {
// Run input system
{
let mut is = InputSystem {};
is.run_now(&self.world);
}
Ok(())
}

System 有点像策略,它本身不持有 World,而是接受 World 去做操作,因此动态地增减 System 是轻松的,World 也不需要知道当前有多少个 System 还在活跃。所以这又是一个取舍……

推箱子

当前 Player 能到处动了,但和环境没有任何交互,会穿过墙壁和箱子。为此需要添加相应业务。考虑再定义两个 Marker,标识 Entity 可以被移动和无法被移动;Player 和箱子是可以被移动的,而墙壁无法被移动;其它的部分和移动功能无关,因此不进行任何标识

1
2
3
4
5
6
7
8
9
10
11
// 没有任何字段时可以使用 NullStorage,尚不知这玩意本质
#[derive(Component, Default)]
#[storage(NullStorage)]
pub struct Movable;

#[derive(Component, Default)]
#[storage(NullStorage)]
pub struct Immovable;

// 为 Player 和 Box with Movable
// 为 Wall with Immovable

然后就是修改 InputSystem 去添加相应逻辑了,这里允许推动多个箱子(要头疼啦),其它的规则大家都知道。主要逻辑大概是检查移动方向上的所有物体,找到连续的 Movable(包括 Player),然后看尾随的是否是 Immovable,如果不是,就移动这所有的 Movable,否则谁都动不了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
impl<'a> System<'a> for InputSystem {
type SystemData = (
// 它要读写 InputQueue
Write<'a, InputQueue>,
// 它要读 Entity 以知晓其 id
Entities<'a>,
// 它要读写 Position
WriteStorage<'a, Position>,
// 它要读 Player 以确定谁是 Player
ReadStorage<'a, Player>,
// 它要读 Movable,确认谁是可以被移动的以推动它
ReadStorage<'a, Movable>,
// 它要读 Immovable,确认何时无法推动
ReadStorage<'a, Immovable>,
);
fn run(&mut self, data: Self::SystemData) {
let (mut input_queue, entities, mut positions, players, movable, immovable) = data;
let Some(key) = input_queue.keys_pressed.pop() else {
return;
};
let direction = match key {
KeyCode::Up => (0, -1),
KeyCode::Down => (0, 1),
KeyCode::Left => (-1, 0),
KeyCode::Right => (1, 0),
_ => return
};
// 找到所有的 Movable 和 Immovable 的 Entity,按坐标->entityId 去建立索引
let mut mov = HashMap::new();
let mut immov = HashMap::new();
for (entity, &Position { x, y, .. }, _) in (&entities, &positions, &movable).join() {
mov.insert((x, y), entity.id());
}
for (entity, &Position { x, y, .. }, _) in (&entities, &positions, &immovable).join() {
immov.insert((x, y), entity.id());
}
let (&Position { x, y, .. }, _) = (&positions, &players).join().next().expect("No Player Component");

let mut to_move = Vec::new();
for pos in std::iter::successors(Some((x, y)), |&(x, y)| Some((x + direction.0, y + direction.1)))
.take_while(|&(x, y)| x >= 0 && x as u16 <= MAP_WIDTH && y >= 0 && y as u16 <= MAP_HEIGHT) {
// 从 player 出发,往移动方向看
// 如果遇到 mov,加到待 move 的集合中
// 如果遇到 immov,证明有墙壁直接相连,谁都动不了,直接返回
// 如果遇到既不 mov 又不 immov 的,证明有空隙,可以动
if let Some(entity_id) = mov.get(&pos) {
to_move.push(*entity_id)
} else if let Some(_) = immov.get(&pos) {
return
} else {
break
}
}
for entity_id in to_move {
if let Some(pos) = positions.get_mut(entities.entity(entity_id)) {
pos.x = pos.x + direction.0;
pos.y = pos.y + direction.1;
}
}
}
}

模块化

前面所有代码全写在main.rs中,为了方便维护,应按照关注点分离原则去分离代码。这里旨在感受一下 rust 的模块化。对项目内的代码,每个 rs 文件都是从它自己出发去引用其它 rs 文件(模块):

(没细学,瞎 BB 的!)

  • 解析从main.rslib.rs开始,它们是命名空间的根路径,即crate::,这两个文件中能直接看到的顶层成员,通过crate::都能看到,无论它是否加了 pub;另外使用self::表示当前目录(这似乎是默认行为)
  • 每个 rs 文件中可以使用[pub] mod MODULE_NAME {/* ... */}去定义新的模块,或者使用[pub] mod MODULE_NAME;去“声明”新的模块,rustc 会从./MODULE_NAME.rs./MODULE_NAME/mod.rs中找模块的实际定义;如果 mod 前缀 pub,则它是 public 的,能被外界访问到,这点对main.rslib.rs不适用
  • 使用pub use可以进行“重新导出”,允许越过模块结构,直接从当前模块中导出其它模块,主要是嵌套子模块中的成员,比如这里在/systems/mod.rs中编写了mod input_system; pub use input_system::InputSystemmain.rs中就可以直接通过systems::InputStream去导入InputStream,这允许对模块结构进行抽象

gameplay

能动了,自由了,但没有目标,仍旧是带着锁链。该为这游戏设立点目标了。目标是显然的——以最小的步数把所有箱子移动到目标位置,为此,需要记录步数,需要检查箱子是否移动到目标位置。

步数,以及当前是否完成游戏,这显然是一个全局的数据,可以使用 Resource 去抽象它。InputSystem 需要读写它——如果游戏没有完成,自增步数,另外需要一个 GamePlayStateSystem——包含关于游戏完成状态的逻辑——去读写它:变更当前游戏完成状态。

两个实体在读写同一个数据…设计上是在update阶段先执行 InputStream 的逻辑,再执行 GameStateSystem 的逻辑所以没啥问题,在工业级的游戏中也会是这样有严格的顺序执行的吗?如果不是,那就不能这么操作了,考虑到一些并发问题啥的。猜测工业级的游戏中如果要实现类似功能,应该是使用某种发布订阅模式去通知 Player 有移动,而不是让 InputSystem 去进行操作——我只负责动,其它的关我屁事。

GamePlayStateSystem 需要读写游戏状态,需要读 Box,BoxSpot 和 Position 以确认箱子是否是在目标处:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
impl<'a> System<'a> for GameplayStateSystem {
type SystemData = (
Write<'a, Gameplay>,
ReadStorage<'a, Position>,
ReadStorage<'a, Box>,
ReadStorage<'a, BoxSpot>,
);
fn run(&mut self, data: Self::SystemData) {
let (mut gameplay, positions, boxes, box_spots) = data;
// 首先获取所有 Box 的位置
let box_pos = (&positions, &boxes).join()
.map(|(&Position { x, y, .. }, _)| (x, y)).collect::<HashSet<_>>();
// 然后检查所有 Box 都在 Spot 上
if (&positions, &box_spots).join().map(|x| x.0)
.all(|&Position { x, y, .. }| box_pos.contains(&(x, y))) {
gameplay.state = GameplayState::Won
}
}
}

然后要绘制当前的游戏状态到屏幕上,因此 Rendering System 需要读取游戏状态。