diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..22fe843 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7585238 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +book diff --git a/README.md b/README.md new file mode 100644 index 0000000..a83bfa3 --- /dev/null +++ b/README.md @@ -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 diff --git a/book.toml b/book.toml new file mode 100644 index 0000000..dcf9bcc --- /dev/null +++ b/book.toml @@ -0,0 +1,10 @@ +[book] +title = "Sokoban Tutorial" +authors = ["ShenMian "] +language = "cn" +multilingual = false +src = "src" + +[output.html] +git-repository-url = "https://github.com/ShenMian/sokoban-tutorial" +git-repository-icon = "fa-github" diff --git a/src/SUMMARY.md b/src/SUMMARY.md new file mode 100644 index 0000000..4a2c020 --- /dev/null +++ b/src/SUMMARY.md @@ -0,0 +1,13 @@ +# Summary + +[介绍](introduction.md) + +- [关卡](level/README.md) + - [表示](level/representation.md) + - [行程编码](level/run_length_encoding.md) + - [构造](level/construction.md) + - [标准化](level/normalization.md) +- [动作]() +- [求解器](solver/README.md) +- [资源](resources.md) +- [术语表](glossary.md) diff --git a/src/assets/boxworld_1.png b/src/assets/boxworld_1.png new file mode 100644 index 0000000..c3ed1b7 Binary files /dev/null and b/src/assets/boxworld_1.png differ diff --git a/src/assets/no_walls_level.png b/src/assets/no_walls_level.png new file mode 100644 index 0000000..71d2db7 Binary files /dev/null and b/src/assets/no_walls_level.png differ diff --git a/src/assets/sasquatch_41.png b/src/assets/sasquatch_41.png new file mode 100644 index 0000000..cca031a Binary files /dev/null and b/src/assets/sasquatch_41.png differ diff --git a/src/assets/steaming_hot.png b/src/assets/steaming_hot.png new file mode 100644 index 0000000..9114135 Binary files /dev/null and b/src/assets/steaming_hot.png differ diff --git a/src/glossary.md b/src/glossary.md new file mode 100644 index 0000000..eac5e67 --- /dev/null +++ b/src/glossary.md @@ -0,0 +1,6 @@ +# 术语表 + +| 中文 | 英文 | +| ----------- | -------- | +| 求解器 | solver | +| 解/解决方案 | solution | diff --git a/src/introduction.md b/src/introduction.md new file mode 100644 index 0000000..e4baad2 --- /dev/null +++ b/src/introduction.md @@ -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 diff --git a/src/level/README.md b/src/level/README.md new file mode 100644 index 0000000..6e9dab8 --- /dev/null +++ b/src/level/README.md @@ -0,0 +1,106 @@ +# 关卡 + +推箱子关卡使用最广泛的格式为 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 符号 | 描述 | +| ----------------- | -------------- | +| ``/`-`/`_` | Floor | +| `#` | Wall | +| `$` | Box | +| `.` | Goal | +| `@` | Player | +| `+` | Player on goal | +| `*` | Box on goal | + +除了上面的元数据, 还有一种用于多行注释的特殊元数据. 内容通过 `comment:` 和 `comment-end:` 包裹. + +关卡可能通过行程编码压缩, 详情请参见[行程编码](run_length.md). + +## 特殊关卡 + +### 玩家不可达区域存在箱子 + +![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 +* ** * + ** +**@$.* + ** +* ** * +``` + +## 参考 + +- diff --git a/src/level/construction.md b/src/level/construction.md new file mode 100644 index 0000000..51b79fd --- /dev/null +++ b/src/level/construction.md @@ -0,0 +1,383 @@ +# 构建 + +## 从字符串构建单个关卡 + +关卡解析可以分为两部分: 地图解析/元数据和注释解析. + +### 解析关卡数据 + +关联函数 `Level::from_str` 只负责解析元数据和注释, 地图数据的进一步解析则由 `Map::from_str` 负责. + +```rs +impl Level { + pub fn from_str(str: &str) -> Result { + let mut map_offset = 0; + let mut map_len = 0; + let mut metadata = HashMap::new(); + let mut comments = String::new(); + let mut in_block_comment = false; + for line in str.split_inclusive(['\n', '|']) { + if map_len == 0 { + map_offset += line.len(); + } + + let trimmed_line = line.trim(); + if trimmed_line.is_empty() { + continue; + } + + // Parse comments + if in_block_comment { + if trimmed_line.to_lowercase().starts_with("comment-end") { + // Exit block comment + in_block_comment = false; + continue; + } + comments += trimmed_line; + comments.push('\n'); + continue; + } + if let Some(comment) = trimmed_line.strip_prefix(';') { + comments += comment.trim_start(); + comments.push('\n'); + continue; + } + + // Parse metadata + if let Some((key, value)) = trimmed_line.split_once(':') { + let key = key.trim().to_lowercase(); + let value = value.trim(); + + if key == "comment" { + if value.is_empty() { + // Enter block comment + in_block_comment = true; + } else { + comments += value; + comments.push('\n'); + } + continue; + } + + if metadata.insert(key.clone(), value.to_string()).is_some() { + return Err(ParseLevelError::DuplicateMetadata(key)); + } + continue; + } + + // Discard line that are not map data (with RLE) + if !is_xsb_string(trimmed_line) { + if map_len != 0 { + return Err(ParseMapError::InvalidCharacter( + trimmed_line + .chars() + .find(|&c| !is_xsb_symbol_with_rle(c)) + .unwrap(), + ) + .into()); + } + continue; + } + + if map_len == 0 { + map_offset -= line.len(); + } + map_len += line.len(); + } + if !comments.is_empty() { + debug_assert!(!metadata.contains_key("comments")); + metadata.insert("comments".to_string(), comments); + } + if in_block_comment { + return Err(ParseLevelError::UnterminatedBlockComment); + } + if map_len == 0 { + return Err(ParseLevelError::NoMap); + } + + Ok(Self { + map: Map::from_str(&str[map_offset..map_offset + map_len])?, + metadata, + // ... SKIP ... + }) + } + // ... SKIP ... +} +``` + +因为只需要对字符串进行解析, 所以参数类型为 `&str`. 在此过程中, 除了存储解析得到的注释和元数据外, 不涉及额外的动态内存分配. +解析得到的注释将被视作键为 "comments" 的元数据. + +### 解析地图数据 + +解析地图数据可以分为以下几个部分: + +1. **裁剪空白部分**: 首先, 移除每行右侧的空白字符. 随后, 确定地图左侧的最小缩进量(即每行左侧空白字符的最小数量), 并据此剔除左侧的多余空白. +2. **计算地图尺寸**. +3. **RLE 解码**: 如果地图数据经过 RLE 编码, 进行解码操作. +4. **解析地图数据**: 将转换后的数据写入缓冲区中. +5. **填充地板**. 从玩家位置开始, 以墙为边界, 使用洪水填充算法填充地板. + +```rs +impl Map { + pub fn from_str(str: &str) -> Result { + debug_assert!(!str.trim().is_empty(), "string is empty"); + + // Calculate map dimensions and indentation + let mut indent = i32::MAX; + let mut dimensions = Vector2::::zeros(); + let mut buffer = String::with_capacity(str.len()); + for line in str.split(['\n', '|']) { + let mut line = line.trim_end().to_string(); + if line.is_empty() { + continue; + } + // If the `line` contains digits, perform RLE decoding + if line.chars().any(char::is_numeric) { + line = rle_decode(&line).unwrap(); + } + dimensions.x = dimensions.x.max(line.len() as i32); + dimensions.y += 1; + indent = indent.min(line.chars().take_while(char::is_ascii_whitespace).count() as i32); + buffer += &(line + "\n"); + } + dimensions.x -= indent; + + let mut instance = Map::with_dimensions(dimensions); + + // Parse map data + let mut player_position: Option> = None; + for (y, line) in buffer.lines().enumerate() { + // Trim map indentation + let line = &line[indent as usize..]; + for (x, char) in line.chars().enumerate() { + let position = Vector2::new(x as i32, y as i32); + instance[position] = match char { + ' ' | '-' | '_' => Tiles::empty(), + '#' => Tiles::Wall, + '$' => { + instance.box_positions.insert(position); + Tiles::Box + } + '.' => { + instance.goal_positions.insert(position); + Tiles::Goal + } + '@' => { + if player_position.is_some() { + return Err(ParseMapError::MoreThanOnePlayer); + } + player_position = Some(position); + Tiles::Player + } + '*' => { + instance.box_positions.insert(position); + instance.goal_positions.insert(position); + Tiles::Box | Tiles::Goal + } + '+' => { + if player_position.is_some() { + return Err(ParseMapError::MoreThanOnePlayer); + } + player_position = Some(position); + instance.goal_positions.insert(position); + Tiles::Player | Tiles::Goal + } + _ => return Err(ParseMapError::InvalidCharacter(char)), + }; + } + } + if instance.box_positions.len() != instance.goal_positions.len() { + return Err(ParseMapError::BoxGoalMismatch); + } + if instance.box_positions.is_empty() { + return Err(ParseMapError::NoBoxOrGoal); + } + if let Some(player_position) = player_position { + instance.player_position = player_position; + } else { + return Err(ParseMapError::NoPlayer); + } + + instance.add_floors(instance.player_position); + + Ok(instance) + } + // ... SKIP ... +} +``` + +### 错误处理 + +在解析地图数据的过程中, 还需要检查可能发生的错误. +许多错误均可以在解析数据的时候顺带检查, 只会带来很小的额外开销. + +## 从字符串构建多个关卡 + +多个关卡解析就是将字符串以关卡为单位进行切片, 然后再使用单个关卡解析方法逐个解析. +这种方法将单个关卡的解析和多个关卡之间的分割进行了解耦, 有利于后续实现惰性关卡解析. + +```rs +impl Map { + pub fn load(str: &str) -> impl Iterator> + '_ { + Self::to_groups(str).map(Self::from_str) + } + + pub fn load_nth(str: &str, id: usize) -> Result { + let group = Self::to_groups(str).nth(id - 1).unwrap(); + Self::from_str(group) + } + + fn to_groups(str: &str) -> impl Iterator + '_ { + str.split_inclusive(['\n', '|']).filter_map({ + let mut offset = 0; + let mut len = 0; + let mut in_block_comment = false; + let mut has_map_data = false; + move |line| { + len += line.len(); + + let trimmed_line = line.trim(); + if !in_block_comment && (trimmed_line.is_empty() || !line.ends_with(['\n', '|'])) { + let group = &str[offset..offset + len - 1]; + offset += len; + len = 0; + if group.is_empty() || !has_map_data { + return None; + } + has_map_data = false; + Some(group) + } else { + if in_block_comment { + if trimmed_line.to_lowercase().starts_with("comment-end") { + // Exit block comment + in_block_comment = false; + } + return None; + } + if let Some(value) = trimmed_line.to_lowercase().strip_prefix("comment:") { + if value.trim_start().is_empty() { + // Enter block comment + in_block_comment = true; + } + return None; + } + if has_map_data || !is_xsb_string(trimmed_line) { + return None; + } + + has_map_data = true; + + None + } + } + }) + } +} +``` + +关联函数 `to_groups` 接受包含多个关卡的字符串, 返回包含单个关卡的字符串切片的迭代器. + +这种实现方式有以下优点: + +- 可以支持从大文件中流式读取关卡, 需要稍加修改以支持 [BufReader]. +- 读取第 n 个关卡. 跳过前 n-1 个关卡, 只对第 n 个关卡的数据进行解析. + +## 从解决方案构建关卡 + +解析地图数据可以分为以下几个部分: + +1. **计算地图尺寸**: 地图的尺寸等于玩家移动范围加上 1, 以包含外墙. +2. **模拟玩家移动**: 记录三组数据, 分别是: *当前箱子位置*和箱子初始位置. + + 玩家移动到的位置设为地板. + 若玩家推动了箱子, 且该箱子移动前的位置不再*当前箱子位置*中, 添加到箱子位置中. + 箱子当前位置在模拟结束后, *当前箱子位置*即最终箱子位置. 若解决方案正确, 那么最终箱子位置与目标位置相同. + +3. **在地板周围添加墙壁**: 在地板周围添加墙壁, 形成完整的关卡结构. +4. **验证解决方案**: 在构建的关卡里验证解决方案. 若验证不通过, 则表示解决方案不正确. + +```rs +impl Map { + pub fn from_actions(actions: &Actions) -> Result { + let mut min_position = Vector2::::zeros(); + let mut max_position = Vector2::::zeros(); + + // Calculate the dimensions of the player's movement range + let mut player_position = Vector2::zeros(); + for action in &**actions { + player_position += &action.direction().into(); + min_position = min_position.zip_map(&player_position, |a, b| a.min(b)); + max_position = max_position.zip_map(&player_position, |a, b| a.max(b)); + } + + // Reserve space for walls + min_position -= Vector2::new(1, 1); + max_position += Vector2::new(1, 1); + + if min_position.x < 0 { + player_position.x = min_position.x.abs(); + } + if min_position.y < 0 { + player_position.y = min_position.y.abs(); + } + + let dimensions = min_position.abs() + max_position.abs() + Vector2::new(1, 1); + let mut instance = Map::with_dimensions(dimensions); + + // The initial position of boxes are the box positions, and the final position + // of boxes are the goal positions + let mut initial_box_positions = HashSet::new(); + let mut final_box_positions = HashSet::new(); + + let mut final_player_position = player_position; + for action in &**actions { + instance[final_player_position] = Tiles::Floor; + final_player_position += &action.direction().into(); + if action.is_push() { + // The player pushed the box when moving, which means there is a box at the + // player's current location + if !final_box_positions.contains(&final_player_position) { + final_box_positions.insert(final_player_position); + initial_box_positions.insert(final_player_position); + } + final_box_positions.remove(&final_player_position); + final_box_positions.insert(final_player_position + &action.direction().into()); + } + } + instance[final_player_position] = Tiles::Floor; + + let box_positions = initial_box_positions; + let goal_positions = final_box_positions; + if box_positions.is_empty() { + return Err(ParseMapError::NoBoxOrGoal); + } + + instance[player_position].insert(Tiles::Player); + for box_position in &box_positions { + instance[*box_position].insert(Tiles::Box); + } + for goal_position in &goal_positions { + instance[*goal_position].insert(Tiles::Goal); + } + + instance.add_walls_around_floors(); + + instance.player_position = player_position; + instance.box_positions = box_positions; + instance.goal_positions = goal_positions; + + // Verify solution + let mut level = Level::from_map(instance.clone()); + for action in &**actions { + level + .do_move(action.direction()) + .map_err(|_| ParseMapError::InvalidActions)?; + } + + Ok(instance) + } +} +``` + +[bufreader]: https://doc.rust-lang.org/std/io/struct.BufReader.html diff --git a/src/level/normalization.md b/src/level/normalization.md new file mode 100644 index 0000000..fc68147 --- /dev/null +++ b/src/level/normalization.md @@ -0,0 +1,84 @@ +# 标准化 + +有些关卡为了美观会在原关卡的基础上添加一些装饰, 这些装饰性的元素在本质上并不影响关卡的解法. +标准化的目的是移除这些与求解无关或冗余的元素. + +理想状态下, 任何具有相同步数最优解的关卡都可以被标准化为同一个关卡. 理论上可以先对关卡解析自动求解, 获得最优解决方案后再[从解决方案构建关卡](parse_level.md#从解决方案构建关卡)来得到标准化后的关卡. +但对于 NP 难的推箱子问题来说, 自动求解最优解决方案通常是耗时且困难的, 因此还需要一种不依赖于关卡解决方案的快速标准化方法. 尽管该方法得到的标准化结果并不完美. + +关卡的标准化可以分为以下几个步骤: + +1. 将不可移动的箱子变为墙. + + 箱子可能默认就处于死锁状态, 可以当作墙体处理. + +2. 将无需使用的地板变为墙. + + 如果一个地板被三面墙包围, 属于死胡同, 可以当作墙体处理. + +3. 移除无法到达的墙. + + 部分墙无法与玩家产生交互, 因此移除这部分墙不影响关卡的解决方案. + +4. 标准化外墙. + + 外墙有不同的包围类型, 如: + + ```txt + ### + #@$.# + ### + + ##### + #@$.# + ##### + ``` + + 应该统一为其中一种类型, 通常使用下面的类型. + +5. 移除无法到达的箱子. + + 部分箱子无法与玩家产生交互, 因此移除这部分箱子不影响关卡的解决方案. + +6. 收紧地图尺寸. + + 地图中的元素被删除可能导致地图尺寸缩小. + +7. 对旋转和翻转标准化. + + 将经过不同旋转和翻转的关卡标准化为同一个关卡. + 一个简单的方法是计算不同旋转和翻转后地图的哈希值,选择哈希值最小的版本. + +8. 玩家初始位置标准化. **(注意: 可能移动玩家位置, 进而改变关卡的解决方案)** + + 需要对先对玩家的位置进行标准化. 因为即使玩家位置不一样, 但都在同一个区域也可以转变为同一个关卡. + 一个简单的玩家位置标准化方法是将玩家的位置设为玩家可达区域的位置中 Y 坐标最小, 其次 X 坐标最小的位置. + +## 激进的标准化 + +激进的标准化可能改变关卡的解. +以最简单的关卡为例: + +```txt +##### +#@$.# +##### +``` + +因为玩家开始的位置三面有墙, 位于死胡同, 只能向右移动. 得到结果: + +```txt +##### +# @*# +##### +``` + +玩家左侧死胡同属于无用的区域, 使用墙体填充. 玩家右侧位于目标上的箱子处于死锁状态, 属于无用的箱子和目标, 使用墙体填充. 最后移除多余的墙体得到激进的标准化结果: + +```txt +### +#@# +### +``` + +这是一个最简单的非标准关卡, 因为其没有箱子和目标, 解为空. diff --git a/src/level/representation.md b/src/level/representation.md new file mode 100644 index 0000000..9c2399d --- /dev/null +++ b/src/level/representation.md @@ -0,0 +1,87 @@ +# 表示 + +## 表示地图 + +地图共包含 5 种元素, 这些元素可能在一个格子内叠加(比如玩家位于目标上). +最小的数据存储单元字节包含 8 个比特, 足以表示 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, + dimensions: Vector2, + // ... SKIP ... +} +``` + +使用一维数组而非二维数组是因为一维数组更平坦(flatten), 进行部分操作时更简单高效. 如: + +```rs +impl Map { + pub fn with_dimensions(dimensions: Vector2) -> Self { + Self { + data: vec![Tiles::empty(); (dimensions.x * dimensions.y) as usize], + dimensions, + // ... SKIP ... + } + } + // ... SKIP ... +} +``` + +因为只需要操作一个数组(而非 n 个数组), 进行调整地图尺寸等操作的代码会更简单高效. +而且能确保动态数组的元素是紧密排列的, 根据数据局部性原理, 读取数据的性能通常会更好. + +重载下标运算符, 以便后续直接通过二维坐标访问地图元素. + +```rs +impl Index> for Map { + type Output = Tiles; + fn index(&self, position: Vector2) -> &Tiles { + &self.data[(position.y * self.dimensions.x + position.x) as usize] + } +} + +impl IndexMut> for Map { + fn index_mut(&mut self, position: Vector2) -> &mut Tiles { + &mut self.data[(position.y * self.dimensions.x + position.x) as usize] + } +} +``` + +## 表示关卡 + +关卡数据可分为三个部分: 地图数据, 元数据和注释. 其中注释可以作为元数据. +元数据是一个键值对的集合, 因此可以使用 HashMap 来存储. +可以使用下面的结构体存储关卡数据: + +```rs +pub struct Level { + map: Map, + metadata: HashMap, + // ... SKIP ... +} +``` + +将地图从关卡中拆分是因为地图涉及大量的关联函数, 如果都放在 `Level` 里会导致其变得过于庞大. +为了提升代码的可读性和可维护性, 将相关代码拆分到 `Map` 中, 并将 `Level` 的 Deref 操作指向 `Map`. 并通过实现 [Deref trait], 使得用户能够透明地引用到 `Level` 内部的 Map. + +[Deref trait]: https://doc.rust-lang.org/std/ops/trait.Deref.html diff --git a/src/level/run_length_encoding.md b/src/level/run_length_encoding.md new file mode 100644 index 0000000..323a4b1 --- /dev/null +++ b/src/level/run_length_encoding.md @@ -0,0 +1,115 @@ +# 行程编码(Run-length encoding) + +[行程编码](run-length encoding, RLE)经常被用于压缩推箱子的关卡和解决方案. + +```txt +### +#.### +#*$ # +# @ # +##### +``` + +经 RLE 编码后可得: + +```txt +3# +#.3# +#*$-# +#--@# +5# +``` + +可以看出, 虽然编码后的关卡有更小的体积, 但不再能直观地看出关卡的结构. + +RLE 编码后的关卡通常还会使用 `|` 来分割行, 而非 `\n`. 使其看上去更加紧凑: + +```txt +3#|#.3#|#*$-#|#--@#|5# +``` + +只需要对原本的语句进行修改即可提供对 `|` 分割行的支持: + +```rs +for line in str.lines() { ... SKIP ... } + +for line in str.split(['\n', '|']) { ... SKIP ... } +``` + +## 编码 + +下面是一个简单的 RLE 编码函数的实现: + +```rs +pub fn rle_encode(str: &str) -> Result { + let mut result = String::new(); + let mut chars = str.chars().peekable(); + let mut count = 0; + while let Some(char) = chars.next() { + if char.is_numeric() { + return Err(EncodeRleError::InvalidCharacter(char)); + } + count += 1; + if chars.peek() != Some(&char) { + if count > 1 { + result.push_str(&count.to_string()); + } + result.push(char); + count = 0; + } + } + Ok(result) +} +``` + +该方法不会使用括号包裹重复的相连字串以提高压缩率. + +## 解码 + +下面是一个 RLE 解码函数的实现: + +```rs +pub fn rle_decode(str: &str) -> Result { + let mut result = String::new(); + + let mut length_string = String::new(); + let mut iter = str.chars(); + while let Some(char) = iter.next() { + if char.is_ascii_digit() { + length_string.push(char); + continue; + } + let mut token = String::new(); + if char == '(' { + let mut nesting_level = 0; + for char in &mut iter { + if char == '(' { + nesting_level += 1; + } else if char == ')' { + if nesting_level == 0 { + break; + } + nesting_level -= 1; + } + token.push(char); + } + } else { + token = char.to_string(); + } + let length = length_string.parse().unwrap_or(1); + result += &token.repeat(length); + length_string.clear(); + } + if !length_string.is_empty() { + return Err(DecodeRleError::EndWithDigits( + length_string.parse().unwrap(), + )); + } + if result.contains('(') { + return rle_decode(&result); + } + Ok(result) +} +``` + +[行程编码]: https://en.wikipedia.org/wiki/Run-length_encoding diff --git a/src/resources.md b/src/resources.md new file mode 100644 index 0000000..2e379ef --- /dev/null +++ b/src/resources.md @@ -0,0 +1,5 @@ +# 资源 + +- +- +- diff --git a/src/solver/README.md b/src/solver/README.md new file mode 100644 index 0000000..f7122b7 --- /dev/null +++ b/src/solver/README.md @@ -0,0 +1,47 @@ +# 求解器 + +顾名思义, 求解器是用于自动求解推箱子关卡的程序. + +寻路算法是在**路径点**组成的图中寻找一条从**初始位置**到**最终位置**的路径. +求解器则是在**状态**组成的图中寻找一条从**初始状态**到**最终状态**的路径. + +本文将描述如何创建一个基于 [A* 搜索算法]的求解器. + +## 表示状态 + +顾名思义, 状态用于表示关卡的状态. + +```rs +pub struct State { + pub player_position: Vector2, + pub box_positions: HashSet>, +} +``` + +可以看出状态仅包含了玩家的位置和箱子的位置, 因为玩家和箱子是关卡中可移动对象. 保存它们的位置足以描述整个关卡的状态. 其他元素, 如墙壁/目标位置等是静态的,它们的位置不会改变, 因此只需要从关卡的初始状态中获取它们的位置. + +状态是图里的节点, 从初始状态开始探索其相邻的节点, 即[邻域]. + +通过下面函数来生成当前状态的派生状态, 即相邻的状态: + +```rs +impl State { + pub fn successors(&self, solver: &Solver) -> Vec { + // ... SKIP ... + } +} +``` + +通过启发式算法优先探索最接近最终状态的节点. + +TODO + +## 优化器 + +对已知的路径进行[局部搜索], 以寻找更佳的路径. + +TODO + +[A* 搜索算法]: https://en.wikipedia.org/wiki/A*_search_algorithm +[邻域]: https://en.wikipedia.org/wiki/Neighbourhood_(graph_theory) +[局部搜索]: https://en.wikipedia.org/wiki/Local_search_(optimization)