对局
一局棋从空棋盘开始,落子、换手与终局都由 Game 记录。Game 不自己扫描三连:它检查这一手能不能下,写进 棋盘 的 Board,再按棋盘返回的胜负与满盘状态更新自己。接口在 game.h,实现在 game.cpp。
对局类型
对局状态随落子变化,玩家、落子记录和结果分别用 Player、Move 与 GameResult 表示。
对局玩家
对局玩家用枚举 Player 区分,先手使用 X 棋子:
game.h
/// 对局玩家。
enum class Player : std::uint8_t {
kCross = 0, ///< 先手:使用 X 棋子。
kNought = 1, ///< 后手:使用 O 棋子。
};棋盘接收格子类型,写入前要把玩家转换为格子类型。落子时,TryPlace 用它决定写入哪类棋子;判胜时,JudgeAfterPlace 用它把刚落子的玩家与棋盘上的棋子类型对上:
game.h
/// 由对局玩家得到应写入的棋盘格子类型。
///
/// Player 是“谁在下”,CellType 是“棋盘上是什么”;转换都是 constexpr 纯函数。
///
/// Player::kCross 对应 CellType::kX。
constexpr CellType PlayerToCellType(Player player) {
return player == Player::kCross ? CellType::kX : CellType::kO;
}落子成功后轮到对手。TryPlace 换手时用它取得对手:
game.h
/// 由对局玩家得到对手。
///
/// Player::kCross 对应 Player::kNought。
constexpr Player PlayerToOpponent(Player player) {
return player == Player::kCross ? Player::kNought : Player::kCross;
}获胜时要把玩家转换为对局结果。JudgeAfterPlace 用它填上胜方:
game.h
/// 由对局玩家得到该玩家获胜时的对局结果。
///
/// Player::kCross 对应 GameResult::kCrossWin。
constexpr GameResult PlayerToWinResult(Player player) {
return player == Player::kCross ? GameResult::kCrossWin : GameResult::kNoughtWin;
}三个转换各回答一个问题:落子写什么、下一步轮到谁、谁获胜记成什么。它们都写成 constexpr,编译期就能求值。
落子与成败
一次落子要带上目标坐标和落子的玩家,用结构体 Move 表示:
game.h
/// 一名玩家的一次落子。
struct Move {
CellPosition position;
Player player = Player::kCross;
};Move 不记第几步,步数由它在历史里的位置给出。历史按落子次序追加,回放时顺着读一遍就能重走整局。
落子是否成功写进棋盘,用 PlaceResult 表示:
game.h
/// 一次落子的处理结果。
enum class PlaceResult : std::uint8_t {
kPlaced = 0, ///< 落子成功。
kRejected = 1, ///< 落子被拒,棋盘不变。
};落子成功时,这一步写进棋盘,对局是否继续由判定结果决定。被拒绝时棋盘保持原样,同一名玩家可以再试一次;两种结局靠返回值分辨。
对局结果
整局走到哪一步,用 GameResult 表示:
game.h
/// 对局结果。
enum class GameResult : std::uint8_t {
kInProgress = 0, ///< 游戏进行中:还可以落子。
kCrossWin = 1, ///< X 获胜:达成三连。
kNoughtWin = 2, ///< O 获胜:达成三连。
kDraw = 3, ///< 平局:棋盘已满,双方都没有三连。
};任一名玩家达成三连,对局分出胜负;棋盘下满而没有人达成三连,是平局。
胜负名沿用 Player::kCross、Player::kNought,不再另造 kXWin、kOWin,避免对局层和棋盘层各用一套玩家名。
成员与接口
Game() 默认构造之后,四个成员各自有初值:
board_是这一局的棋盘。开局是空棋盘。current_player_是当前轮到的玩家。开局轮到先手。result_是当前对局结果。开局是游戏进行中。history_是本局落子历史。开局还没有落子。
game.h
/// 一局 X 先手、两名玩家轮流落子的井字棋对局,棋盘、轮次、结果与历史同步更新。
///
/// 落子成功时 TryPlace 同时改写这四项;对局结束后轮次停在最后一名玩家。
/// 历史停在最后一步。
class Game {
public:
/// 默认构造函数:得到一局空棋盘对局,轮到 X 落子。
Game() = default;
private:
/// 本局棋盘:开局是空棋盘。
Board board_;
/// 当前玩家:开局由 X 先手。
Player current_player_ = Player::kCross;
/// 对局结果:落子被拒时保持原值。
GameResult result_ = GameResult::kInProgress;
/// 落子历史:按落子次序保存。
std::vector<Move> history_;
};history_ 的每个元素是前面定义的 Move,坐标与玩家一起保存;四个成员随落子变化,外部通过 Game 的公开成员函数读取。
Game 的公开接口都声明在 game.h 里,落子入口是 TryPlace:
game.h
class Game {
public:
/// 获取当前落子玩家。
[[nodiscard]] Player GetCurrentPlayer() const noexcept;
/// 获取当前对局结果。
[[nodiscard]] GameResult GetResult() const noexcept;
/// 转发给 Board;坐标越界时抛出 std::out_of_range。
///
/// @param position 行列坐标。
/// @throws std::out_of_range 坐标不在棋盘范围内。
[[nodiscard]] CellType GetCellType(CellPosition position) const;
/// 推演在指定坐标放入给定棋子后是否会形成三连。
///
/// 只做推演,不改变对局;电脑落子用它判断“这一步能不能赢”。
/// 具体规则见 Board::PreviewWin:不可落子的位置返回 kInvalidPlace。
///
/// @param position 假设落子的行列坐标。
/// @param cell_type 假设放入的棋盘格子类型。
/// @return 不可落子返回 kInvalidPlace;落子后成线返回 kWouldWin;否则返回 kWouldNotWin。
[[nodiscard]] WinPreviewResult PreviewWin(CellPosition position, CellType cell_type) const;
/// 获取本局落子历史。
///
/// 历史随落子增长,界面和测试都只读;调用方不要跨界面刷新缓存它。
/// 先手第一步在最前,最近一步在末尾。
[[nodiscard]] const std::vector<Move>& GetHistory() const noexcept;
};这五个接口按用途列出:
| 接口 | 返回 | 说明 |
|---|---|---|
GetCurrentPlayer |
当前轮到的玩家 | 开局是 X;落子成功后换成对手,终局后停在最后一名玩家 |
GetResult |
当前对局结果 | 开局为进行中;分出胜负时记录胜方,满盘无三连时记录平局 |
GetCellType |
指定坐标处的 CellType |
转发 Board::GetCellType;坐标越界时抛出 std::out_of_range |
PreviewWin |
假设落子后的推演结果 | 转发 Board::PreviewWin;只读推演,返回 WinPreviewResult |
GetHistory |
落子列表 | 按落子先后排列;被拒绝的落子不追加 |
五个接口都不改动棋盘、轮次或结果。终端程序用它们提示轮次、打印结果、逐格取格子、试算下一步,以及给局面快照编号。
GetHistory 返回引用而不拷贝:历史随落子增长,界面每画一帧都拿一份拷贝没有意义。
落子入口
Game 的落子写入入口是 TryPlace。
落子结果判定
棋盘给出胜负与平局。JudgeAfterPlace 是 Game 的私有成员,声明与定义分在 game.h、game.cpp 两个文件:
game.h
class Game {
private:
/// 判定落子后的对局结果。
///
/// 只看刚落子的一方:它成线就判它胜,否则棋盘下满判平局,都没发生则对局继续。
/// 做成 static 因为它只依赖传入的棋盘与玩家,测试可单独驱动。
///
/// @param board 落子后的棋盘。
/// @param player 刚落子的对局玩家。
[[nodiscard]] static GameResult JudgeAfterPlace(const Board& board, Player player);
};实现写在 game.cpp 里,判定顺序是胜负在前、平局在后:
game.cpp
GameResult Game::JudgeAfterPlace(const Board& board, Player player) {
// 次序不能反:先判胜再判满,否则最后一子同时成线和填满棋盘时会被误判成平局。
const auto winner = board.GetWinner();
if (winner.has_value() && *winner == PlayerToCellType(player)) {
return PlayerToWinResult(player);
}
if (board.IsFull()) {
return GameResult::kDraw;
}
return GameResult::kInProgress;
}Board::GetWinner 返回 std::optional<CellType>:有三连时带赢家,没有时为空;Board::IsFull 判断棋盘是否下满。分出胜负时,胜方是刚落子的玩家。
落子执行
落子从这里进入对局。TryPlace 遇到四种情况直接返回 PlaceResult::kRejected:对局已结束、没轮到该玩家、目标格子已被占用、坐标越界。被拒时棋盘、轮次和历史都不变。
TryPlace 定义在 game.h 里:
game.h
class Game {
public:
/// 尝试执行一次落子。
///
/// 只有对局进行中、且落子玩家正是当前玩家时才继续,棋盘、轮次、历史一起更新。
///
/// @param move 待执行的落子,玩家必须等于 GetCurrentPlayer()。
/// @return 写入棋盘并换手时返回 kPlaced;
/// 终局、玩家不符或棋盘拒绝落子时返回 kRejected。
/// 三种情况都不改变棋盘、轮次和历史。
[[nodiscard]] PlaceResult TryPlace(const Move& move);
};实现写在 game.cpp 里,把校验、写入和判定放在同一次调用里:
game.cpp
PlaceResult Game::TryPlace(const Move& move) {
// 先看对局状态和轮次,再碰棋盘:被拒的落子不应改写任何成员。
if (result_ != GameResult::kInProgress || move.player != current_player_) {
return PlaceResult::kRejected;
}
const CellType cell_type = PlayerToCellType(move.player);
// 历史最多九手;在改棋盘前一次留够容量,push_back 不会在棋盘已改后分配失败。
history_.reserve(static_cast<std::size_t>(kBoardRows) * static_cast<std::size_t>(kBoardCols));
// 落子失败的具体原因由棋盘区分,对局层统一转成 kRejected。
const PlaceCellResult placed = board_.PlaceCell(move.position, cell_type);
if (placed != PlaceCellResult::kSuccess) {
return PlaceResult::kRejected;
}
// 棋盘已经写好,后面三步按依赖顺序更新:先判结果,再记历史,最后换手。
result_ = JudgeAfterPlace(board_, move.player);
history_.push_back(move);
if (result_ == GameResult::kInProgress) {
// 终局后不换手:current_player_ 停在落最后一子的人,界面靠它显示胜负归属。
current_player_ = PlayerToOpponent(current_player_);
}
return PlaceResult::kPlaced;
}TryPlace 把拒绝当成普通返回值:检查全在写入之前,被拒时不需要撤销。写入前预留 history_ 的容量,避免落子过程中再分配。写入后依次判结果、记历史、换手。终局不换手,current_player_ 停在落最后一子的人,界面因此知道刚刚落子的是哪一方。
常见错误
重复落子
再次写入同一个格子时,Board::PlaceCell 返回 PlaceCellResult::kOccupied。TryPlace 再把它转成 PlaceResult::kRejected,原有类型保留;已有棋子不会被后来的落子覆盖。
- 出错的原因:调用方没有检查
TryPlace的返回值,把拒绝当成成功继续换手。 - 正确的做法:按返回值分支,
kRejected时不换手、不记历史。
误判平局
落下一子同时形成三连时,获胜优先,满盘其次。
- 出错的原因:先判满盘再判三连,顺序颠倒会把获胜记成平局。
- 正确的做法:先判三连,再判是否下满;
JudgeAfterPlace的判定顺序与这里一致。
自测问题
空棋盘开局,轮到 X。没轮到的一方落子时,棋盘会改变吗?
查看答案
不会。落子的玩家不是当前玩家,
TryPlace返回kRejected,棋盘和轮次都保持原样。刚创建的一局棋,轮到谁落子?
查看答案
轮到 X。
Game()默认构造后,current_player_的默认值是Player::kCross。落下一子同时让棋盘下满并形成三连。这时对局结果是什么?
查看答案
判获胜,不判平局。
JudgeAfterPlace先问Board::GetWinner有没有赢家。刚落子的玩家已经连成三个,就判获胜。未分出胜负时,再问Board::IsFull棋盘是否已满;已满才判平局。先判胜再判满,最后一子同时成线与填满时不会误判。
本章回顾
你已经能用 Game 处理一次落子,并得到对局结果。下一章电脑落子,把选格交给三种强度。