对局

一局棋从空棋盘开始,落子、换手与终局都由 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;
};

这五个接口按用途列出:

表 6.1: 对局查询接口
接口 返回 说明
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 里:

列表 6.1: TryPlace 的接口
game.h
class Game {
 public:
  /// 尝试执行一次落子。
  ///
  /// 只有对局进行中、且落子玩家正是当前玩家时才继续,棋盘、轮次、历史一起更新。
  ///
  /// @param move 待执行的落子,玩家必须等于 GetCurrentPlayer()。
  /// @return 写入棋盘并换手时返回 kPlaced;
  /// 终局、玩家不符或棋盘拒绝落子时返回 kRejected。
  /// 三种情况都不改变棋盘、轮次和历史。
  [[nodiscard]] PlaceResult TryPlace(const Move& move);
};

实现写在 game.cpp 里,把校验、写入和判定放在同一次调用里:

列表 6.2: TryPlace 的实现
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 的判定顺序与这里一致。

自测问题

  1. 空棋盘开局,轮到 X。没轮到的一方落子时,棋盘会改变吗?

    查看答案

    不会。落子的玩家不是当前玩家,TryPlace 返回 kRejected,棋盘和轮次都保持原样。

  2. 刚创建的一局棋,轮到谁落子?

    查看答案

    轮到 X。Game() 默认构造后,current_player_ 的默认值是 Player::kCross。

  3. 落下一子同时让棋盘下满并形成三连。这时对局结果是什么?

    查看答案

    判获胜,不判平局。JudgeAfterPlace 先问 Board::GetWinner 有没有赢家。刚落子的玩家已经连成三个,就判获胜。未分出胜负时,再问 Board::IsFull 棋盘是否已满;已满才判平局。先判胜再判满,最后一子同时成线与填满时不会误判。

本章回顾

你已经能用 Game 处理一次落子,并得到对局结果。下一章电脑落子,把选格交给三种强度。