Skip to content

Commit

Permalink
feat: initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
ShenMian committed Mar 1, 2024
0 parents commit 125793f
Show file tree
Hide file tree
Showing 14 changed files with 781 additions and 0 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Test

on:
push:
branches: [main]

env:
CARGO_TERM_COLOR: always

jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}

steps:
- uses: actions/checkout@v4

- name: Setup mdBook
uses: peaceiris/actions-mdbook@v1
with:
mdbook-version: 'latest'

- run: mdbook build

- name: Deploy
uses: peaceiris/actions-gh-pages@v3
if: ${{ github.ref == 'refs/heads/main' }}
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./book
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
book
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Sokoban Tutorial

本文将描述如何实现一个推箱子游戏 📦, 代码示例使用 Rust 语言 🦀 编写.

完整的项目代码位于:

- [ShenMian/soukoban](https://github.com/shenmian/soukoban): 推箱子相关算法实现.
- [ShenMian/sokoban-rs](https://github.com/shenmian/sokoban-rs): 推箱子游戏实现, 使用 [Bevy] 引擎.

[bevy]: https://github.com/bevyengine/bevy
10 changes: 10 additions & 0 deletions book.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[book]
title = "Sokoban Tutorial"
authors = ["ShenMian <sms_school@outlook.com>"]
language = "cn"
multilingual = false
src = "src"

[output.html]
git-repository-url = "https://github.com/ShenMian/sokoban-tutorial"
git-repository-icon = "fa-github"
10 changes: 10 additions & 0 deletions src/SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Summary

[介绍](introduction.md)

- [关卡](level/README.md)
- [构建](level/construct.md)
- [标准化](level/normalization.md)
- [动作]()
- [求解器]()
- [术语表](glossary.md)
Binary file added src/assets/boxworld_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/no_walls_level.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sasquatch_41.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/steaming_hot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions src/glossary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# 术语表

| 中文 | 英文 |
| ----------- | -------- |
| 求解器 | solver |
| 解/解决方案 | solution |
24 changes: 24 additions & 0 deletions src/introduction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 介绍

本文将描述如何实现一个推箱子游戏 📦, 代码示例使用 Rust 语言 🦀 编写.
推箱子游戏具有以下特点:

- 规则简单. 可以专注于实现功能, 而非理解复杂的游戏规则和机制.
- 基本功能易于实现.
- 有具有挑战的高级功能. 比如纯鼠标控制, 逆推等.
- 有需要深入钻研的求解器, 用于自动求解推箱子关卡.

本文将由浅入深的介绍上面功能并提供实现的思路.

本文不会探讨的内容有:

- 推箱子的其他变种. 感兴趣的读者可以自行了解, 可以作为一种拓展.
- Rust 语言本身. 本文中出现的算法适用于其他编程语言. 因为示例代码使用了 Rust 语言, 可能会探讨 Rust 实现该算法的细节, 但不会专门介绍 Rust 语言的特性.
- 游戏引擎.

完整的项目代码位于:

- [ShenMian/soukoban](https://github.com/shenmian/soukoban): 推箱子相关算法实现.
- [ShenMian/sokoban-rs](https://github.com/shenmian/sokoban-rs): 推箱子游戏实现, 使用 [Bevy] 引擎.

[bevy]: https://github.com/bevyengine/bevy
220 changes: 220 additions & 0 deletions src/level/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# 关卡

推箱子关卡使用最广泛的格式为 XSB, 最初由 XSokoban 所使用. 该格式使用 ASCII 字符来表示地图元素, 并支持注释和附加元数据.
以关卡 `Boxworld #1` 为例:

![Boxworld #1](../assets/boxworld_1.png)

其 XSB 格式关卡的数据如下:

```txt
;Level 1
__###___
__#.#___
__#-####
###$-$.#
#.-$@###
####$#__
___#.#__
___###__
Title: Boxworld 1
Author: Thinking Rabbit
```

- 第 1 行, 以 `;` 开头的单行注释.
- 第 2-9 行, 使用 ASCII 字符表示的地图数据.
- 第 10-11 行, 包括关卡标题和作者的元数据.

| ASCII 符号 | 描述 |
| ----------------- | -------------- |
| `<SPACE>`/`-`/`_` | Floor |
| `#` | Wall |
| `$` | Box |
| `.` | Goal |
| `@` | Player |
| `+` | Player on goal |
| `*` | Box on goal |

除了上面的元数据, 还有一种用于多行注释的特殊元数据. 内容通过 `comment:``comment-end:` 包裹.

## 表示地图

地图共包含 5 种元素, 这些元素可能在一个格子内叠加(比如玩家位于目标上). 因此可以使用比特位来表示地图中的每个格子包含哪些元素.
创建用于表示地图元素的比特位:

```rs
use bitflags::bitflags;

bitflags! {
pub struct Tiles: u8 {
const Floor = 1 << 0;
const Wall = 1 << 1;
const Box = 1 << 2;
const Goal = 1 << 3;
const Player = 1 << 4;
}
}
```

使用一维数组来存储地图数据, 使用二维向量(数学)存储地图尺寸.

```rs
use nalgebra::Vector2;

pub struct Map {
data: Vec<Tiles>,
dimensions: Vector2<i32>,
// ... SKIP ...
}
```

使用一维数组而非二维数组是因为一维数组更平坦(flatten), 进行部分操作时更简单高效. 如:

```rs
impl Map {
pub fn with_dimensions(dimensions: Vector2<i32>) -> Self {
Self {
data: vec![Tiles::empty(); (dimensions.x * dimensions.y) as usize],
dimensions,
// ... SKIP ...
}
}
// ... SKIP ...
}
```

因为只需要操作一个数组(而非 n 个数组), 进行调整地图尺寸等操作的代码会更简单高效.
而且能确保动态数组的元素是紧密排列的, 根据数据局部性原理, 读取数据的性能通常会更好.

重载下标运算符, 以便后续直接通过二维坐标访问地图元素.

```rs
impl Index<Vector2<i32>> for Map {
type Output = Tiles;
fn index(&self, position: Vector2<i32>) -> &Tiles {
&self.data[(position.y * self.dimensions.x + position.x) as usize]
}
}

impl IndexMut<Vector2<i32>> for Map {
fn index_mut(&mut self, position: Vector2<i32>) -> &mut Tiles {
&mut self.data[(position.y * self.dimensions.x + position.x) as usize]
}
}
```

## 表示关卡

关卡数据可分为三个部分: 地图数据, 元数据和注释. 其中注释可以作为元数据.
元数据是一个键值对的集合, 因此可以使用 HashMap 来存储.
可以使用下面的结构体存储关卡数据:

```rs
pub struct Level {
map: Map,
metadata: HashMap<String, String>,
// ... SKIP ...
}
```

将地图从关卡中拆分是因为地图涉及大量的关联函数, 如果都放在 `Level` 里会导致其变得过于庞大.
为了提升代码的可读性和可维护性, 将相关代码拆分到 `Map` 中, 并将 `Level` 的 Deref 操作指向 `Map`. 并通过实现 [Deref trait], 使得用户能够透明地引用到 `Level` 内部的 Map.

## 行程编码(Run-length encoding)

[行程编码](run-length encoding, RLE)经常被用于压缩推箱子的关卡和解决方案.

```txt
###
#.###
#*$ #
# @ #
#####
```

经 RLE 编码后可得:

```txt
3#
#.3#
#*$-#
#--@#
5#
```

可以看出, 虽然编码后的关卡有更小的体积, 但不再能直观地看出关卡的结构.

RLE 编码后的关卡通常还会使用 `|` 来分割行, 而非 `\n`. 使其看上去更加紧凑:

```txt
3#|#.3#|#*$-#|#--@#|5#
```

## 特殊关卡

### 玩家不可达区域存在箱子

![Sasquatch #41](../assets/sasquatch_41.png)

```txt
#####
# #
### ########
## *** # # #
# * * ## # #####
## *** ## # ## ##
### #### # # # #
# # # # ####$ $###
## ## # ## $...$ ##
##### # ## .@. #
# # # $...$ ##
########$ $###
# #
#####
```

### 存在只有空元素的行

!["Steaming Hot" by David Buchweitz](../assets/steaming_hot.png)

```txt
# #
# # #
# # #
# # #
# # #
# # #
# # #
-
##########
#........####
# $$$$$$$# #
#.$......# #
# $$$$$$ # #
#......$+# #
#$$$$$$$ # #
# ####
##########
```

### 无完整外墙

部分推箱子程序支持无完整外墙的关卡.
在本文中, 这种关卡属于无效关卡. 但可以通过为其添加外墙的方式来转换为有效关卡.

!["No walls" by Rincewind](../assets/no_walls_level.png)

```txt
* ** *
**
**@$.*
**
* ** *
```

## 参考

- <http://sokobano.de/wiki/index.php?title=Level_format>

[Deref trait]: https://doc.rust-lang.org/std/ops/trait.Deref.html
[行程编码]: https://en.wikipedia.org/wiki/Run-length_encoding
Loading

0 comments on commit 125793f

Please sign in to comment.