章节项目

谨向读者 Avtem 致以敬意,他构思并共同参与了本项目。

项目时间:

让我们实现经典游戏「15 数码」!

在 15 数码中,游戏开始时,你会看到一个随机打乱顺序的 4×4 方格棋盘,其中 15 个格子分别标有数字 1 至 15,另有一个格子为空。例如:

 15   1   4

2 5 9 12 7 8 11 14 10 13 6 3 在此例中,缺失的格子位于左上角。

每回合,玩家需选择一张与空位相邻的数字格,并将其滑入空位。

游戏目标是把所有数字按顺序排列,并将空位移至右下角:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
你可以在该网站上先玩几局,以熟悉游戏规则与实现方式。

在我们实现的版本中,每回合用户输入一个单字母命令。共有 5 种有效命令:

w —— 向上滑动
a —— 向左滑动
s —— 向下滑动
d —— 向右滑动
q —— 退出游戏
由于程序较长,我们将分阶段完成。

另:每一步,我们将给出「目标」与「任务」。目标阐述本步期望达到的效果及相关信息;任务则提供实现细节与提示。

任务默认隐藏,鼓励你先根据目标与示例输出或示例程序自行尝试。若无从下手或卡住,可展开任务查看提示,助你继续推进。

步骤 #1

由于程序较大,我们首先进行设计练习。

作者按
若你尚未有太多预先设计的经验,可能感到吃力,这很正常。重点不在于一次做对,而在于参与并学习。

目标:记录本程序的关键需求,并高层次规划程序结构。分为三部分:

A) 程序顶层需完成哪些任务?以下给出若干示例供启思:

棋盘相关:
显示游戏棋盘
……

用户相关:
获取用户命令
……

B) 你将使用哪些主要类(class)或命名空间(namespace)来实现 A 中的项目?main() 函数又将承担哪些职责?

你可以绘制图示,或使用如下两张表:

主要类/命名空间/main 实现的顶层任务 成员
class Board 显示游戏棋盘
…… ……
function main 主游戏循环逻辑
…… ……

C)(附加分)能否想到任何辅助类或功能,使上述实现更简洁、内聚?

若此练习令你颇费周折,亦无妨。目的主要在于动手前先思考。

现在,开始编码!

步骤 #2

目标:能够在屏幕上显示单个格子。

我们的棋盘是 4×4 的可滑动数字格。因此,定义 Tile 类来表示 4×4 网格中的某一个数字格或空位。每个 Tile 应能:

接受一个数字或被设为空位;
判断自身是否为空位;
以恰当间距输出至控制台(保证棋盘对齐)。参见下方示例输出。

下述代码应能编译,并产生所示结果:

int main()
{
    Tile tile1{ 10 };
    Tile tile2{ 8 };
    Tile tile3{ 0 }; // 空位
    Tile tile4{ 1 };

    std::cout << "0123456789ABCDEF\n"; // 用于观察下一行空格数量
    std::cout << tile1 << tile2 << tile3 << tile4 << '\n';

    std::cout << std::boolalpha << tile1.isEmpty() << ' ' << tile3.isEmpty() << '\n';
    std::cout << "Tile 2 has number: " << tile2.getNum() << "\nTile 4 has number: " << tile4.getNum() << '\n';

    return 0;
}

预期输出(注意空格):

0123456789ABCDEF 10 8 1 false true Tile 2 has number: 8 Tile 4 has number: 1

步骤 #3

目标:创建并显示一个已排好序的 4×4 棋盘。

定义 Board 类表示 4×4 的格子网格。新创建的 Board 对象应处于「已解」状态。为了显示棋盘,先输出 g_consoleLines(见下方代码)个空行,再输出棋盘本身,借此把先前输出推出视野,使控制台仅保留当前棋盘。

为何初始化成已解状态?购买实体版 15 数码时,盘面通常已排好序——你需先手动打乱(随机滑动格子)再开始解题。我们将在程序中模拟此过程(后续步骤完成「打乱」)。

下述程序应能运行:

// 若棋盘未贴底,可增大空行数量
constexpr int g_consoleLines{ 25 };

// 此处放置你的代码

int main()
{
    Board board{};
    std::cout << board;

    return 0;
}

并输出:

(空行略)

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15

步骤 #4

目标:本步实现用户可反复输入游戏命令、处理无效输入,并实现退出命令。

游戏支持的 5 种命令(均以单字符输入):

‘w’ —— 向上滑动格子
‘a’ —— 向左滑动格子
‘s’ —— 向下滑动格子
‘d’ —— 向右滑动格子
‘q’ —— 退出游戏

用户运行程序时,应发生以下流程:

  1. 控制台打印已解棋盘。
  2. 程序反复获取有效命令;若输入无效命令或多余字符,则忽略。
  3. 对每个有效命令:
    • 输出 “Valid command: " 及用户所输字符;
    • 若为退出命令,额外输出 “\n\nBye!\n\n” 并终止程序。

由于用户输入例程无需维护状态,将其置于 UserInput 命名空间内实现。

程序输出应与下例完全一致:

(空行略)

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 w Valid command: w a Valid command: a s Valid command: s d Valid command: d f g h Valid command: q

Bye!

步骤 #5

目标:实现一个辅助类,以简化方向命令的处理。

完成上一步后,我们已能接受用户输入(字符 ‘w’、‘a’、‘s’、‘d’、‘q’)。这些字符在代码中类似「魔数」。虽然 UserInput 命名空间及 main() 中处理它们尚可,但不应让 Board 类也知晓 ‘s’ 的含义。

实现 Direction 辅助类,使其能表示基本方向(上、左、下、右)。operator- 应返回相反方向;operator« 可将方向输出至控制台。再提供一成员函数,返回随机方向对象。最后,在 UserInput 命名空间内添加函数,将方向命令字符(‘w’、‘a’、‘s’、‘d’)转为 Direction 对象。

越能用 Direction 取代方向命令字符,代码可读性越高。

修改上一步程序,使输出如下:

(空行略)

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 Generating random direction… up Generating random direction… down Generating random direction… up Generating random direction… left

Enter a command: w You entered direction: up a You entered direction: left s You entered direction: down d You entered direction: right q

Bye!

步骤 #6

目标:实现一个辅助类,以便更轻松地以坐标索引棋盘格子。

我们的棋盘是 4×4 的 Tile 网格,在 Board 类中以二维数组成员 m_tiles 存储。我们将通过 {x, y} 坐标访问指定格子。例如,左上角格子坐标为 {0, 0};其右侧格子为 {1, 0}(x 增 1,y 不变);再下一格为 {1, 1}。

由于需频繁使用坐标,创建 Point 辅助类存储 {x, y} 坐标对。该类应支持相等与不等比较。另实现成员函数 getAdjacentPoint,接收 Direction 对象,返回该方向上的邻接点。例如,Point{1, 1}.getAdjacentPoint(Direction::right) == Point{2, 1}。

保存上一步的 main(),下一步仍需使用。

下述代码应运行,且所有测试用例输出 true:

// 此处放置你的代码

// 注意:保存上一步的 main(),下一步仍需使用
int main()
{
    std::cout << std::boolalpha;
    std::cout << (Point{ 1, 1 }.getAdjacentPoint(Direction::up)    == Point{ 1, 0 }) << '\n';
    std::cout << (Point{ 1, 1 }.getAdjacentPoint(Direction::down)  == Point{ 1, 2 }) << '\n';
    std::cout << (Point{ 1, 1 }.getAdjacentPoint(Direction::left)  == Point{ 0, 1 }) << '\n';
    std::cout << (Point{ 1, 1 }.getAdjacentPoint(Direction::right) == Point{ 2, 1 }) << '\n';
    std::cout << (Point{ 1, 1 } != Point{ 2, 1 }) << '\n';
    std::cout << (Point{ 1, 1 } != Point{ 1, 2 }) << '\n';
    std::cout << !(Point{ 1, 1 } != Point{ 1, 1 }) << '\n';

    return 0;
}

步骤 #7

目标:本步为棋盘添加滑动功能。

先仔细观察滑动机制:

给定如下盘面:

 15   1   4

2 5 9 12 7 8 11 14 10 13 6 3 当用户键入 ‘w’ 时,唯一可上移的格子是数字 2。

移动后盘面变为:

2 15 1 4 5 9 12 7 8 11 14 10 13 6 3 本质上,我们将空位与数字 2 做了交换。

现将流程一般化:用户输入方向命令后,需:

  1. 定位空位。
  2. 自空位出发,找到与用户输入方向相反方向的邻接格。
  3. 若该邻接格有效(未越界),交换空位与该格。
  4. 若无效,则不做任何操作。

在 Board 类中添加成员函数 moveTile(Direction),并在步骤 5 的主循环中调用。若成功滑动,重绘棋盘。

步骤 #8

目标:本步完成游戏。随机化棋盘初始状态,并检测玩家胜利,胜利后输出祝贺信息并正常退出。

需注意随机化方式:并非所有随机排列均可解。例如,下图盘面无解:

1 2 3 4 5 6 7 8 9 10 11 12 13 15 14 若盲目随机排列数字,可能产生不可解盘面。实体版 15 数码通过「反复随机滑动」来打乱,从而保证可解——因为解法即为反向滑动。程序亦可采用相同方式:随机方向滑动若干次,必得可解盘面。

一旦玩家完成拼图,程序应输出 “\n\nYou won!\n\n” 并正常结束。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"