让每次移动遵守棋盘规则

C++Beginner
立即练习

介绍

为可玩的 C++ 终端仓库游戏实现合法行走和单箱推动。

你将制作一个固定地形与移动对象分离的小型终端游戏。你会实现核心 C++ 函数,然后实际游玩并检查行为。

行走时不穿过墙壁

本步骤先让你游玩完整示例,再让自己的玩家移动遵守棋盘规则。从 Terminal 标签页开始。若使用 WebIDE 编辑器,请打开其中的 Terminal > New Terminal;下面的命令都在这个 shell 中运行。cd 改变当前 shell 的工作目录,不会延续到新建的终端会话。

cd /home/labex/project/sokoban

准备好的 sample 是完整演示,与尚未完成的程序分开。./ 表示运行当前目录中的程序。

./sample

输入 d 并按 Enter,再输入 d 并按 Enter。这两步先走到箱子旁,再将箱子推上目标。屏幕出现 Solved!,$ 变为 *。输入 u 并按 Enter:玩家和箱子都回到之前的位置,消息变为 Not solved。输入 q 并按 Enter 返回 shell。每行只输入一个字母;空命令或未知命令会显示帮助,且不改变任何状态。这个参考程序展示最终效果;运行它不代表完成了自己的引擎。

准备好的 Game 类在 engine.hpp 中声明。Point 保存整数坐标 x 和 y:x 向右增大,y 向下增大。State 保存玩家和一个包含所有箱子的 std::vector<Point>。terrain[y][x] 只包含 # 墙壁、空格和 . 目标。const 使地形在构造后只读,因此移动的箱子不会抹掉目标。渲染时合并这两层:* 表示目标上的箱子,+ 表示目标上的玩家。

floor(point) 判断坐标是否位于棋盘内且不是墙壁。boxAt(point) 返回该处箱子的 vector 索引;没有箱子时返回 -1。这些辅助函数、加载、渲染和命令读取均已准备好。你在 engine.cpp 中实现 move、won,稍后再实现 undo;保留 engine.hpp、support.cpp、main.cpp、levels/ 和 sample。你可以添加辅助函数并使用等价算法。检查关注实际行为,不要求特定写法或路线。

阅读准备好的声明和初始代码:

cat engine.hpp
cat engine.cpp

初始程序拒绝每次移动。因此,下面这个有意执行的自检会打印 FAIL walk changes only the player 并以非零状态退出;这表示还需要实现功能,不是环境损坏:

bash /opt/labex-sokoban/check.sh walk

支持的地图是由墙封闭的矩形 ASCII 网格,至少三行、三列。地图恰有一个玩家、至少一个箱子,目标数量与箱子数量相同,且对象不重叠。符号为:# 墙壁、空格地面、. 目标、@ 玩家、$ 箱子、+ 目标上的玩家,以及 * 目标上的箱子。地图可以无解,也可以初始时已经完成。准备好的加载器会拒绝其他输入;你不需要编写解析器。

move(dx, dy) 只接受 (1,0)、(-1,0)、(0,1) 或 (0,-1)。其他任何整数对都必须返回 false,且不改变状态或历史。合法动作返回 true;非法动作返回 false,不改变任何内容。箱子 vector 中的顺序不影响占位含义。稍后的撤销会恢复该动作之前实际保存的完整状态,包括当时的箱子顺序。

先只实现行走。Point{...} 构造一个坐标值。&& 表示两个条件都必须成立;|| 表示任一个成立即可。在进行坐标相加之前显式列出四个方向,也能安全拒绝异常大的整数。>= 0 表示该格有箱子。此阶段箱子会阻挡行走;下一步才实现推动。各个 return false 分支都不改变状态。

要在终端写入完整文件,cat > engine.cpp 会将文本重定向到该文件并替换旧内容。带引号的 here-document 标记 <<'CPP' 让 shell 按原样读取后续各行,直到只有 CPP 的一行。请复制整条命令,包括结尾标记:

cat > engine.cpp <<'CPP'
#include "engine.hpp"

bool Game::move(int dx, int dy) {
    if (!((dx == -1 && dy == 0) || (dx == 1 && dy == 0) ||
          (dx == 0 && dy == -1) || (dx == 0 && dy == 1))) return false;
    Point next{state.player.x + dx, state.player.y + dy};
    if (!floor(next) || boxAt(next) >= 0) return false;
    state.player = next;
    return true;
}
bool Game::undo() { return false; }
bool Game::won() const { return false; }
CPP

编译这三个源文件。-std=c++17 选择 C++17,-Wall -Wextra 启用实用的警告,-o sokoban 指定可执行文件名。编译错误会包含行号;修正 engine.cpp 中的对应行(或重新完整替换文件),然后再次运行相同的构建命令。

g++ -std=c++17 -Wall -Wextra engine.cpp support.cpp main.cpp -o sokoban
./sokoban

输入 a 后按 Enter:左侧墙壁使程序显示 Blocked,棋盘保持不变。输入 d 后按 Enter:玩家向右走。第二个 d 仍被箱子挡住,这是本步骤的预期行为。u 显示 No earlier move:撤销尚未实现。输入 q 退出。

bash /opt/labex-sokoban/check.sh walk

All walk checks passed 确认四方向行走和安全拒绝均正确。检查会在临时目录编译你的实际引擎,也会在隔离游戏实例中运行现有的 sokoban 可执行文件。编辑后请重新构建 sokoban;可执行文件缺失或过期都表示交付尚未完成。检查不会移动正在游玩的游戏,也不会修改源文件。

推动一个箱子并保留目标

本步骤扩展引擎,使其能推动一个箱子,并根据箱子的真实位置判断完成。继续使用 Terminal,工作目录为 /home/labex/project/sokoban。每次运行都会在内存中开始新游戏;编辑或重新构建程序不会保存正在进行的游戏。

推动之前,先检查箱子后方的格子。它必须是没有其他箱子的地面或目标。先完成拒绝判断,再一起更新箱子与玩家。这样就不会穿墙推箱,也不会推动一串两个箱子。箱子索引使用 int,因为 -1 表示不存在;只有检查 box >= 0 后才能用它访问 vector。

won() const 只观察游戏而不修改它。范围循环 for (Point box : state.boxes) 按值访问每个箱子的坐标。每个箱子都必须位于固定地形中的 . 上;两箱地图只完成一个箱子还不够。按本课契约,空箱子 vector 永远不算完成。每次都会重新计算胜利状态,因此箱子离开目标后结果会再次变为假。本实验中 undo 仍有意拒绝;历史是下一个实验的新任务。

用完整的移动与完成判断实现替换 engine.cpp:

cat > engine.cpp <<'CPP'
#include "engine.hpp"

bool Game::move(int dx, int dy) {
    if (!((dx == -1 && dy == 0) || (dx == 1 && dy == 0) ||
          (dx == 0 && dy == -1) || (dx == 0 && dy == 1))) return false;
    Point next{state.player.x + dx, state.player.y + dy};
    if (!floor(next)) return false;
    int box = boxAt(next);
    Point beyond{next.x + dx, next.y + dy};
    if (box >= 0 && (!floor(beyond) || boxAt(beyond) >= 0)) return false;
    if (box >= 0) state.boxes[box] = beyond;
    state.player = next;
    return true;
}
bool Game::undo() {
    return false;
}
bool Game::won() const {
    if (state.boxes.empty()) return false;
    for (Point box : state.boxes) {
        if (terrain[box.y][box.x] != '.') return false;
    }
    return true;
}
CPP
g++ -std=c++17 -Wall -Wextra engine.cpp support.cpp main.cpp -o sokoban
./sokoban

依次输入 d、d,每次都按 Enter。棋盘显示 * 和 Solved!。再输入一个 d:箱子离开目标,玩家站到目标上,显示为 +;游戏返回 Not solved。再按一次 d 会被拒绝,因为箱子将进入墙壁。固定目标仍在玩家脚下。输入 q。

将文件路径作为程序的可选参数,试试准备好的两箱仓库:

./sokoban levels/warehouse.txt

一条可行路线是 w、s、d、d、w,每次输入后按 Enter。也可以选择其他合法路线;不要求最短解。两个箱子都必须显示为 *,才会出现 Solved!。输入 q。

关卡路径拼错时会显示 Cannot open level: ... 并退出,不修改任何关卡文件。要有意观察这种普通错误,请运行:

./sokoban levels/missing.txt

将路径改为 levels/warehouse.txt,重新运行前面的命令即可恢复。这只需纠正文件名,不需要重置环境。重新打开关卡会开始新的内存游戏。

bash /opt/labex-sokoban/check.sh move

All move checks passed 覆盖合法行走与推动、无效动作、目标保留、初始已完成地图,以及不同的两箱解法。保留引擎和准备好的文件;你已经有了可以实际游玩的移动系统。

总结

你将固定地形与移动对象分开,拒绝无效动作而不产生副作用,并完成了两个小仓库。下一个独立实验将加入历史记录和撤销。