棋盘
井字棋棋盘是 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== 让两个坐标可以直接比较。图里每个棋盘格子标出的就是这对行列值。
(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和3都会被拒绝?查看答案
Board::IsInRange要求行号从0到kBoardRows - 1。kBoardRows是 3,所以-1和3都不属于棋盘。向已有 X 的棋盘格子再落一次 O,棋盘会变吗?
查看答案
不会。
Board::PlaceCell在棋盘格子已被占用时返回PlaceCellResult::kOccupied,棋盘保持不变。cells_[row][col]里的两个下标各是什么?查看答案
row是行号,col是列号。cells_[row][col]就是(row, col)处的棋盘格子。
本章回顾
棋盘用 3×3 的二维数组存九个格子,越界、空类型与占用都在写入前拒绝,结果由 PlaceCellResult 区分;判胜扫八条线,先看 X 再看 O,成线推演复用同一套落子校验。下一章看一次落子怎样改变棋盘,并决定谁继续下。