棋盘

井字棋棋盘是 3 行 3 列。Board 保存这九个棋盘格子,并提供写入与读出的接口;声明在 board.h,实现在 board.cpp。

类型与坐标

在 对局规则 里,棋盘格子用行列数字定位,每个棋盘格子的取值是 X、O 或空。CellPosition 表示一对行列,CellType 表示这个取值。

每个手写源文件开头都有一段 Doxygen 文件头,board.h 的前四行是:

board.h
/// @file board.h
/// @brief 定义井字棋棋盘、格子坐标与格子类型。
///
/// 棋盘保存九个棋盘格子的状态,提供查询、落子与清空棋盘。

@file 写文件名本身,@brief 用一句话说明这个文件定义什么,空一行后只写本文件自己的范围。

棋盘格子坐标

棋盘坐标由行数和列数确定,一对行列写成 (row, col)。尺寸和坐标定义在 board.h 里:

board.h
/// 棋盘行数。
inline constexpr int kBoardRows = 3;
/// 棋盘列数。
inline constexpr int kBoardCols = 3;

/// 棋盘格子坐标。
struct CellPosition {
  int row = 0;  ///< 行坐标,0 表示最上面一行。
  int col = 0;  ///< 列坐标,0 表示最左边一列。
};

/// 检查两个棋盘格子坐标的行与列是否都相同。
[[nodiscard]] constexpr bool operator==(CellPosition lhs, CellPosition rhs) noexcept {
  return lhs.row == rhs.row && lhs.col == rhs.col;
}

kBoardRows 和 kBoardCols 定下 3 行 3 列,CellPosition 把一对行列打成一个值,operator== 让两个坐标可以直接比较。图里每个棋盘格子标出的就是这对行列值。

col row 0 1 2 0 1 2 (0,0) (0,1) (0,2) (1,0) (1,1) (1,2) (2,0) (2,1) (2,2)
棋盘坐标图:每格标出 (row, col);行向下、列向右,左上角是 (0, 0)。

棋盘格子类型

棋盘格子有三种类型:空格子、X 标记和 O 标记,代码用 CellType 表示:

board.h
/// 棋盘格子类型。
enum class CellType : std::uint8_t {
  kEmpty = 0,  ///< 空格子。
  kX = 1,      ///< X 标记。
  kO = 2,      ///< O 标记。
};

终端一行 | | X | O | 从左到右依次是空格子、X 标记和 O 标记。

成员与接口

Board 对内用二维数组存棋盘格子,对外提供带边界检查的读取入口。头文件里的类声明、构造函数与数据成员:

board.h
/// 井字棋棋盘。
class Board {
 public:
  /// 构造空棋盘:九个棋盘格子都是 kEmpty。
  Board() noexcept;

 private:
  /// 棋盘格子数组:外层下标是行,内层下标是列,每个元素表示一个棋盘格子的类型。
  std::array<std::array<CellType, kBoardCols>, kBoardRows> cells_{};
};

构造函数建立一局空棋盘:九个棋盘格子都是 kEmpty。棋盘大小在对局进行中不变,因此不需要单独的初始化入口。

坐标下标

IsInRange 检查一个棋盘格子坐标是否落在棋盘的下标区间里,定义在 board.h 里:

board.h
class Board {
 public:
  /// 检查坐标是否落在棋盘下标区间内。
  ///
  /// 行的有效区间是 [0, kBoardRows),列的有效区间是 [0, kBoardCols)。
  /// 只判断几何边界,不检查格子是否为空、对局是否已经结束。
  ///
  /// @param position 待检查的坐标。
  /// @return 行和列都在区间内返回 true;任一越界返回 false。
  [[nodiscard]] static bool IsInRange(CellPosition position) noexcept;
};

IsInRange 可以直接调用,不用先创建 Board。

只读接口

Board 另外三个只读接口都定义在 board.h 里:

board.h
class Board {
 public:
  /// 检查指定坐标上的棋盘格子是否为空。
  ///
  /// 越界返回 false。本函数只看格子上有没有棋子,不判断对局是否结束。
  /// 对局因一方成线而提前结束时,棋盘上尚未落满的空格仍是空的;
  /// 只有下满且没有赢家的平局才会没有空格。
  ///
  /// @param position 待检查的坐标。
  /// @return 坐标在棋盘内且该格子为空时返回 true;越界或已有棋子时返回 false。
  [[nodiscard]] bool IsCellEmpty(CellPosition position) const;

  /// 检查棋盘是否已满。
  ///
  /// @return 九个棋盘格子都不为空时返回 true;否则返回 false。
  [[nodiscard]] bool IsFull() const noexcept;

  /// 合法坐标返回该棋盘格子的类型;越界时抛出 std::out_of_range。
  ///
  /// @param position 行列坐标。
  /// @return 该坐标上的棋盘格子类型。
  /// @throws std::out_of_range 坐标不在棋盘范围内。
  [[nodiscard]] CellType GetCellType(CellPosition position) const;
};

GetCellType、IsCellEmpty 和 IsFull 都只读棋盘,区别在返回什么、越界怎么处理:

接口 返回 边界
GetCellType 坐标处的 CellType 越界时抛出 std::out_of_range
IsCellEmpty 空棋盘格子为 true 坐标越界或该棋盘格子已有棋子时返回 false
IsFull 九个棋盘格子都有棋子时为 true 不判断谁获胜

调用方按场合选择:

  • 终端打印棋盘时逐格调用 GetCellType。
  • 判断单个棋盘格子是否为空时调用 IsCellEmpty。
  • 测试用 GetCellType 核对空棋盘格子、已有棋子与越界坐标;满盘局面由 PlaceCell 逐格摆出。
  • IsFull 用来判断是否满盘,测试里也用它确认未满或已满。

越界读取

GetCellType 越界时抛标准库的 std::out_of_range,合法坐标直接返回数组里的取值:

board.cpp
CellType Board::GetCellType(CellPosition position) const {
  if (!IsInRange(position)) {
    throw std::out_of_range{std::format(
        "Board::GetCellType: board position is out of range (row={}, col={}); board is {}x{}",
        position.row, position.col, kBoardRows, kBoardCols)};
  }

  return cells_.at(position.row).at(position.col);
}

写入与清空

Board 用 PlaceCell 写入一枚棋子,用 Clear 把整盘恢复为空。落子可能因坐标越界、棋子类型非法或目标格已有棋子而失败,返回值用 PlaceCellResult 区分这三种情况。

落子结果

bool 只能说明“成功或失败”,调用方想知道失败原因时还得再查一遍。PlaceCellResult 把结果收进一个枚举,定义在 board.h 里:

board.h
/// 落子结果,用于区分落子时的情况。
enum class PlaceCellResult : std::uint8_t {
  kSuccess = 0,        ///< 落子成功。
  kOutOfRange = 1,     ///< 坐标越界。
  kEmptyCellType = 2,  ///< 传入空棋盘格子类型,非法参数。
  kOccupied = 3,       ///< 目标已有棋子,落子失败。
};

四个取值各自对应一条拒绝路径;调用方用 switch 或比较就能知道该提示什么,不需要再调用别的查询。

落子校验

CheckPlaceable 只做校验,不改棋盘;PlaceCell 先调用它,再执行写入。声明在 board.h 里:

board.h
class Board {
 public:
  /// 检查一次落子是否合法,不修改棋盘。
  ///
  /// 依次检查坐标、棋子类型与目标格子占用情况;任一项不通过就返回对应结果。
  ///
  /// @param position 目标行列坐标。
  /// @param cell_type 要放置的棋盘格子类型。
  /// @return 可以落子返回 kSuccess;否则返回对应的拒绝原因。
  [[nodiscard]] PlaceCellResult CheckPlaceable(CellPosition position, CellType cell_type) const;
};

三项检查按从外到内的顺序排列,任何一项失败都直接返回:

board.cpp
PlaceCellResult Board::CheckPlaceable(CellPosition position, CellType cell_type) const {
  // 坐标越界时棋盘外没有格子可写,直接拒绝。
  if (!IsInRange(position)) {
    return PlaceCellResult::kOutOfRange;
  }

  // 空类型表示“没有棋子”,不能作为一次落子写进棋盘。
  if (cell_type == CellType::kEmpty) {
    return PlaceCellResult::kEmptyCellType;
  }

  // 目标格已经有棋子时不能覆盖,否则会改掉已有对局。
  if (!IsCellEmpty(position)) {
    return PlaceCellResult::kOccupied;
  }

  return PlaceCellResult::kSuccess;
}

写入棋子

PlaceCell 在指定坐标放置棋子,声明在 board.h 里:

board.h
class Board {
 public:
  /// 在指定坐标放置棋子。
  ///
  /// @param position 目标行列坐标。
  /// @param cell_type 要放置的棋盘格子类型,必须是 kX 或 kO。
  /// @return 落子成功返回 kSuccess;越界、空类型或目标已有棋子时返回对应枚举,棋盘不变。
  [[nodiscard]] PlaceCellResult PlaceCell(CellPosition position, CellType cell_type);
};

实现先校验、再写入,校验不通过时棋盘保持原样:

board.cpp
PlaceCellResult Board::PlaceCell(CellPosition position, CellType cell_type) {
  // 先只做校验,保证被拒的落子不会改动棋盘。
  const PlaceCellResult check = CheckPlaceable(position, cell_type);
  if (check != PlaceCellResult::kSuccess) {
    return check;
  }

  // 校验已通过,写入不会失败。
  cells_.at(position.row).at(position.col) = cell_type;
  return PlaceCellResult::kSuccess;
}

构造与清空

构造函数只得到一局空棋盘,实现写在 board.cpp 里:

board.cpp
Board::Board() noexcept {
  Clear();
}

Clear 把整盘恢复为空。这里不写 cells_ = {},而是显式把九个格子填成 kEmpty:

board.cpp
void Board::Clear() noexcept {
  std::ranges::fill(cells_ | std::views::join, CellType::kEmpty);
}

cells_ | std::views::join 把二维数组摊平成一维序列,std::ranges::fill 一次写满九个格子。意图直接可读:把所有格子填成 kEmpty,不用猜整体赋值恢复成了什么。

清空后九个格子都是 kEmpty,同一格可以再次落子。

终局判定

Board 从当前棋盘算出赢家、平局与是否结束;电脑落子前还要判断一手尚未写入时会不会成线。

赢家类型

GetWinner 获取形成三连的棋盘格子类型,定义在 board.h 里:

board.h
class Board {
 public:
  /// 获取形成三连的棋盘格子类型。
  ///
  /// 按 kX、kO 的次序检查,先手优先;同一棋盘不会让双方同时成线。
  /// 次序一致只为结果可复现。
  ///
  /// @return 有赢家时返回形成三连的棋盘格子类型;没有三连时返回 std::nullopt。
  [[nodiscard]] std::optional<CellType> GetWinner() const;
};

线坐标表

八条线的坐标写在 board.cpp 的 namespace tictactoe 里,存成一张 constexpr 常量表:

board.cpp
/// 八条线的坐标表:三行、三列、两条对角线。
constexpr std::array<std::array<CellPosition, 3>, 8> kLines{{
    {{{.row = 0, .col = 0}, {.row = 0, .col = 1}, {.row = 0, .col = 2}}},  // 行 0
    {{{.row = 1, .col = 0}, {.row = 1, .col = 1}, {.row = 1, .col = 2}}},  // 行 1
    {{{.row = 2, .col = 0}, {.row = 2, .col = 1}, {.row = 2, .col = 2}}},  // 行 2
    {{{.row = 0, .col = 0}, {.row = 1, .col = 0}, {.row = 2, .col = 0}}},  // 列 0
    {{{.row = 0, .col = 1}, {.row = 1, .col = 1}, {.row = 2, .col = 1}}},  // 列 1
    {{{.row = 0, .col = 2}, {.row = 1, .col = 2}, {.row = 2, .col = 2}}},  // 列 2
    {{{.row = 0, .col = 0}, {.row = 1, .col = 1}, {.row = 2, .col = 2}}},  // 对角线
    {{{.row = 0, .col = 2}, {.row = 1, .col = 1}, {.row = 2, .col = 0}}},  // 反对角线
}};

坐标都是常数,直接列出来比两层循环运行时推算更好查:新增一条线时改表即可,扫线代码不动。

成线判断

HasLine 扫这张表,判断有没有一条线全是同一种棋子:

board.cpp
bool Board::HasLine(CellType cell_type) const {
  // 逐格比较棋盘格子类型;不用编号求和,和相等不代表整条线都是同一种棋子。
  return std::ranges::any_of(kLines, [&](const auto& line) {
    return std::ranges::all_of(line, [&](CellPosition position) {
      return cells_.at(position.row).at(position.col) == cell_type;
    });
  });
}

不要改写成编号求和。若把 kX、kO 记成 1 和 2,空格记 0,那么 X + O + 空 等于 3,与三个 X 的和相同。这样,没有三连的盘面会被判成三连;逐格比较类型没有这个漏洞。

赢家判定

GetWinner 依次问 HasLine(kX) 与 HasLine(kO),先命中先返回;两个都没有,就是平局或对局还没结束:

board.cpp
std::optional<CellType> Board::GetWinner() const {
  if (HasLine(CellType::kX)) {
    return CellType::kX;
  }
  if (HasLine(CellType::kO)) {
    return CellType::kO;
  }
  return std::nullopt;
}

结束判定的归属

Board 不提供 IsWin、IsDraw 或 IsGameOver 这类组合查询。GetWinner 只报告三连,IsFull 只报告满盘;胜、负、和棋或继续由调用方组合这两个结果判断,具体见对局。分开查询后,最后一子同时成线和填满棋盘仍会先判胜利。

成线预判

电脑落子前需要知道“在某个空棋盘格子落子会不会连成三个”,此时这一手还没有写进棋盘。Board::PreviewWin 推演这一手的结果,定义在 board.h 里。

推演有三种结果:会成线、合法但不成线、这个位置根本不能落子。前两种都是合法落子,第三种是非法输入,所以返回值不能再用 bool:

board.h
/// 落子推演结果。
enum class WinPreviewResult : std::uint8_t {
  kWouldWin = 0,     ///< 在候选位置落子会形成完整线。
  kWouldNotWin = 1,  ///< 落子合法,但不会成线。
  kInvalidPlace = 2,  ///< 该位置本身不可落子,见 CheckPlaceable。
};

kInvalidPlace 的判定直接复用 CheckPlaceable,不再重复写一遍越界、空类型与占格检查:

board.h
class Board {
 public:
  /// 推演在指定坐标放入给定棋子后是否会形成三连,不修改棋盘。
  ///
  /// 先按 CheckPlaceable 判断这一手能否落子:不能落子时返回 kInvalidPlace;
  /// 能落子时只在推演中放下这一子,再检查它所在的线是否整条同色。
  ///
  /// @param position 假设落子的行列坐标。
  /// @param cell_type 假设放入的棋盘格子类型。
  /// @return 不可落子返回 kInvalidPlace;落子后成线返回 kWouldWin;否则返回 kWouldNotWin。
  [[nodiscard]] WinPreviewResult PreviewWin(CellPosition position, CellType cell_type) const;
};

实现不复制棋盘:候选落子先当成棋盘上已有的棋子,再用同一张 kLines 表扫线:

board.cpp
WinPreviewResult Board::PreviewWin(CellPosition position, CellType cell_type) const {
  // 先复用落子校验:不可落子的位置不进入成线推演。
  if (CheckPlaceable(position, cell_type) != PlaceCellResult::kSuccess) {
    return WinPreviewResult::kInvalidPlace;
  }

  // 候选坐标视为已放入该类型,其余棋盘格子照常读取。
  const auto matches = [&](CellPosition cell) {
    return cell == position || cells_.at(cell.row).at(cell.col) == cell_type;
  };

  // 只检查包含候选坐标的那条线:其他线不受这一手影响。
  const bool completes = std::ranges::any_of(kLines, [&](const auto& line) {
    return std::ranges::find(line, position) != line.end() && std::ranges::all_of(line, matches);
  });
  return completes ? WinPreviewResult::kWouldWin : WinPreviewResult::kWouldNotWin;
}

常见错误

写入空棋盘格子

给 Board::PlaceCell 传入 CellType::kEmpty 时,它返回 PlaceCellResult::kEmptyCellType,棋盘保持不变。

kEmpty 表示空棋盘格子而不是一枚棋子,Board 把它当作无效写入拒绝;写入时传 kX 或 kO。

误用越界坐标

把行号或列号写到 0 到 2 之外时,PlaceCell 返回 PlaceCellResult::kOutOfRange,棋盘保持不变。

CheckPlaceable 在写入前拦下越界坐标;调用前自己核对 0 到 2 更省事,也能用来禁用按钮。

自测问题

先自己回答,再展开答案对照;答不上来就回到对应小节。

  1. 为什么行号 -1 和 3 都会被拒绝?

    查看答案

    Board::IsInRange 要求行号从 0 到 kBoardRows - 1。kBoardRows 是 3,所以 -1 和 3 都不属于棋盘。

  2. 向已有 X 的棋盘格子再落一次 O,棋盘会变吗?

    查看答案

    不会。Board::PlaceCell 在棋盘格子已被占用时返回 PlaceCellResult::kOccupied,棋盘保持不变。

  3. cells_[row][col] 里的两个下标各是什么?

    查看答案

    row 是行号,col 是列号。cells_[row][col] 就是 (row, col) 处的棋盘格子。

本章回顾

棋盘用 3×3 的二维数组存九个格子,越界、空类型与占用都在写入前拒绝,结果由 PlaceCellResult 区分;判胜扫八条线,先看 X 再看 O,成线推演复用同一套落子校验。下一章看一次落子怎样改变棋盘,并决定谁继续下。