21-y 第二十一章项目(todo)

向读者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 - 退出游戏

由于这将是一个较长的程序,我们将分阶段开发。

另需说明:每个步骤中,我们将呈现两项内容:目标与任务。目标定义该步骤试图达成的结果,并包含相关补充信息。任务则提供实现目标的具体方法与提示。

任务初始状态为隐藏,旨在鼓励您仅凭目标描述及示例输出/程序完成每个步骤。若不知如何着手或遇到瓶颈,可选择显示任务内容,它们将助您继续推进。


Step #1

步骤 #1

由于这将是一个较大的程序,我们先从设计练习开始。

作者注:
若您缺乏前期程序设计经验,可能会觉得有些困难。这是正常的。关键不在于设计是否完美,而在于参与并从中学习。

后续步骤将详细展开所有内容,若你完全不知所措,可跳过此步骤。

目标:记录该程序的核心需求,并规划程序的高层级结构框架。我们将分三部分完成:

A) 程序需要实现哪些核心功能?以下是几个参考方向:

棋盘相关:

  • 显示游戏棋盘

用户交互:

  • 接收用户指令

显示答案

棋盘相关:

* 显示游戏棋盘
* 显示单个棋子
* 随机化初始状态
* 滑动棋子
* 判定是否满足获胜条件

用户相关:

* 获取用户指令
* 处理无效输入
* 允许用户在获胜前退出

B) 您将使用哪些主要类或命名空间来实现步骤1中概述的项目?此外,您的main()函数将执行哪些操作?

您可以绘制示意图,或使用如下所示的两张表格:

Primary class/namespace/main Implements top-level items Members
class Board Display the game board
function main Main game logic loop

显示答案

Primary class/Namespace/mainImplements top-level itemsMembers (type)
class BoardDisplay the game board
Randomize the starting state
Slide tiles
Determine if win condition reached
2d array of Tile
class TileDisplay an individual tileint display number
namespace UserInputGet commands from user
Handle invalid input
none
function main()Main game logic loop
Allow user to quit before winning
none
  • 以下是上述选择背后的部分考量:
  • class Board:我们的游戏由4×4的方块网格构成。该类的主要目的是存储和管理方块的二维数组。此类还负责随机排列、移动方块、显示棋盘以及检查棋盘是否被解开。
  • class Tile:该类表示棋盘中的单个方块。采用类结构可重载输出运算符,按指定格式输出棋子。同时能通过命名规范的成员函数提升单个棋子相关代码的可读性。
  • namespace UserInput:该命名空间包含用户输入获取函数、输入有效性验证函数及无效输入处理函数。因其不涉及状态管理,故无需类结构。
  • 函数 main():此处编写主游戏循环,负责棋盘初始化、坐标获取、用户输入与指令处理,以及退出条件(用户获胜或输入退出指令)的处理。

我们还将使用两个辅助类。这些类的需求可能初看并不明显,若你尚未想到类似方案也无需担忧。辅助类的需求(或价值)往往在深入程序实现后才会显现。

C) (附加题) 你能否想到任何辅助类或功能,能使上述实现更简便或更具凝聚力?

显示解决方案

Helper class/Namespace What does this help with? Members (type)
class Point Indexing the game board tiles int x-axis and y-axis coordinates
class Direction Make working with Directional commands easier and more intuitive enum direction
  • 类 Point:访问二维瓦片数组中的特定瓦片需要两个索引。可将其视为 { x 轴, y 轴 } 索引对。此 Point 类实现此类索引对,以便轻松传递或返回索引对。

  • 方向类:用户将通过键盘输入单字母命令(字符)来推动瓦片朝基本方向移动(例如'w'=上,‘a’=左)。将这些字符命令转换为方向对象(代表基本方向)能使代码更直观,并避免代码中充斥字符常量(Direction::left比'a'更具语义)。

若此练习令你感到困难,不必介怀。此处的核心目标在于培养你在动手前先思考的习惯。

现在,是时候开始实现代码了!


Step #2

步骤 #2

目标:能够在屏幕上显示单个方块。

我们的游戏棋盘是一个可滑动的4×4方块网格。因此,创建一个Tile类来表示4×4网格中的编号方块或缺失方块将非常有用。每个方块应具备以下能力:

  • 被赋予编号或被设为缺失方块
  • 判断自身是否为缺失方块
  • 在控制台绘制时需保持适当间距(确保棋盘显示时方块对齐)。下方示例输出展示了方块间距的规范要求。

显示任务:
我们的Tile类应具备以下功能:

  • 默认构造函数
  • 可创建带显示值的Tile的构造函数。由于不使用0作为显示值,可将0用于标识缺失的Tile
  • getNum()访问函数,返回Tile存储的数值
  • isEmpty()成员函数,返回布尔值以指示当前Tile是否为缺失的Tile
  • operator<<运算符,用于显示瓦片存储的数值。

以下代码应能编译并产生您在代码下方所见的结果:

int main()
{
    Tile tile1{ 10 };
    Tile tile2{ 8 };
    Tile tile3{ 0 }; // the missing tile
    Tile tile4{ 1 };

    std::cout << "0123456789ABCDEF\n"; // to make it easy to see how many spaces are in the next line
    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

显示答案

#include <iostream>

class Tile
{
public:
    Tile() = default;
    explicit Tile(int number)
        :m_num(number)
    {
    }

    bool isEmpty() const
    {
        return m_num == 0;
    }

    int getNum() const { return m_num; }

private:
    int m_num { 0 };
};

std::ostream& operator<<(std::ostream& stream, Tile tile)
{
    if (tile.getNum() > 9) // if two digit number
        stream << " " << tile.getNum() << " ";
    else if (tile.getNum() > 0) // if one digit number
        stream << "  " << tile.getNum() << " ";
    else if (tile.getNum() == 0) // if empty spot
        stream << "    ";
    return stream;
}

int main()
{
    Tile tile1{ 10 };
    Tile tile2{ 8 };
    Tile tile3{ 0 }; // the missing tile
    Tile tile4{ 1 };

    std::cout << "0123456789ABCDEF\n"; // to make it easy to see how many spaces are in the next line
    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;
}

Step #3

步骤 #3

目标:创建一个已解开的棋盘(4×4 格子布局)并在屏幕上显示。

定义一个Board类来表示4×4的方块网格。新创建的Board对象应处于解完状态。显示棋盘时,需先打印g_consoleLines(定义在下文代码片段中)的空行,再输出棋盘本身。此操作可确保清除屏幕上的先前输出内容,使控制台仅显示当前棋盘。

为何初始化时设置为已解状态?实体版拼图通常以已解状态出售——用户需手动打乱(滑动方块)后才能开始解题。我们将通过程序模拟此过程(后续步骤将实现打乱功能)。

显示任务:
Board类应具备以下功能:

  • 一个constexpr符号常量,设置为网格大小(可假设网格为正方形)。
  • 一个二维 Tile 对象数组,用于存储 16 个数字。这些数字初始应处于已解状态。
  • 默认构造函数。
  • 重载的 operator<< 运算符,用于打印 N 行空白行(其中 N = g_consoleLines 的值),然后将棋盘绘制到控制台。

以下程序应运行:

// Increase amount of new lines if your board isn't
// at the very bottom of the console
constexpr int g_consoleLines{ 25 };

// Your code goes here

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

    return 0;
}

并输出以下内容:

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

显示解决方案

#include <iostream>

// Increase amount of new lines if your board isn't
// at the very bottom of the console
constexpr int g_consoleLines{ 25 };

class Tile
{
public:
    Tile() = default;
    explicit Tile(int number)
        :m_num(number)
    {
    }

    bool isEmpty() const
    {
        return m_num == 0;
    }

    int getNum() const { return m_num; }

private:
    int m_num { 0 };
};

std::ostream& operator<<(std::ostream& stream, Tile tile)
{
    if (tile.getNum() > 9) // if two digit number
        stream << " " << tile.getNum() << " ";
    else if (tile.getNum() > 0) // if one digit number
        stream << "  " << tile.getNum() << " ";
    else if (tile.getNum() == 0) // if empty spot
        stream << "    ";
    return stream;
}

class Board
{
public:

    Board() = default;

    friend std::ostream& operator<<(std::ostream& stream, const Board& board)
    {
        // Before drawing always print some empty lines
        // so that only one board appears at a time
        // and it's always shown at the bottom of the window
        // because console window scrolls automatically when there is no
        // enough space.
        for (int i = 0; i < g_consoleLines; ++i)
            std::cout << '\n';

        for (int y = 0; y < s_size; ++y)
        {
            for (int x = 0; x < s_size; ++x)
                stream << board.m_tiles[y][x];
            stream << '\n';
        }

        return stream;
    }

private:
    static constexpr int s_size { 4 };
    Tile m_tiles[s_size][s_size]{
        Tile{ 1 }, Tile { 2 }, Tile { 3 } , Tile { 4 },
        Tile { 5 } , Tile { 6 }, Tile { 7 }, Tile { 8 },
        Tile { 9 }, Tile { 10 }, Tile { 11 }, Tile { 12 },
        Tile { 13 }, Tile { 14 }, Tile { 15 }, Tile { 0 } };
};

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

    return 0;
}

Step #4

步骤 #4

目标:在此步骤中,我们将允许用户反复输入游戏指令,处理无效输入,并实现退出游戏指令。

游戏支持以下5种指令(每条指令以单个字符输入):

  • ‘w’ - 向上滑动方块
  • ‘a’ - 向左滑动方块
  • ‘s’ - 向下滑动方块
  • ‘d’ - 向右滑动方块
  • ‘q’ - 退出游戏

用户运行游戏时应实现以下功能:

  • 将(已解开的)棋盘显示在控制台。
  • 程序应反复获取用户输入的有效游戏指令。若用户输入无效指令或无关内容,则忽略该输入。

对于每个有效游戏指令:

  • 打印“有效指令:”及用户输入的字符。
  • 若指令为退出指令,则同时打印“\n\n再见!\n\n”并退出应用。

由于用户输入函数无需维护状态,请将其封装在名为UserInput的命名空间内实现。

显示任务
实现 UserInput 命名空间:

  • 创建名为 getCommandFromUser() 的函数。从用户处读取单个字符。若该字符不是有效的游戏命令,则清除所有多余输入,并再次从用户处读取字符。重复此过程直至输入有效游戏命令。将有效命令返回给调用方。
  • 根据需要创建任意数量的辅助函数。

在 main() 中:

  • 实现无限循环。循环内部获取有效游戏指令,并按上述要求处理指令。

程序输出应符合以下要求:

  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!

显示解决方案

#include <iostream>
#include <limits>

// Increase amount of new lines if your board isn't
// at the very bottom of the console
constexpr int g_consoleLines{ 25 };

namespace UserInput
{
    bool isValidCommand(char ch)
    {
        return ch == 'w'
            || ch == 'a'
            || ch == 's'
            || ch == 'd'
            || ch == 'q';
    }

    void ignoreLine()
    {
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
    }

    char getCharacter()
    {
        char operation{};
        std::cin >> operation;
        ignoreLine(); // remove any extraneous input
        return operation;
    }

    char getCommandFromUser()
    {
        char ch{};
        while (!isValidCommand(ch))
            ch = getCharacter();

        return ch;
    }
};

class Tile
{
public:
    Tile() = default;
    explicit Tile(int number)
        :m_num(number)
    {
    }

    bool isEmpty() const
    {
        return m_num == 0;
    }

    int getNum() const { return m_num; }

private:
    int m_num { 0 };
};

std::ostream& operator<<(std::ostream& stream, Tile tile)
{
    if (tile.getNum() > 9) // if two digit number
        stream << " " << tile.getNum() << " ";
    else if (tile.getNum() > 0) // if one digit number
        stream << "  " << tile.getNum() << " ";
    else if (tile.getNum() == 0) // if empty spot
        stream << "    ";
    return stream;
}

class Board
{
public:

    Board() = default;

    friend std::ostream& operator<<(std::ostream& stream, const Board& board)
    {
        // Before drawing always print some empty lines
        // so that only one board appears at a time
        // and it's always shown at the bottom of the window
        // because console window scrolls automatically when there is no
        // enough space.
        for (int i = 0; i < g_consoleLines; ++i)
            std::cout << '\n';

        for (int y = 0; y < s_size; ++y)
        {
            for (int x = 0; x < s_size; ++x)
                stream << board.m_tiles[y][x];
            stream << '\n';
        }

        return stream;
    }

private:
    static constexpr int s_size { 4 };
    Tile m_tiles[s_size][s_size]{
        Tile{ 1 }, Tile { 2 }, Tile { 3 } , Tile { 4 },
        Tile { 5 } , Tile { 6 }, Tile { 7 }, Tile { 8 },
        Tile { 9 }, Tile { 10 }, Tile { 11 }, Tile { 12 },
        Tile { 13 }, Tile { 14 }, Tile { 15 }, Tile { 0 } };
};

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

    while (true)
    {
        char ch{ UserInput::getCommandFromUser() };

        // If we reach the line below, "ch" will ALWAYS be a correct command!
        std::cout << "Valid command: " << ch << '\n';

        // Handle non-direction commands
        if (ch == 'q')
        {
            std::cout << "\n\nBye!\n\n";
            return 0;
        }
    }

    return 0;
}

Step #5

步骤 #5

目标:实现一个辅助类,以便更轻松地处理方向指令。

完成前一步后,我们已能接收用户输入的指令(字符‘w’、‘a’、‘s’、‘d’和‘q’)。这些字符本质上是代码中的魔数。虽然在UserInput命名空间和main()函数中处理这些指令尚可,但我们不希望它们在整个程序中传播。例如,Board类不应知道's'代表什么含义。

实现名为Direction的辅助类,用于创建表示基本方向(上、左、下、右)的对象。operator-应返回相反方向,operator<<应将方向打印到控制台。我们还需要一个成员函数,用于返回包含随机方向的Direction对象。最后,在UserInput命名空间中添加函数,将方向性游戏指令(‘w’、'a'、‘s'或'd’)转换为Direction对象。

越能用Direction替代方向性游戏指令,代码就越易于阅读和理解。

显示任务
实现 Direction 类,包含:

  • 一个名为 Type 的公共嵌套枚举,枚举项为 up、down、left、right 和 maxDirections。
  • 一个存储实际方向的私有成员。
  • 一个带单参数的构造函数,用于通过 Type 初始化器初始化 Direction。
  • 一个重载的 - 运算符,接受 Direction 并返回相反方向。
  • 重载<<运算符,将方向名称输出至控制台。
  • 静态函数,返回随机方向类型。可使用“Random.h”头文件中的Random::get()函数生成随机数。

此外,在UserInput命名空间中添加:

  • 将方向性游戏指令(字符)转换为Direction对象的函数。

Random.h

#ifndef RANDOM_MT_H
#define RANDOM_MT_H

#include <chrono>
#include <random>

// This header-only Random namespace implements a self-seeding Mersenne Twister.
// Requires C++17 or newer.
// It can be #included into as many code files as needed (The inline keyword avoids ODR violations)
// Freely redistributable, courtesy of learncpp.com (https://www.learncpp.com/cpp-tutorial/global-random-numbers-random-h/)
namespace Random
{
	// Returns a seeded Mersenne Twister
	// Note: we'd prefer to return a std::seed_seq (to initialize a std::mt19937), but std::seed can't be copied, so it can't be returned by value.
	// Instead, we'll create a std::mt19937, seed it, and then return the std::mt19937 (which can be copied).
	inline std::mt19937 generate()
	{
		std::random_device rd{};

		// Create seed_seq with clock and 7 random numbers from std::random_device
		std::seed_seq ss{
			static_cast<std::seed_seq::result_type>(std::chrono::steady_clock::now().time_since_epoch().count()),
				rd(), rd(), rd(), rd(), rd(), rd(), rd() };

		return std::mt19937{ ss };
	}

	// Here's our global std::mt19937 object.
	// The inline keyword means we only have one global instance for our whole program.
	inline std::mt19937 mt{ generate() }; // generates a seeded std::mt19937 and copies it into our global object

	// Generate a random int between [min, max] (inclusive)
        // * also handles cases where the two arguments have different types but can be converted to int
	inline int get(int min, int max)
	{
		return std::uniform_int_distribution{min, max}(mt);
	}

	// The following function templates can be used to generate random numbers in other cases

	// See https://www.learncpp.com/cpp-tutorial/function-template-instantiation/
	// You can ignore these if you don't understand them

	// Generate a random value between [min, max] (inclusive)
	// * min and max must have the same type
	// * return value has same type as min and max
	// * Supported types:
	// *    short, int, long, long long
	// *    unsigned short, unsigned int, unsigned long, or unsigned long long
	// Sample call: Random::get(1L, 6L);             // returns long
	// Sample call: Random::get(1u, 6u);             // returns unsigned int
	template <typename T>
	T get(T min, T max)
	{
		return std::uniform_int_distribution<T>{min, max}(mt);
	}

	// Generate a random value between [min, max] (inclusive)
	// * min and max can have different types
        // * return type must be explicitly specified as a template argument
	// * min and max will be converted to the return type
	// Sample call: Random::get<std::size_t>(0, 6);  // returns std::size_t
	// Sample call: Random::get<std::size_t>(0, 6u); // returns std::size_t
	// Sample call: Random::get<std::int>(0, 6u);    // returns int
	template <typename R, typename S, typename T>
	R get(S min, T max)
	{
		return get<R>(static_cast<R>(min), static_cast<R>(max));
	}
}

#endif

最后修改上一步编写的程序,使其输出符合以下要求:

  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!

显示解决方案

#include <cassert>
#include <iostream>
#include <limits>
#include "Random.h"

// Increase amount of new lines if your board isn't
// at the very bottom of the console
constexpr int g_consoleLines{ 25 };

class Direction
{
public:
    enum Type
    {
        up,
        down,
        left,
        right,
        maxDirections,
    };

    Direction(Type type)
        :m_type(type)
    {
    }

    Type getType() const
    {
        return m_type;
    }

    Direction operator-() const
    {
        switch (m_type)
        {
        case up:    return Direction{ down };
        case down:  return Direction{ up };
        case left:  return Direction{ right };
        case right: return Direction{ left };
        default:    break;
        }

        assert(0 && "Unsupported direction was passed!");
        return Direction{ up };
    }

    static Direction getRandomDirection()
    {
        Type random{ static_cast<Type>(Random::get(0, Type::maxDirections - 1)) };
        return Direction{ random };
    }

private:
    Type m_type{};
};

std::ostream& operator<<(std::ostream& stream, Direction dir)
{
    switch (dir.getType())
    {
    case Direction::up:     return (stream << "up");
    case Direction::down:   return (stream << "down");
    case Direction::left:   return (stream << "left");
    case Direction::right:  return (stream << "right");
    default:                break;
    }

    assert(0 && "Unsupported direction was passed!");
    return (stream << "unknown direction");
}

namespace UserInput
{
    bool isValidCommand(char ch)
    {
        return ch == 'w'
            || ch == 'a'
            || ch == 's'
            || ch == 'd'
            || ch == 'q';
    }

    void ignoreLine()
    {
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
    }

    char getCharacter()
    {
        char operation{};
        std::cin >> operation;
        ignoreLine(); // remove any extraneous input
        return operation;
    }

    char getCommandFromUser()
    {
        char ch{};
        while (!isValidCommand(ch))
            ch = getCharacter();

        return ch;
    }

    Direction charToDirection(char ch)
    {
        switch (ch)
        {
        case 'w': return Direction{ Direction::up };
        case 's': return Direction{ Direction::down };
        case 'a': return Direction{ Direction::left };
        case 'd': return Direction{ Direction::right };
        }

        assert(0 && "Unsupported direction was passed!");
        return Direction{ Direction::up };
    }
};

class Tile
{
public:
    Tile() = default;
    explicit Tile(int number)
        :m_num(number)
    {
    }

    bool isEmpty() const
    {
        return m_num == 0;
    }

    int getNum() const { return m_num; }

private:
    int m_num { 0 };
};

std::ostream& operator<<(std::ostream& stream, Tile tile)
{
    if (tile.getNum() > 9) // if two digit number
        stream << " " << tile.getNum() << " ";
    else if (tile.getNum() > 0) // if one digit number
        stream << "  " << tile.getNum() << " ";
    else if (tile.getNum() == 0) // if empty spot
        stream << "    ";
    return stream;
}

class Board
{
public:

    Board() = default;

    static void printEmptyLines(int count)
    {
        for (int i = 0; i < count; ++i)
            std::cout << '\n';
    }

    friend std::ostream& operator<<(std::ostream& stream, const Board& board)
    {
        // Before drawing always print some empty lines
        // so that only one board appears at a time
        // and it's always shown at the bottom of the window
        // because console window scrolls automatically when there is no
        // enough space.
        for (int i = 0; i < g_consoleLines; ++i)
            std::cout << '\n';

        for (int y = 0; y < s_size; ++y)
        {
            for (int x = 0; x < s_size; ++x)
                stream << board.m_tiles[y][x];
            stream << '\n';
        }

        return stream;
    }

private:
    static constexpr int s_size { 4 };
    Tile m_tiles[s_size][s_size]{
        Tile{ 1 }, Tile { 2 }, Tile { 3 } , Tile { 4 },
        Tile { 5 } , Tile { 6 }, Tile { 7 }, Tile { 8 },
        Tile { 9 }, Tile { 10 }, Tile { 11 }, Tile { 12 },
        Tile { 13 }, Tile { 14 }, Tile { 15 }, Tile { 0 } };
};

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

    std::cout << "Generating random direction... " << Direction::getRandomDirection() << '\n';
    std::cout << "Generating random direction... " << Direction::getRandomDirection() << '\n';
    std::cout << "Generating random direction... " << Direction::getRandomDirection() << '\n';
    std::cout << "Generating random direction... " << Direction::getRandomDirection() << "\n\n";

    std::cout << "Enter a command: ";
    while (true)
    {
        char ch{ UserInput::getCommandFromUser() };

        // Handle non-direction commands
        if (ch == 'q')
        {
            std::cout << "\n\nBye!\n\n";
            return 0;
        }

        // Handle direction commands
        Direction dir{ UserInput::charToDirection(ch) };

        std::cout << "You entered direction: " << dir << '\n';
    }

    return 0;
}

Step #6

步骤 #6

目标:实现一个辅助类,以便更轻松地对游戏棋盘中的棋子进行索引。

我们的游戏棋盘由4×4的Tile格子构成,这些格子存储在Board类的二维数组成员m_tiles中。我们将通过{x, y}坐标访问特定格子。例如,左上角格子的坐标为{0, 0},其右侧格子坐标为{1, 0}(x变为1,y保持0)。其下方一格的坐标为{1, 1}。

由于坐标操作频繁,请创建名为Point的辅助类来存储{x, y}坐标对。该类应支持比较两个Point对象的相等性与不等性,并实现名为getAdjacentPoint的成员函数——该函数接收Direction对象作为参数,返回指定方向上的点坐标。例如:Point{1, 1}.getAdjacentPoint(Direction::right) == Point{2, 1}。

显示任务

实现一个名为Point的结构体。它应包含:

  • 两个公共数据成员,用于存储x轴和y轴坐标。
  • 重载的operator==和operator!=运算符,用于比较两组坐标。
  • 一个常量成员函数Point getAdjacentPoint(Direction),该函数返回指向Direction参数所指方向的Point对象。此处无需进行任何有效性检查。

此处采用结构体而非类,因为Point本质上是简单的数据集合,封装对其价值有限。

请保留上一步的主函数(main()),后续步骤仍需用到。

以下代码运行时应针对每个测试用例输出 true:

// Your code goes here

// Note: save your main() from the prior step, as you'll need it again in the next step
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;
}

显示解决方案

#include <array>
#include <cassert>
#include <iostream>
#include <limits>
#include "Random.h"

// Increase amount of new lines if your board isn't
// at the very bottom of the console
constexpr int g_consoleLines{ 25 };

class Direction
{
public:
    enum Type
    {
        up,
        down,
        left,
        right,
        maxDirections,
    };

    Direction(Type type)
        :m_type(type)
    {
    }

    Type getType() const
    {
        return m_type;
    }

    Direction operator-() const
    {
        switch (m_type)
        {
        case up:    return Direction{ down };
        case down:  return Direction{ up };
        case left:  return Direction{ right };
        case right: return Direction{ left };
        default:    break;
        }

        assert(0 && "Unsupported direction was passed!");
        return Direction{ up };
    }

    static Direction getRandomDirection()
    {
        Type random{ static_cast<Type>(Random::get(0, Type::maxDirections - 1)) };
        return Direction{ random };
    }

private:
    Type m_type{};
};


std::ostream& operator<<(std::ostream& stream, Direction dir)
{
    switch (dir.getType())
    {
    case Direction::up:     return (stream << "up");
    case Direction::down:   return (stream << "down");
    case Direction::left:   return (stream << "left");
    case Direction::right:  return (stream << "right");
    default:                break;
    }

    assert(0 && "Unsupported direction was passed!");
    return (stream << "unknown direction");
}

struct Point
{
    int x{};
    int y{};

    friend bool operator==(Point p1, Point p2)
    {
        return p1.x == p2.x && p1.y == p2.y;
    }

    friend bool operator!=(Point p1, Point p2)
    {
        return !(p1 == p2);
    }

    Point getAdjacentPoint(Direction dir) const
    {
        switch (dir.getType())
        {
        case Direction::up:     return Point{ x,     y - 1 };
        case Direction::down:   return Point{ x,     y + 1 };
        case Direction::left:   return Point{ x - 1, y };
        case Direction::right:  return Point{ x + 1, y };
        default:                break;
        }

        assert(0 && "Unsupported direction was passed!");
        return *this;
    }
};

namespace UserInput
{
    bool isValidCommand(char ch)
    {
        return ch == 'w'
            || ch == 'a'
            || ch == 's'
            || ch == 'd'
            || ch == 'q';
    }

    void ignoreLine()
    {
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
    }

    char getCharacter()
    {
        char operation{};
        std::cin >> operation;
        ignoreLine(); // remove any extraneous input
        return operation;
    }

    char getCommandFromUser()
    {
        char ch{};
        while (!isValidCommand(ch))
            ch = getCharacter();

        return ch;
    }

    Direction charToDirection(char ch)
    {
        switch (ch)
        {
        case 'w': return Direction{ Direction::up };
        case 's': return Direction{ Direction::down };
        case 'a': return Direction{ Direction::left };
        case 'd': return Direction{ Direction::right };
        }

        assert(0 && "Unsupported direction was passed!");
        return Direction{ Direction::up };
    }
};

class Tile
{
public:
    Tile() = default;
    explicit Tile(int number)
        :m_num(number)
    {
    }

    bool isEmpty() const
    {
        return m_num == 0;
    }

    int getNum() const { return m_num; }

private:
    int m_num { 0 };
};

std::ostream& operator<<(std::ostream& stream, Tile tile)
{
    if (tile.getNum() > 9) // if two digit number
        stream << " " << tile.getNum() << " ";
    else if (tile.getNum() > 0) // if one digit number
        stream << "  " << tile.getNum() << " ";
    else if (tile.getNum() == 0) // if empty spot
        stream << "    ";
    return stream;
}

class Board
{
public:

    Board() = default;

    static void printEmptyLines(int count)
    {
        for (int i = 0; i < count; ++i)
            std::cout << '\n';
    }

    friend std::ostream& operator<<(std::ostream& stream, const Board& board)
    {
        // Before drawing always print some empty lines
        // so that only one board appears at a time
        // and it's always shown at the bottom of the window
        // because console window scrolls automatically when there is no
        // enough space.
        for (int i = 0; i < g_consoleLines; ++i)
            std::cout << '\n';

        for (int y = 0; y < s_size; ++y)
        {
            for (int x = 0; x < s_size; ++x)
                stream << board.m_tiles[y][x];
            stream << '\n';
        }

        return stream;
    }

private:
    static constexpr int s_size { 4 };
    Tile m_tiles[s_size][s_size]{
        Tile{ 1 }, Tile { 2 }, Tile { 3 } , Tile { 4 },
        Tile { 5 } , Tile { 6 }, Tile { 7 }, Tile { 8 },
        Tile { 9 }, Tile { 10 }, Tile { 11 }, Tile { 12 },
        Tile { 13 }, Tile { 14 }, Tile { 15 }, Tile { 0 } };
};

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;
}

Step #7

步骤 #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格互换。

现在将这个过程泛化。当用户输入方向指令时,我们需要:

  • 定位空格。
  • 从空格出发,找到与用户输入方向相反的相邻格。
  • 若相邻方块有效(未超出网格范围),则交换空格与相邻方块。
  • 若相邻方块无效,则不执行操作。

通过在Board类中添加成员函数moveTile(Direction)实现此逻辑。将其添加至步骤5的游戏循环中。当用户成功滑动方块时,游戏应重新绘制更新后的棋盘。

显示任务:

在我们的棋盘类中实现以下成员函数:

  • 一个返回布尔值的函数,用于判断给定点是否有效(位于棋盘内)。
  • 一个查找并返回空格位置的点坐标的函数。虽然可以直接记录空格位置,但这会引入类不变量,而按需查找空格的开销并不高。
  • 根据Point索引交换两个方块的函数。
  • moveTile(Direction dir)函数:尝试按指定方向移动方块,成功则返回true。该函数应实现上述流程。

修改步骤5中的main()函数:当输入方向指令时调用moveTile()。若移动成功,则重绘棋盘。

显示解决方案

#include <array>
#include <cassert>
#include <iostream>
#include <limits>
#include "Random.h"

// Increase amount of new lines if your board isn't
// at the very bottom of the console
constexpr int g_consoleLines{ 25 };

class Direction
{
public:
    enum Type
    {
        up,
        down,
        left,
        right,
        maxDirections,
    };

    Direction(Type type)
        :m_type(type)
    {
    }

    Type getType() const
    {
        return m_type;
    }

    Direction operator-() const
    {
        switch (m_type)
        {
        case up:    return Direction{ down };
        case down:  return Direction{ up };
        case left:  return Direction{ right };
        case right: return Direction{ left };
        default:    break;
        }

        assert(0 && "Unsupported direction was passed!");
        return Direction{ up };
    }

    static Direction getRandomDirection()
    {
        Type random{ static_cast<Type>(Random::get(0, Type::maxDirections - 1)) };
        return Direction{ random };
    }

private:
    Type m_type{};
};


std::ostream& operator<<(std::ostream& stream, Direction dir)
{
    switch (dir.getType())
    {
    case Direction::up:     return (stream << "up");
    case Direction::down:   return (stream << "down");
    case Direction::left:   return (stream << "left");
    case Direction::right:  return (stream << "right");
    default:                break;
    }

    assert(0 && "Unsupported direction was passed!");
    return (stream << "unknown direction");
}

struct Point
{
    int x{};
    int y{};

    friend bool operator==(Point p1, Point p2)
    {
        return p1.x == p2.x && p1.y == p2.y;
    }

    friend bool operator!=(Point p1, Point p2)
    {
        return !(p1 == p2);
    }

    Point getAdjacentPoint(Direction dir) const
    {
        switch (dir.getType())
        {
        case Direction::up:     return Point{ x,     y - 1 };
        case Direction::down:   return Point{ x,     y + 1 };
        case Direction::left:   return Point{ x - 1, y };
        case Direction::right:  return Point{ x + 1, y };
        default:                break;
        }

        assert(0 && "Unsupported direction was passed!");
        return *this;
    }
};

namespace UserInput
{
    bool isValidCommand(char ch)
    {
        return ch == 'w'
            || ch == 'a'
            || ch == 's'
            || ch == 'd'
            || ch == 'q';
    }

    void ignoreLine()
    {
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
    }

    char getCharacter()
    {
        char operation{};
        std::cin >> operation;
        ignoreLine(); // remove any extraneous input
        return operation;
    }

    char getCommandFromUser()
    {
        char ch{};
        while (!isValidCommand(ch))
            ch = getCharacter();

        return ch;
    }

    Direction charToDirection(char ch)
    {
        switch (ch)
        {
        case 'w': return Direction{ Direction::up };
        case 's': return Direction{ Direction::down };
        case 'a': return Direction{ Direction::left };
        case 'd': return Direction{ Direction::right };
        }

        assert(0 && "Unsupported direction was passed!");
        return Direction{ Direction::up };
    }
};

class Tile
{
public:
    Tile() = default;
    explicit Tile(int number)
        :m_num(number)
    {
    }

    bool isEmpty() const
    {
        return m_num == 0;
    }

    int getNum() const { return m_num; }

private:
    int m_num { 0 };
};

std::ostream& operator<<(std::ostream& stream, Tile tile)
{
    if (tile.getNum() > 9) // if two digit number
        stream << " " << tile.getNum() << " ";
    else if (tile.getNum() > 0) // if one digit number
        stream << "  " << tile.getNum() << " ";
    else if (tile.getNum() == 0) // if empty spot
        stream << "    ";
    return stream;
}

class Board
{
public:

    Board() = default;

    static void printEmptyLines(int count)
    {
        for (int i = 0; i < count; ++i)
            std::cout << '\n';
    }

    friend std::ostream& operator<<(std::ostream& stream, const Board& board)
    {
        // Before drawing always print some empty lines
        // so that only one board appears at a time
        // and it's always shown at the bottom of the window
        // because console window scrolls automatically when there is no
        // enough space.
        for (int i = 0; i < g_consoleLines; ++i)
            std::cout << '\n';

        for (int y = 0; y < s_size; ++y)
        {
            for (int x = 0; x < s_size; ++x)
                stream << board.m_tiles[y][x];
            stream << '\n';
        }

        return stream;
    }

    Point getEmptyTilePos() const
    {
        for (int y = 0; y < s_size; ++y)
            for (int x = 0; x < s_size; ++x)
                if (m_tiles[y][x].isEmpty())
                    return { x,y };

        assert(0 && "There is no empty tile in the board!!!");
        return { -1,-1 };
    }

    static bool isValidTilePos(Point pt)
    {
        return (pt.x >= 0 && pt.x < s_size)
            && (pt.y >= 0 && pt.y < s_size);
    }

    void swapTiles(Point pt1, Point pt2)
    {
        std::swap(m_tiles[pt1.y][pt1.x], m_tiles[pt2.y][pt2.x]);
    }

    // returns true if user moved successfully
    bool moveTile(Direction dir)
    {
        Point emptyTile{ getEmptyTilePos() };
        Point adj{ emptyTile.getAdjacentPoint(-dir) };

        if (!isValidTilePos(adj))
            return false;

        swapTiles(adj, emptyTile);
        return true;
    }

private:
    static const int s_size { 4 };
    Tile m_tiles[s_size][s_size]{
        Tile{ 1 }, Tile { 2 }, Tile { 3 } , Tile { 4 },
        Tile { 5 } , Tile { 6 }, Tile { 7 }, Tile { 8 },
        Tile { 9 }, Tile { 10 }, Tile { 11 }, Tile { 12 },
        Tile { 13 }, Tile { 14 }, Tile { 15 }, Tile { 0 } };
};

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

    std::cout << "Enter a command: ";
    while (true)
    {
        char ch{ UserInput::getCommandFromUser() };

        // Handle non-direction commands
        if (ch == 'q')
        {
            std::cout << "\n\nBye!\n\n";
            return 0;
        }

        // Handle direction commands
        Direction dir{ UserInput::charToDirection(ch) };

        bool userMoved { board.moveTile(dir) };
        if (userMoved)
            std::cout << board;
    }

    return 0;
}

Step #8

步骤 #8

目标:在本步骤中,我们将完成游戏开发。随机化游戏棋盘的初始状态。同时实现用户获胜检测功能,以便在用户获胜后打印胜利提示并退出游戏。

在随机化谜题时需谨慎,因为并非所有谜题都可解。例如以下谜题无法解开:

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

若盲目随机排列数字,可能生成此类无法解开的谜题。实体版拼图通过随机滑动拼板实现随机化,当拼板充分混合后即可解开——只需将每块拼板按最初随机化时的相反方向滑动即可。因此这种随机化方式始终能生成可解谜题。

程序可采用相同方式随机化棋盘。

当用户解开谜题后,程序应输出“\n\n你赢了!\n\n”并正常退出。

显示任务:

  • 在Board类中添加一个randomize()成员函数,用于随机排列棋盘上的棋子。选择一个随机方向,若相邻位置有效,则将棋子向该方向滑动。重复此操作1000次即可充分打乱棋盘布局。
  • 在Board类中实现operator==运算符,用于比较两个棋盘的棋子是否完全相同。
  • 在Board类中添加playerWon()成员函数,当当前棋盘被解开时返回true。可使用你实现的operator==将当前棋盘与解开状态的棋盘进行比较。注意Board对象初始化时处于解开状态,因此若需解开棋盘,只需对Board对象进行值初始化即可!
  • 更新main()函数以集成randomize()和playerWon()功能。

以下是15拼图游戏的完整解法:

显示解法

#include <array>
#include <cassert>
#include <iostream>
#include <limits>
#include "Random.h"

// Increase amount of new lines if your board isn't
// at the very bottom of the console
constexpr int g_consoleLines{ 25 };

class Direction
{
public:
    enum Type
    {
        up,
        down,
        left,
        right,
        maxDirections,
    };

    Direction(Type type)
        :m_type(type)
    {
    }
    Type getType() const
    {
        return m_type;
    }

    Direction operator-() const
    {
        switch (m_type)
        {
        case up:    return Direction{ down };
        case down:  return Direction{ up };
        case left:  return Direction{ right };
        case right: return Direction{ left };
        default:    break;
        }

        assert(0 && "Unsupported direction was passed!");
        return Direction{ up };
    }

    static Direction getRandomDirection()
    {
        Type random{ static_cast<Type>(Random::get(0, Type::maxDirections - 1)) };
        return Direction{ random };
    }

private:
    Type m_type{};
};

std::ostream& operator<<(std::ostream& stream, Direction dir)
{
    switch (dir.getType())
    {
    case Direction::up:     return (stream << "up");
    case Direction::down:   return (stream << "down");
    case Direction::left:   return (stream << "left");
    case Direction::right:  return (stream << "right");
    default:                break;
    }

    assert(0 && "Unsupported direction was passed!");
    return (stream << "unknown direction");
}

struct Point
{
    int x{};
    int y{};

    friend bool operator==(Point p1, Point p2)
    {
        return p1.x == p2.x && p1.y == p2.y;
    }

    friend bool operator!=(Point p1, Point p2)
    {
        return !(p1 == p2);
    }

    Point getAdjacentPoint(Direction dir) const
    {
        switch (dir.getType())
        {
        case Direction::up:     return Point{ x,     y - 1 };
        case Direction::down:   return Point{ x,     y + 1 };
        case Direction::left:   return Point{ x - 1, y };
        case Direction::right:  return Point{ x + 1, y };
        default:                break;
        }

        assert(0 && "Unsupported direction was passed!");
        return *this;
    }
};

namespace UserInput
{
    bool isValidCommand(char ch)
    {
        return ch == 'w'
            || ch == 'a'
            || ch == 's'
            || ch == 'd'
            || ch == 'q';
    }

    void ignoreLine()
    {
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
    }

    char getCharacter()
    {
        char operation{};
        std::cin >> operation;
        ignoreLine(); // remove any extraneous input
        return operation;
    }

    char getCommandFromUser()
    {
        char ch{};
        while (!isValidCommand(ch))
            ch = getCharacter();

        return ch;
    }

    Direction charToDirection(char ch)
    {
        switch (ch)
        {
        case 'w': return Direction{ Direction::up };
        case 's': return Direction{ Direction::down };
        case 'a': return Direction{ Direction::left };
        case 'd': return Direction{ Direction::right };
        }

        assert(0 && "Unsupported direction was passed!");
        return Direction{ Direction::up };
    }
};

class Tile
{
public:
    Tile() = default;
    explicit Tile(int number)
        :m_num(number)
    {
    }

    bool isEmpty() const
    {
        return m_num == 0;
    }

    int getNum() const { return m_num; }

private:
    int m_num { 0 };
};

std::ostream& operator<<(std::ostream& stream, Tile tile)
{
    if (tile.getNum() > 9) // if two digit number
        stream << " " << tile.getNum() << " ";
    else if (tile.getNum() > 0) // if one digit number
        stream << "  " << tile.getNum() << " ";
    else if (tile.getNum() == 0) // if empty spot
        stream << "    ";
    return stream;
}

class Board
{
public:

    Board() = default;

    static void printEmptyLines(int count)
    {
        for (int i = 0; i < count; ++i)
            std::cout << '\n';
    }

    friend std::ostream& operator<<(std::ostream& stream, const Board& board)
    {
        // Before drawing always print some empty lines
        // so that only one board appears at a time
        // and it's always shown at the bottom of the window
        // because console window scrolls automatically when there is no
        // enough space.
        for (int i = 0; i < g_consoleLines; ++i)
            std::cout << '\n';

        for (int y = 0; y < s_size; ++y)
        {
            for (int x = 0; x < s_size; ++x)
                stream << board.m_tiles[y][x];
            stream << '\n';
        }

        return stream;
    }

    Point getEmptyTilePos() const
    {
        for (int y = 0; y < s_size; ++y)
            for (int x = 0; x < s_size; ++x)
                if (m_tiles[y][x].isEmpty())
                    return { x,y };

        assert(0 && "There is no empty tile in the board!!!");
        return { -1,-1 };
    }

    static bool isValidTilePos(Point pt)
    {
        return (pt.x >= 0 && pt.x < s_size)
            && (pt.y >= 0 && pt.y < s_size);
    }

    void swapTiles(Point pt1, Point pt2)
    {
        std::swap(m_tiles[pt1.y][pt1.x], m_tiles[pt2.y][pt2.x]);
    }

    // Compare two boards to see if they are equal
    friend bool operator==(const Board& f1, const Board& f2)
    {
        for (int y = 0; y < s_size; ++y)
            for (int x = 0; x < s_size; ++x)
                if (f1.m_tiles[y][x].getNum() != f2.m_tiles[y][x].getNum())
                    return false;

        return true;
    }

    // returns true if user moved successfully
    bool moveTile(Direction dir)
    {
        Point emptyTile{ getEmptyTilePos() };
        Point adj{ emptyTile.getAdjacentPoint(-dir) };

        if (!isValidTilePos(adj))
            return false;

        swapTiles(adj, emptyTile);
        return true;
    }

    bool playerWon() const
    {
        static Board s_solved{};  // generate a solved board
        return s_solved == *this; // player wins if current board == solved board
    }

    void randomize()
    {
        // Move empty tile randomly 1000 times
        // (just like you would do in real life)
        for (int i = 0; i < 1000; )
        {
            // If we are able to successfully move a tile, count this
            if (moveTile(Direction::getRandomDirection()))
                ++i;
        }
    }

private:
    static const int s_size { 4 };
    Tile m_tiles[s_size][s_size]{
        Tile{ 1 }, Tile { 2 }, Tile { 3 } , Tile { 4 },
        Tile { 5 } , Tile { 6 }, Tile { 7 }, Tile { 8 },
        Tile { 9 }, Tile { 10 }, Tile { 11 }, Tile { 12 },
        Tile { 13 }, Tile { 14 }, Tile { 15 }, Tile { 0 } };
};

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

    while (!board.playerWon())
    {
        char ch{ UserInput::getCommandFromUser() };

        // Handle non-direction commands
        if (ch == 'q')
        {
            std::cout << "\n\nBye!\n\n";
            return 0;
        }

        // Handle direction commands
        Direction dir{ UserInput::charToDirection(ch) };

        bool userMoved{ board.moveTile(dir) };
        if (userMoved)
            std::cout << board;
    }

    std::cout << "\n\nYou won!\n\n";
    return 0;
}
posted @ 2026-01-26 19:00  游翔  阅读(0)  评论(0)    收藏  举报