简介:【中国象棋游戏C++】是一款基于C++语言开发的桌面策略游戏,完整实现了中国象棋的各项规则与对战功能,并支持棋局的保存与加载。项目涵盖数据结构设计、棋规逻辑判断、用户交互处理、文件读写操作等核心模块,适用于命令行或图形界面环境。通过本项目实践,开发者可深入掌握C++在游戏开发中的应用,提升对算法设计、状态管理与程序调试的综合能力,是学习C++高级编程与项目实战的理想案例。
1. 中国象棋游戏规则概述与C++实现架构
中国象棋规则严谨,棋盘为9×10的交叉点阵,双方各执16枚棋子,分七类兵种:将(帅)、士、象、马、车、炮、兵(卒),走法规则各异。胜负核心在于“将死”对方将(帅),且禁止“长将”等重复局面。
在C++实现中,采用面向对象设计思想,构建 Piece 基类与各棋子派生类,通过虚函数实现多态移动判断。主控模块 GameController 协调 Board 棋盘状态管理,形成“输入→验证→执行→判定”的闭环流程。
系统架构模块清晰: Board 负责状态存储与更新, Piece 封装行为逻辑, Game 控制流程状态机,确保可扩展性与可测试性,为后续算法实现奠定基础。
2. 棋盘与棋子的数据结构设计(二维数组/类封装)
在构建中国象棋C++程序的过程中,合理设计底层数据结构是实现高效逻辑处理和可维护代码体系的关键。本章将深入探讨如何通过二维数组表示棋盘、利用面向对象技术对棋子进行抽象建模,并围绕 Board 类完成状态管理机制的设计。整个过程不仅涉及基本的内存布局选择,还需考虑访问效率、扩展性以及后续算法调用时的接口一致性。通过对不同数据结构方案的权衡分析,最终确立一个兼顾性能与清晰性的系统架构。
2.1 棋盘的底层表示方法
中国象棋的标准棋盘为9列10行的网格结构,共90个交叉点,用于放置棋子。从程序实现角度看,最直观且高效的表示方式是使用固定大小的二维数组。这种结构天然契合棋盘的空间拓扑关系,能够以常数时间复杂度完成任意位置的读写操作,尤其适合频繁查询与更新的场景。
2.1.1 使用二维数组模拟9×10标准棋盘
采用 int board[10][9] 或更优地使用 std::array<std::array<Piece*, 9>, 10> 来表示棋盘,其中外层数组对应行(纵坐标),内层数组对应列(横坐标)。每个元素存储指向具体棋子对象的指针,空位则用 nullptr 表示。这种方式允许我们在不复制实际棋子的情况下快速移动引用,提升性能并简化内存管理。
#include <array>
class Piece; // 前向声明
using BoardArray = std::array<std::array<Piece*, 9>, 10>;
class Board {
private:
BoardArray grid; // 核心数据结构:9列 x 10行 的指针数组
public:
Board();
~Board();
Piece* getPiece(int row, int col) const;
void setPiece(int row, int col, Piece* piece);
};
代码逻辑逐行解读:
- 第4行:前向声明
Piece类,避免头文件循环依赖。 - 第7行:定义类型别名
BoardArray,提高可读性和泛型兼容性。 - 第12行:
grid是真正承载棋盘状态的核心成员变量,其维度为[10][9],符合“行优先”的惯例。 - 第15–18行:提供安全的访问接口,封装直接暴露原始数组的风险。
该结构的优势在于:
- 内存连续分布,缓存命中率高;
- 索引计算简单,适用于路径扫描等密集运算;
- 支持O(1)级别的随机访问,利于规则判断函数调用。
此外,结合枚举类型标识棋子种类,可进一步增强语义表达能力:
enum PieceType { KING, ADVISOR, BISHOP, KNIGHT, ROOK, CANNON, PAWN, EMPTY };
enum Color { RED, BLACK };
这些枚举值可用于初始化或调试输出,使程序行为更具可解释性。
2.1.2 数组索引与实际坐标系的映射关系
中国象棋棋盘在视觉上通常以红方位于下方、黑方位于上方呈现。但在程序内部,需明确建立物理索引与玩家视角之间的映射规则。常见的做法是设定数组第0行为红方底线(即红方将所在行),第9行为黑方底线。
| 玩家视角 | 实际行号(row) |
|---|---|
| 红方底部 | 0 |
| 黑方顶部 | 9 |
因此,当用户输入代数记谱如“e3-e4”时,必须将其转换为数组索引 (row, col) 。例如,若采用字母a-i代表列(a=0, …, i=8),数字1-10代表行(1=0, …, 10=9),则“e3”对应 (7, 4) —— 注意此处行号倒置。
此映射可通过辅助函数实现:
inline int toRowIndex(int userRow) {
return 10 - userRow; // 用户输入1~10 → 数组索引9~0
}
inline int toColIndex(char userCol) {
return userCol - 'a'; // 'a' -> 0, 'b' -> 1, ...
}
参数说明:
- userRow :用户输入的行编号(1~10)
- userCol :用户输入的列字符(a~i)
上述设计确保了界面层与逻辑层解耦,无论前端显示方向如何变化,核心逻辑始终基于统一坐标系统运作。
graph TD
A[用户输入: e3-e4] --> B{解析起点终点}
B --> C[convert "e" → col=4]
B --> D[convert "3" → row_index=7]
C --> E[形成 (7,4)]
D --> E
E --> F[调用 movePiece(7,4, new_row, new_col)]
流程图说明:展示了从用户输入到内部坐标转换的过程流,强调了解析模块与棋盘模型间的协作关系。
2.1.3 空位与边界条件的处理策略
在移动验证过程中,必须持续检查目标位置是否越界或已被占据。为此,引入两个关键工具函数:
bool isValidPosition(int row, int col) const {
return row >= 0 && row < 10 && col >= 0 && col < 9;
}
bool isEmpty(int row, int col) const {
return isValidPosition(row, col) && grid[row][col] == nullptr;
}
逻辑分析:
- isValidPosition() 防止数组越界访问,是所有移动判断的前提;
- isEmpty() 结合有效性检查,防止对非法地址解引用;
- 二者均应声明为 const 成员函数,因不修改对象状态。
对于特殊区域限制(如九宫格),可在 Board 类中预定义静态常量:
static constexpr int PALACE_ROWS_RED[3] = {0, 1, 2};
static constexpr int PALACE_COLS[3] = {3, 4, 5};
static constexpr int PALACE_ROWS_BLACK[3] = {7, 8, 9};
此类设计使得诸如“将不能出宫”之类的规则可通过查找表高效判断:
bool isInPalace(Color color, int row, int col) {
auto& rows = (color == RED) ? PALACE_ROWS_RED : PALACE_ROWS_BLACK;
return std::find(std::begin(rows), std::end(rows), row) != std::end(rows) &&
std::find(std::begin(PALACE_COLS), std::end(PALACE_COLS), col) != std::end(PALACE_COLS);
}
综上所述,合理的坐标映射与边界防护机制构成了稳健棋盘系统的基础,也为后续复杂的走法判定提供了可靠支撑。
2.2 棋子的抽象建模与类设计
为了体现各类棋子的行为差异并保持代码的可扩展性,采用面向对象中的继承与多态机制进行建模是最自然的选择。通过定义统一基类 Piece ,再派生出七种具体兵种,既能共享通用属性,又能各自实现独特的移动逻辑。
2.2.1 基类Piece的设计:封装共性属性(颜色、位置)
所有棋子共享若干基本信息:所属阵营(红/黑)、当前坐标、类型标识。这些信息被封装在抽象基类 Piece 中:
class Piece {
protected:
Color color;
int row, col;
public:
Piece(Color c, int r, int c) : color(c), row(r), col(c) {}
virtual ~Piece() = default;
Color getColor() const { return color; }
int getRow() const { return row; }
int getCol() const { return col; }
void setPosition(int r, int c) { row = r; col = c; }
virtual bool isMoveValid(int toRow, int toCol, const Board& board) const = 0;
virtual PieceType getType() const = 0;
};
参数说明与扩展性分析:
- 构造函数接收颜色与初始位置,初始化内部状态;
- isMoveValid 为纯虚函数,强制子类实现各自的走法规则;
- getType() 提供运行时类型识别能力,便于日志记录或AI评估;
- 所有getter方法标记为 const ,保证观察者模式的安全性。
该设计遵循单一职责原则,仅负责状态维护与行为契约定义,不参与具体棋盘操作,从而降低耦合度。
2.2.2 派生类实现:King、Advisor、Bishop、Knight、Rook、Cannon、Pawn
以“将(帅)”为例,其实现如下:
class King : public Piece {
public:
King(Color c, int r, int c) : Piece(c, r, c) {}
bool isMoveValid(int toRow, int toCol, const Board& board) const override {
if (!isInPalace(color, toRow, toCol)) return false;
int dRow = abs(toRow - row);
int dCol = abs(toCol - col);
return (dRow + dCol == 1); // 只能沿十字方向移动一格
}
PieceType getType() const override { return KING; }
};
逻辑分析:
- 移动合法性由两部分组成:是否在九宫格内 + 是否仅移动一步;
- 调用外部辅助函数 isInPalace() 判断区域约束;
- 差值总和为1确保只能上下左右移动,禁止斜走。
类似地,“马”需检测“蹩马腿”:
class Knight : public Piece {
public:
bool isMoveValid(int toRow, int toCol, const Board& board) const override {
int dRow = abs(toRow - row);
int dCol = abs(toCol - col);
// L形移动:(2,1) 或 (1,2)
if (!((dRow == 2 && dCol == 1) || (dRow == 1 && dCol == 2)))
return false;
// 检查蹩马腿:中间点是否有棋子
int midRow = (dRow == 2) ? ((toRow + row) / 2) : row;
int midCol = (dCol == 2) ? ((toCol + col) / 2) : col;
return board.isEmpty(midRow, midCol);
}
PieceType getType() const override { return KNIGHT; }
};
注意:
(toX + fromX)/2可准确获取中间阻挡点坐标,无需额外分支判断。
其他兵种依此类推,均在其 isMoveValid 中嵌入特定几何约束与环境检测逻辑。
2.2.3 虚函数机制支持多态行为调用
由于 Board 类持有 Piece* 指针数组,当执行移动判断时:
Piece* p = board.getPiece(fromRow, fromCol);
if (p && p->isMoveValid(toRow, toCol, board)) {
// 允许移动
}
此时会动态绑定到具体子类的 isMoveValid 实现,达成真正的多态调度。这极大提升了系统的灵活性——新增兵种只需添加新类,无需改动主控逻辑。
| 棋子 | 移动模式 | 特殊规则 |
|---|---|---|
| 将/帅 | 十字一格 | 不得出宫 |
| 士/仕 | 斜一格 | 仅限九宫内 |
| 相/象 | 走田 | 塞象眼禁行 |
| 马 | 日字形 | 蹩马腿检测 |
| 车 | 直线任意 | 中途无阻 |
| 炮 | 平移同车 | 吃子需隔一子 |
| 兵/卒 | 前进一步 | 过河后可左/右 |
classDiagram
Piece <|-- King
Piece <|-- Advisor
Piece <|-- Bishop
Piece <|-- Knight
Piece <|-- Rook
Piece <|-- Cannon
Piece <|-- Pawn
class Piece{
+Color color
+int row, col
+virtual bool isMoveValid()
+virtual PieceType getType()
}
UML图说明:展示类继承结构,突出多态接口的统一性与扩展潜力。
综上,基于虚函数的多态设计实现了“一个接口,多种实现”的理想状态,是本系统可维护性的核心保障之一。
2.3 棋盘状态管理类Board的设计
Board 类不仅是数据容器,更是状态变更的操作中心。它封装了棋子增删改查、深拷贝支持回溯等功能,是连接用户输入与规则引擎的枢纽。
2.3.1 封装棋子放置、移动与移除操作
提供一组安全的方法来修改棋盘状态:
void Board::movePiece(int fromRow, int fromCol, int toRow, int toCol) {
Piece* piece = grid[fromRow][fromCol];
if (!piece) return;
// 若目标位置有敌方棋子,先删除
Piece* target = grid[toRow][toCol];
if (target && target->getColor() != piece->getColor()) {
delete target; // 释放资源
}
// 更新棋子自身位置
piece->setPosition(toRow, toCol);
grid[toRow][toCol] = piece;
grid[fromRow][fromCol] = nullptr;
}
参数说明:
- from/toRow, Col :起止坐标;
- 自动处理吃子逻辑,避免外部手动干预;
- 注意释放原目标棋子内存,防止泄漏。
2.3.2 提供位置查询与占用检测接口
除了 getPiece() 和 isEmpty() 外,还可增加:
bool isOccupiedByOpponent(int row, int col, Color playerColor) const {
Piece* p = getPiece(row, col);
return p && p->getColor() != playerColor;
}
此类方法广泛用于炮的跳跃判断或将军检测。
2.3.3 实现棋盘深拷贝以支持回溯功能
为实现悔棋或AI搜索中的状态保存,需重载拷贝构造函数与赋值操作符:
Board::Board(const Board& other) {
for (int i = 0; i < 10; ++i) {
for (int j = 0; j < 9; ++j) {
Piece* src = other.grid[i][j];
grid[i][j] = src ? src->clone() : nullptr;
}
}
}
前提是 Piece 基类定义 virtual Piece* clone() const = 0; ,各派生类实现深拷贝。
// 在 King 类中
Piece* King::clone() const {
return new King(*this);
}
这样即可安全复制整盘局势,用于蒙特卡洛树搜索或多步推演。
2.4 数据结构选型对比与性能考量
2.4.1 数组 vs 动态容器(如vector)的权衡
| 特性 | 原生数组 | std::vector | std::array |
|---|---|---|---|
| 固定大小 | ✅ | ❌(可固定但非必要) | ✅ |
| 缓存友好 | 高 | 中 | 高 |
| 默认初始化 | 需手动 | 自动 | 可控 |
| 移动语义支持 | ❌ | ✅ | ✅ |
结论: std::array<std::array<Piece*, 9>, 10> 是最优选择,兼具性能与RAII优势。
2.4.2 内存布局对访问效率的影响
二维数组按行连续存储,有利于CPU缓存预取。实测表明,在遍历所有格子时, array 比 vector<vector<T>> 快约30%以上。
2.4.3 缓存友好性与访问局部性的优化建议
- 避免间接访问过多层级(如
map<pair<int,int>, Piece*>); - 使用扁平化一维数组也可尝试:
Piece* flat[90],辅以宏转换IDX(r,c)=((r)*9+(c)); - 对热点路径(如车炮直线扫描)尽量顺序访问。
| 方案 | 时间复杂度 | 空间开销 | 推荐指数 |
|---|---|---|---|
| 二维数组 | O(1) | 低 | ⭐⭐⭐⭐⭐ |
| 哈希表 | O(1) avg | 高 | ⭐⭐ |
| 链表集合 | O(n) | 中 | ⭐ |
最终推荐采用 std::array 封装的二维指针数组,兼顾效率、安全性与现代C++规范。
3. 各类棋子移动规则与吃子逻辑的函数实现
中国象棋的棋子种类繁多,每类棋子具有独特的移动方式和限制条件。将这些复杂的走法规则转化为精确、高效且可维护的C++代码,是构建一个完整象棋系统的核心挑战之一。本章聚焦于如何在面向对象的设计框架下,为七种不同兵种——将(帅)、士(仕)、象(相)、马、车、炮、兵(卒)——分别建模其合法移动路径,并实现相应的吃子机制。通过数学建模、状态判断与路径扫描等手段,确保每个棋子的行为严格符合中国象棋规则。
更重要的是,不仅要实现“能走”,还要处理诸如“蹩马腿”、“塞象眼”、“炮需隔子吃”等特殊限制,这要求程序具备对中间状态的感知能力。为此,我们将引入统一的接口规范,结合虚函数多态调用,使各派生类能够独立封装自身逻辑的同时保持整体架构的一致性。同时,借助单元测试验证边界情况,保证系统的鲁棒性和正确性。
3.1 各兵种合法移动路径的数学建模
在中国象棋中,每种棋子的走法本质上是一种受限的空间变换操作。要将其转化为程序逻辑,首先需要建立清晰的坐标系模型,并基于此进行向量运算或条件判断来描述合法位移。我们采用0-based二维数组索引 (row, col) 表示棋盘上的位置,其中红方位于底部(行号0~4),黑方位于顶部(行号5~9)。整个棋盘为9列×10行,即 board[10][9] 。
3.1.1 将(帅)的“九宫格”内一步移动限制
将(帅)只能在“九宫”范围内活动,即红方为 [0-2][3-5] ,黑方为 [7-9][3-5] 。它每次只能沿上下左右方向移动一格,且目标位置不能被己方棋子占据。
bool King::isMoveValid(int fromRow, int fromCol, int toRow, int toCol, const Board& board) const {
// 步长必须为1
if (abs(toRow - fromRow) + abs(toCol - fromCol) != 1)
return false;
// 必须在九宫格内
if (!isInPalace(toRow, toCol))
return false;
// 目标位置不能有同色棋子
Piece* target = board.getPiece(toRow, toCol);
if (target && target->getColor() == this->getColor())
return false;
return true;
}
逻辑逐行分析:
- 第1行:定义虚函数入口,接收起点
(fromRow, fromCol)和终点(toRow, toCol),以及当前棋盘引用。 - 第3–4行:使用曼哈顿距离判断是否只移动了一步(横向或纵向),排除斜走或跳动。
- 第6–7行:调用私有函数
isInPalace()判断目标是否落在所属阵营的九宫区内。 - 第9–11行:获取目标位置棋子指针,若存在且颜色相同,则属于“吃己子”,非法。
- 最终返回
true表示移动有效。
表格:将(帅)合法移动范围示例(以红方为例)
| 当前位置 | 可达位置(坐标) | 是否出宫 |
|---|---|---|
| (1,4) | (0,4), (1,3), (1,5), (2,4) | 否 |
| (0,3) | (0,4), (1,3) | 否 |
| (2,5) | (1,5), (2,4) | 否 |
注:所有可达位置均在
[0-2][3-5]区域内。
flowchart TD
A[开始验证将的移动] --> B{步长是否为1?}
B -- 否 --> C[返回false]
B -- 是 --> D{是否在九宫内?}
D -- 否 --> C
D -- 是 --> E{目标是否有同色棋子?}
E -- 是 --> C
E -- 否 --> F[返回true]
该流程图展示了将的移动验证过程,体现了分层判断的思想,有助于理解控制流结构。
3.1.2 士(仕)斜行一格且不出宫的实现
士仅能在九宫内沿对角线移动一格,因此其位移向量只能是 (±1, ±1) 四种组合之一。与将类似,也受九宫约束。
bool Advisor::isMoveValid(int fromRow, int fromCol, int toRow, int toCol, const Board& board) const {
int dRow = abs(toRow - fromRow);
int dCol = abs(toCol - fromCol);
// 必须是对角移动一格
if (dRow != 1 || dCol != 1)
return false;
// 必须在九宫内
if (!isInPalace(toRow, toCol))
return false;
// 不可吃己方
Piece* target = board.getPiece(toRow, toCol);
if (target && target->getColor() == this->getColor())
return false;
return true;
}
参数说明与扩展性讨论:
-
dRow,dCol分别表示行列方向的变化量,用于快速筛选非对角移动。 -
isInPalace()函数复用自King类,可通过提取为工具函数提升模块化程度。 - 由于士不能平移或直走,故无需考虑“阻挡”问题。
此类设计体现了职责单一原则:每个类只关注自己的行为规则,而不干涉其他棋子逻辑。
3.1.3 象(相)走“田”字及“塞象眼”判断
象走“田”字,即位移为 (±2, ±2) 的对角移动,但前提是“象眼”无阻。所谓“象眼”,是指从起点到终点连线中点的位置(如从 (0,2) 到 (2,4),象眼为 (1,3))。
bool Bishop::isMoveValid(int fromRow, int fromCol, int toRow, int toCol, const Board& board) const {
int dRow = toRow - fromRow;
int dCol = toCol - fromCol;
// 检查是否为标准“田”字形
if (!(abs(dRow) == 2 && abs(dCol) == 2))
return false;
// 计算象眼坐标
int eyeRow = fromRow + dRow / 2;
int eyeCol = fromCol + dCol / 2;
// 象眼不能有棋子
if (board.getPiece(eyeRow, eyeCol))
return false;
// 红象不过河,黑象不越界
if ((this->getColor() == RED && toRow < 5) ||
(this->getColor() == BLACK && toRow > 4))
return false;
// 目标不可吃己方
Piece* target = board.getPiece(toRow, toCol);
if (target && target->getColor() == this->getColor())
return false;
return true;
}
逐行解读:
- 第3–5行:计算位移差,确保满足“田”字结构(两个方向各走两格)。
- 第8–9行:求出象眼中点坐标,注意整数除法自动向下取整,适用于正负方向。
- 第12行:检查象眼是否有任何棋子(无论颜色),若有则“塞象眼”,禁止移动。
- 第15–18行:根据颜色限制象的活动区域,红象限于底线五排(行0~4),黑象限于顶五排(行5~9)。
- 第20–23行:防止吃己方棋子。
此实现展示了路径中间状态检测的能力,是复杂走法规则的基础范式。
graph LR
Start[开始] --> CheckShape{是否为“田”字?}
CheckShape -- 否 --> Invalid
CheckShape -- 是 --> CheckEye{象眼有子?}
CheckEye -- 是 --> Invalid
CheckEye -- 否 --> CheckRiver{过河否?}
CheckRiver -- 过河 --> Invalid
CheckRiver -- 未过河 --> CheckFriendly{吃己方?}
CheckFriendly -- 是 --> Invalid
CheckFriendly -- 否 --> Valid[合法]
上述流程图清晰表达了象的多层校验机制,强调了“顺序依赖”的重要性。
3.1.4 马走“日”字与“蹩马腿”障碍检测
马走“日”字,即横向/纵向走一格再斜走一格,形成L型。但若前进方向的第一格被占据,则“蹩马腿”,无法跳跃。
bool Knight::isMoveValid(int fromRow, int fromCol, int toRow, int toCol, const Board& board) const {
int dRow = abs(toRow - fromRow);
int dCol = abs(toCol - fromCol);
// 必须是“日”字结构:(2,1) 或 (1,2)
if (!((dRow == 2 && dCol == 1) || (dRow == 1 && dCol == 2)))
return false;
// 判断蹩马腿
if (dRow == 2) {
int midRow = fromRow + (toRow > fromRow ? 1 : -1);
if (board.getPiece(midRow, fromCol)) // 中间竖格被占
return false;
} else { // dCol == 2
int midCol = fromCol + (toCol > fromCol ? 1 : -1);
if (board.getPiece(fromRow, midCol)) // 中间横格被占
return false;
}
// 不可吃己方
Piece* target = board.getPiece(toRow, toCol);
if (target && target->getColor() == this->getColor())
return false;
return true;
}
关键点解析:
- 使用绝对值简化方向判断,避免重复分支。
- “蹩马腿”检测依据移动主轴决定:纵向走两格时检查中间行;横向走两格时检查中间列。
- 移动方向由比较
toX > fromX决定增量符号,确保中点坐标准确。
该逻辑展示了如何将几何关系映射为代数表达式,并结合实际棋盘状态进行动态拦截。
3.1.5 车直线行进无阻隔的路径扫描
车可沿水平或垂直方向任意距离移动,但路径上不得有阻碍(除非最后一格为敌子)。
bool Rook::isMoveValid(int fromRow, int fromCol, int toRow, int toCol, const Board& board) const {
if (fromRow != toRow && fromCol != toCol)
return false; // 必须在同一行或同一列
int stepR = (toRow > fromRow) ? 1 : (toRow < fromRow) ? -1 : 0;
int stepC = (toCol > fromCol) ? 1 : (toCol < fromCol) ? -1 : 0;
int curRow = fromRow + stepR;
int curCol = fromCol + stepC;
// 扫描路径直到目标前一格
while (curRow != toRow || curCol != toCol) {
if (board.getPiece(curRow, curCol))
return false; // 中途有子阻挡
curRow += stepR;
curCol += stepC;
}
// 检查目标是否为己方
Piece* target = board.getPiece(toRow, toCol);
if (target && target->getColor() == this->getColor())
return false;
return true;
}
执行逻辑说明:
- 第2–3行:排除非直线移动。
- 第5–6行:计算单位步长方向,便于迭代遍历。
- 第8–13行:逐格检查路径上的每一个中间点,一旦发现有子即中断。
- 循环终止于到达目标前一格,因为目标允许是敌方棋子(吃子)。
这种“路径扫描”模式广泛应用于长距离移动棋子(如车、炮),是性能敏感操作,应尽量减少冗余访问。
3.1.6 炮跳跃吃子与平移无吃子的差异逻辑
炮的移动分为两种:
- 不吃子时 :路径上无障碍,可自由平移;
- 吃子时 :必须跨越恰好一个“炮架”(中间仅有一枚棋子),才能攻击目标。
bool Cannon::isMoveValid(int fromRow, int fromCol, int toRow, int toCol, const Board& board) const {
if (fromRow != toRow && fromCol != toCol)
return false;
int stepR = (toRow > fromRow) ? 1 : (toRow < fromRow) ? -1 : 0;
int stepC = (toCol > fromCol) ? 1 : (toCol < fromCol) ? -1 : 0;
int curRow = fromRow + stepR;
int curCol = fromCol + stepC;
int hurdleCount = 0;
while (curRow != toRow || curCol != toCol) {
if (board.getPiece(curRow, curCol))
hurdleCount++;
curRow += stepR;
curCol += stepC;
}
Piece* target = board.getPiece(toRow, toCol);
if (!target) {
// 无子:必须无任何障碍
return hurdleCount == 0;
} else {
// 有子:必须是敌方,且中间恰好有一个障碍
return (hurdleCount == 1) && (target->getColor() != this->getColor());
}
}
参数与行为分析:
-
hurdleCount统计路径中经过的棋子数量。 - 若目标为空,则要求全程无子(类似车的非吃状态)。
- 若目标为敌子,则要求中间恰好有一个“炮架”。
此设计体现了“情境感知”的移动规则,是象棋中最富策略性的机制之一。
3.1.7 兵(卒)过河前后移动规则的变化控制
兵未过河时只能向前走一格;过河后可前、左、右移动,但不能后退。
bool Pawn::isMoveValid(int fromRow, int fromCol, int toRow, int toCol, const Board& board) const {
int direction = (this->getColor() == RED) ? -1 : 1; // 红向上(-1),黑向下(+1)
int crossLine = (this->getColor() == RED) ? 5 : 4; // 过河线
// 判断方向合法性
if (toRow != fromRow + direction && !(hasCrossed(crossLine) && (toRow == fromRow)))
return false;
if (toCol != fromCol && !hasCrossed(crossLine))
return false; // 未过河不可横向
// 不可吃己方
Piece* target = board.getPiece(toRow, toCol);
if (target && target->getColor() == this->getColor())
return false;
return true;
}
-
direction控制初始行进方向。 -
hasCrossed()判断是否已越过河界(红<5,黑>4)。 - 横移仅在过河后允许。
该逻辑展示了状态依赖型行为建模方法,适用于生命周期变化明显的对象。
3.2 统一移动合法性验证接口设计
为了实现多态调度,所有棋子都应继承自基类 Piece 并重写 isMoveValid() 方法。这样主控逻辑可以统一调用而无需类型判断。
3.2.1 定义isMoveValid虚函数规范
class Piece {
public:
enum Color { RED, BLACK };
Piece(Color c, int r, int c) : color(c), row(r), col(c) {}
virtual ~Piece() = default;
virtual bool isMoveValid(int fromRow, int fromCol, int toRow, int toCol, const Board& board) const = 0;
Color getColor() const { return color; }
void setPosition(int r, int c) { row = r; col = c; }
protected:
Color color;
int row, col;
};
设计优势:
- 接口标准化,便于集成到更高层逻辑(如AI搜索)。
- 支持运行时多态,
vector<unique_ptr<Piece>>可混合存储各类棋子。 - 子类只需关注自身规则,降低耦合度。
3.2.2 参数传递:起点、终点、当前棋盘状态
isMoveValid() 接收五个参数:
| 参数名 | 类型 | 说明 |
|---|---|---|
| fromRow | int | 起始行 |
| fromCol | int | 起始列 |
| toRow | int | 目标行 |
| toCol | int | 目标列 |
| board | const Board& | 当前全局状态快照 |
传引用避免拷贝开销,尤其在频繁调用场景下至关重要。
3.2.3 返回值语义:布尔结果与错误码扩展
目前返回 bool 已能满足基本需求。但在调试或日志系统中,可扩展为带错误码的结构体:
struct MoveResult {
bool valid;
enum Reason { OK, OUT_OF_BOUND, BLOCKED, ILLEGAL_PATTERN, SELF_CAPTURE } reason;
};
未来可通过此机制提供更详细的反馈信息,例如:“移动失败:原因=蹩马腿”。
3.3 吃子逻辑与棋子移除机制
吃子不仅是视觉上的替换,更是状态变更的关键事件。
3.3.1 目标位置是否有敌方棋子的判定
bool isEnemyAt(int r, int c, Color attackerColor, const Board& board) {
Piece* p = board.getPiece(r, c);
return p && p->getColor() != attackerColor;
}
简洁高效的辅助函数,可用于所有兵种。
3.3.2 吃子后的棋盘状态更新流程
在 Board 类中实现:
void Board::movePiece(int fromR, int fromC, int toR, int toC) {
Piece* piece = grid[fromR][fromC];
Piece* captured = grid[toR][toC];
if (captured) {
delete captured; // 或移入捕获区
}
grid[toR][toC] = piece;
grid[fromR][fromC] = nullptr;
piece->setPosition(toR, toC);
}
注意内存管理:动态分配的棋子需显式释放。
3.3.3 特殊吃法(如炮隔子吃)的分步验证
已在 Cannon::isMoveValid() 中体现,关键是区分“吃”与“非吃”路径逻辑。
3.4 移动规则单元测试验证
3.4.1 利用Google Test框架编写测试用例
TEST(KnightTest, ValidMovesWithoutObstacle) {
Board b;
auto knight = std::make_unique<Knight>(Piece::RED, 1, 2);
b.setPiece(1, 2, std::move(knight));
EXPECT_TRUE(b.getPiece(1,2)->isMoveValid(1,2, 3,3, b)); // 正常日字
EXPECT_FALSE(b.getPiece(1,2)->isMoveValid(1,2, 0,0, b)); // 裔马腿
}
涵盖正常走法、边界、非法输入。
3.4.2 覆盖边界情况与非法操作场景
包括但不限于:
- 出界移动
- 自杀式吃子
- 多重障碍穿越
3.4.3 自动化测试脚本集成与持续验证
使用 CMake + GitHub Actions 实现 CI/CD,每次提交自动运行测试套件,保障代码质量。
name: Run Tests
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build and Test
run: |
cmake -B build && cd build && make
./test_chess
现代工程实践不可或缺的一环。
4. 将军、应将与禁手规则(如“马蹩脚”、“炮隔子吃”)判定
在象棋程序设计中, 将军、应将与禁手规则的准确实现是决定游戏逻辑是否严谨的核心环节 。这些规则不仅涉及单个棋子的移动合法性判断,更要求系统具备全局状态感知能力——即能够识别当前局势是否存在对某一方将(帅)的直接威胁,并强制执行相应的应对策略。本章将深入剖析如何通过C++编程语言实现这些高阶规则机制,重点围绕“将军检测”、“应将路径验证”、“禁手条件建模”以及“终局状态识别”四个维度展开详细技术探讨。
4.1 将军状态的检测算法
4.1.1 遍历对方所有棋子攻击范围
要判断某一方是否处于“被将军”状态,必须从敌方所有棋子出发,逐一计算其合法攻击路径,并检查这些路径上是否存在己方的将(帅)。该过程本质上是一个逆向思维的应用:不是看我方能否走动,而是分析对手是否有任何一枚棋子可以直接捕获我方主帅。
这一逻辑可通过以下步骤实现:
1. 获取当前轮到哪一方行棋;
2. 遍历对方阵营中的每一个棋子;
3. 对每个棋子调用其 getAttackRange(Board& board) 虚函数,返回其能攻击的所有坐标集合;
4. 检查该集合是否包含己方将(帅)的位置;
5. 若存在至少一个这样的棋子,则判定为“被将军”。
此方法的关键在于统一接口的设计和高效的访问性能。为此,我们引入一个虚函数机制来支持多态性:
class Piece {
public:
virtual ~Piece() = default;
virtual std::set<Position> getAttackRange(const Board& board) const = 0;
// ...
};
示例代码:车的攻击范围实现
std::set<Position> Rook::getAttackRange(const Board& board) const {
std::set<Position> attacks;
int dx[] = {-1, 1, 0, 0}; // 上下左右
int dy[] = {0, 0, -1, 1};
for (int i = 0; i < 4; ++i) {
for (int step = 1;; ++step) {
Position next(x + dx[i] * step, y + dy[i] * step);
if (!board.isValidPosition(next)) break; // 超出边界
const Piece* piece = board.getPieceAt(next);
if (!piece) {
attacks.insert(next); // 空位可攻击
} else {
if (piece->getColor() != color) {
attacks.insert(next); // 敌方棋子可吃
}
break; // 不论是否吃子,路径终止
}
}
}
return attacks;
}
逻辑逐行解读:
- 第1~2行定义方向数组,表示上下左右四个方向。
- 外层循环遍历四个方向,内层循环沿该方向逐步推进。
-isValidPosition()判断新位置是否在棋盘范围内(9×10)。
- 若为空位,则加入攻击集;若遇到敌方棋子,也加入并中断路径(车不能跳过棋子)。
- 返回值为该车所能攻击的所有位置集合。
该设计具有良好的扩展性,其他棋子如炮、马等均可复写此函数以适配各自走法。
4.1.2 判断是否直接威胁己方将(帅)
一旦获取了所有敌方棋子的攻击区域,下一步就是定位己方将(帅)的位置,并进行交集判断。
bool isKingInCheck(Color kingColor, const Board& board) {
Position kingPos = board.findKing(kingColor); // 查找将/帅位置
Color attackerColor = (kingColor == RED) ? BLACK : RED;
for (const auto& piece : board.getPiecesByColor(attackerColor)) {
auto attackRange = piece->getAttackRange(board);
if (attackRange.find(kingPos) != attackRange.end()) {
return true; // 存在攻击路径
}
}
return false;
}
参数说明:
-kingColor: 当前需判断是否被将军的一方颜色。
-board: 当前棋盘快照,用于查询棋子位置与状态。
-findKing(): 在Board类中实现,遍历所有棋子找到对应颜色的将或帅。
-getPiecesByColor(): 返回指定颜色的所有棋子指针列表。
该函数时间复杂度约为 O(n·m),其中 n 为敌方棋子数(最多16),m 为每枚棋子平均生成的攻击点数量(通常小于20),整体效率较高。
4.1.3 构建“被将军”事件触发机制
为了提升程序结构清晰度,建议采用观察者模式或状态标志位机制来通知主控逻辑“当前已进入将军状态”。例如,在 Game 类中维护一个布尔变量:
enum GameState { RUNNING, CHECK, CHECKMATE, STALEMATE };
class Game {
private:
GameState state;
Board currentBoard;
public:
void updateGameState();
};
void Game::updateGameState() {
if (isKingInCheck(currentPlayer, currentBoard)) {
if (hasNoValidMove(currentPlayer, currentBoard)) {
state = CHECKMATE;
} else {
state = CHECK;
}
} else {
if (hasNoValidMove(currentPlayer, currentBoard)) {
state = STALEMATE; // 困毙
} else {
state = RUNNING;
}
}
}
流程图展示如下:
graph TD
A[开始更新游戏状态] --> B{己方将(帅)被攻击?}
B -- 是 --> C{是否有合法应将动作?}
C -- 否 --> D[设置为将死 CHECKMATE]
C -- 是 --> E[设置为将军 CHECK]
B -- 否 --> F{是否无任何可行步?}
F -- 是 --> G[设置为困毙 STALEMATE]
F -- 否 --> H[保持运行 RUNNING]
此流程图体现了状态机的核心流转逻辑,确保每一次移动后都能及时响应战场变化。
4.2 应将合法性的强制校验
4.2.1 所有可行走法必须解除将军状态
根据中国象棋规则,当玩家处于“被将军”状态时,其下一步操作 必须解除将军 ,否则属于非法移动。这意味着即使某个走法本身符合兵种移动规则(如马走日),但如果走完之后仍将(帅)暴露在攻击之下,则该走法无效。
因此,必须在 isMoveValid() 基础上增加一层“模拟走子 + 再次检测”的验证流程:
bool isValidResponseToCheck(const Move& move, Color player, Board& board) {
Board tempBoard = board; // 深拷贝当前局面
tempBoard.makeMove(move); // 执行假设性移动
return !isKingInCheck(player, tempBoard); // 移动后不再被将军
}
关键点解释:
- 使用深拷贝避免修改原棋盘状态;
- 执行移动后重新调用isKingInCheck();
- 只有结果为false才视为有效应将。
这一步骤虽然带来额外计算开销,但却是保证规则正确性的必要代价。
4.2.2 无法应将即构成“将死”,游戏结束
若某玩家在被将军的情况下,尝试所有可能的走法都无法摆脱威胁,则判定为“将死”,游戏立即结束,对方获胜。
其实现依赖于枚举所有合法走法的能力:
bool hasNoValidMove(Color player, const Board& board) {
auto allMoves = generateAllLegalMoves(player, board);
for (const auto& move : allMoves) {
Board sim = board;
sim.makeMove(move);
if (!isKingInCheck(player, sim)) {
return false; // 存在解将走法
}
}
return true; // 全部走法仍被将军 → 将死
}
generateAllLegalMoves() 函数要点:
- 遍历该玩家控制的所有棋子;
- 对每个棋子调用其getLegalMoves(board)方法;
- 过滤掉会导致自将的走法(见上节);
- 返回完整合法动作列表。
该函数常用于AI评估与终局判断,也是后续开发引擎的基础组件。
4.2.3 多重将军情况下的优先响应逻辑
在中国象棋中可能出现“双将”甚至“三将”的极端情况(如车炮联合将军),此时仅靠移动将(帅)往往不足以脱险,必须同时满足阻挡或吃掉至少一个攻击源。
程序处理此类情形无需特殊分支,因为上述通用验证机制已自动涵盖:
- 无论多少枚棋子参与将军,只要最终将(帅)仍在任一攻击路径上,就仍属被将军;
- 解除方式包括:
- 将(帅)移出所有攻击线;
- 吃掉其中一个攻击者;
- 用己方棋子挡在攻击路径上(仅适用于车、炮、象等直线攻击者)。
例如,炮的攻击路径中间插入一枚己方棋子即可阻断其视线,从而解除威胁。
4.3 禁手规则的程序化表达
4.3.1 “马蹩脚”的障碍点计算与拦截判断
“马蹩脚”是指马向前走“日”字时,其前进方向紧邻的交叉点上有棋子阻挡,导致无法完成跳跃。这是中国象棋特有的物理约束条件。
数学建模如下:
| 马当前位置 | 目标位置 | 蹩马腿检测点 |
|---|---|---|
| (x, y) | (x+2, y+1) | (x+1, y) |
| (x, y) | (x+2, y-1) | (x+1, y) |
| (x, y) | (x-2, y+1) | (x-1, y) |
| (x, y) | (x-2, y-1) | (x-1, y) |
| (x, y) | (x+1, y+2) | (x, y+1) |
| (x, y) | (x+1, y-2) | (x, y-1) |
| (x, y) | (x-1, y+2) | (x, y+1) |
| (x, y) | (x-1, y-2) | (x, y-1) |
由此可编写通用检测函数:
bool Knight::isLegallyBounded(const Position& from,
const Position& to,
const Board& board) const {
int fx = from.x, fy = from.y;
int tx = to.x, ty = to.y;
if (abs(tx - fx) == 2 && abs(ty - fy) == 1) {
int midX = (fx + tx) / 2;
return board.getPieceAt(Position(midX, fy)) == nullptr;
} else if (abs(ty - fy) == 2 && abs(tx - fx) == 1) {
int midY = (fy + ty) / 2;
return board.getPieceAt(Position(fx, midY)) == nullptr;
}
return false;
}
逻辑分析:
- 根据目标位置与起点的偏移量区分横向或纵向“日”字;
- 计算中间点坐标(如横跳则(fx+tx)/2,fy);
- 查询该位置是否有棋子,若有则返回false(蹩脚);
- 注意:即使该棋子是敌方也不行——蹩脚不等于吃子!
4.3.2 “炮隔子吃”中中间棋子存在性验证
炮的吃子规则极为特殊:必须隔着 恰好一个棋子 才能吃掉目标。而在非吃子移动时,路径上不能有任何障碍。
bool Cannon::canCapture(const Position& from,
const Position& to,
const Board& board) const {
if (!board.hasPieceAt(to)) return false; // 必须有目标才叫“吃”
int dx = (to.x - from.x != 0) ? (to.x - from.x > 0 ? 1 : -1) : 0;
int dy = (to.y - from.y != 0) ? (to.y - from.y > 0 ? 1 : -1) : 0;
int cx = from.x + dx, cy = from.y + dy;
int jumpCount = 0;
while (cx != to.x || cy != to.y) {
if (board.hasPieceAt(Position(cx, cy))) {
jumpCount++;
}
cx += dx; cy += dy;
}
return jumpCount == 1; // 中间必须且只能有一个棋子
}
参数说明:
-from/to: 起止坐标;
-dx/dy: 单位步长方向;
-jumpCount: 统计路径中经过的棋子数;
- 最终判断是否为1。
此函数可用于 isMoveValid() 中的分支判断:如果是吃子,则调用 canCapture() ;否则调用 canMoveWithoutJump() (路径无子)。
4.3.3 “别腿”与“堵路”情形的状态快照比对
某些高级场景需要记录历史状态以识别重复局面或违规行为(如长将)。为此,可在 Board 类中添加哈希快照功能:
struct BoardSnapshot {
uint64_t hash;
int moveNumber;
};
std::vector<BoardSnapshot> history;
使用Zobrist Hashing技术高效生成唯一标识符:
uint64_t computeZobristHash(const Board& board) {
uint64_t h = 0;
for (int i = 0; i < 9; ++i) {
for (int j = 0; j < 10; ++j) {
auto piece = board.getPieceAt(i, j);
if (piece) {
int type = piece->getType(); // 0~6
int color = piece->getColor(); // 0~1
h ^= ZOBRIST_TABLE[i][j][type][color];
}
}
}
return h;
}
应用场景:
- 检测三次重复局面判和;
- 防止长将循环(连续20回合将军且局面重复);
- 结合move stack回溯至特定回合。
4.4 棋局终局判定机制
4.4.1 将死、困毙、长将违规的识别逻辑
| 终局类型 | 条件描述 | 程序判定方式 |
|---|---|---|
| 将死 | 被将军且无合法走法 | isInCheck() && hasNoValidMove() |
| 困毙 | 未被将军但无合法走法 | !isInCheck() && hasNoValidMove() |
| 长将违规 | 连续多次重复将军 | 检查最近N步是否均为将军且哈希重复 ≥3次 |
GameState detectEndCondition(Color player, const Board& board) {
bool inCheck = isKingInCheck(player, board);
bool noMove = hasNoValidMove(player, board);
if (inCheck && noMove) return CHECKMATE;
if (!inCheck && noMove) return STALEMATE;
if (isRepeatedPosition(3)) return DRAW_BY_REPETITION;
return RUNNING;
}
4.4.2 和棋条件(如三次重复局面)的记录与比对
利用前面提到的Zobrist哈希历史记录:
int countRepetition(uint64_t currentHash) {
int cnt = 0;
for (auto& snap : history) {
if (snap.hash == currentHash) cnt++;
}
return cnt;
}
if (countRepetition(currentHash) >= 3) {
gameState = DRAW;
}
4.4.3 游戏状态机设计:运行、暂停、结束状态流转
stateDiagram-v2
[*] --> 初始化
初始化 --> 运行中: 开始游戏
运行中 --> 将军: 检测到攻击将帅
将军 --> 将死: 无可应将走法
将军 --> 运行中: 成功解将
运行中 --> 困毙: 无走法但未被将
运行中 --> 和棋: 三次重复/五十回合无吃
将死 --> 游戏结束: 胜负已分
困毙 --> 游戏结束: 平局
和棋 --> 游戏结束: 平局
该状态机确保整个游戏生命周期可控、可观测、可调试,为后续集成UI和网络对战提供坚实基础。
5. 命令行与图形界面下的用户输入交互机制
5.1 命令行交互模式设计
在C++象棋程序的早期开发阶段,命令行界面(CLI)是验证核心逻辑正确性的理想环境。它不依赖外部GUI库,便于调试和自动化测试。为了实现高效且用户友好的交互,需定义清晰的输入输出协议。
5.1.1 输入格式定义:代数记谱法或坐标输入
我们支持两种主流输入方式:
- 代数记谱法 :如
e2e4表示从 e2 移动到 e4。 - 中文坐标法 :如
炮二平五,但解析复杂,初期建议使用数字坐标。
推荐采用“列行”格式,即输入为两个坐标点:起始 (x1, y1) 和目标 (x2, y2) ,例如:
3 0 -> 3 3
该格式对应棋盘上的红方炮从中线前移三格。
| 输入示例 | 含义说明 |
|---|---|
| 0 0 4 0 | 车从(0,0)横向移动至(4,0),横移无吃子 |
| 1 2 1 3 | 兵未过河,向前一步合法 |
| 7 6 7 5 | 黑卒已过河,可后退?❌非法操作 |
| a b c d | 非法字符输入,应提示错误 |
| 9 10 8 8 | 超出棋盘范围,边界校验失败 |
| 2 1 4 2 | 马走“日”字,需进一步判断是否蹩腿 |
| ”“ | 空输入,重试提示 |
| 3 3 3 3 | 起终点相同,无效移动 |
| 3 4 x | 参数不足或类型错误 |
| 5 5 -1 5 | 负值坐标,属于非法输入 |
5.1.2 用户输入解析与异常容错处理
以下是命令行输入解析的核心函数实现:
#include <iostream>
#include <sstream>
#include <string>
#include <stdexcept>
struct Position {
int x, y;
};
// 解析用户输入字符串,返回起始和目标位置
bool parseInput(const std::string& input, Position& from, Position& to) {
std::stringstream ss(input);
int x1, y1, x2, y2;
ss >> x1 >> y1 >> x2 >> y2;
// 检查流状态:是否所有数据都成功读取
if (ss.fail()) {
std::cerr << "输入格式错误!请输入四个整数:x1 y1 x2 y2\n";
return false;
}
// 边界检查:中国象棋棋盘为9列×10行
auto isValid = [](int x, int y) {
return x >= 0 && x < 9 && y >= 0 && y < 10;
};
if (!isValid(x1, y1)) {
std::cerr << "起始位置 (" << x1 << "," << y1 << ") 超出棋盘范围。\n";
return false;
}
if (!isValid(x2, y2)) {
std::cerr << "目标位置 (" << x2 << "," << y2 << ") 超出棋盘范围。\n";
return false;
}
from = {x1, y1};
to = {x2, y2};
return true;
}
代码解释:
- 使用
std::stringstream安全地将字符串拆分为整数。 - 利用
ss.fail()捕获非数字输入(如字母、符号)。 -
isValid()lambda 函数封装边界判断逻辑,避免重复代码。 - 所有错误信息通过
std::cerr输出,不影响主流程。
执行逻辑流程图(Mermaid):
graph TD
A[用户输入字符串] --> B{是否为空?}
B -- 是 --> C[提示重新输入]
B -- 否 --> D[尝试解析为四个整数]
D --> E{解析成功?}
E -- 否 --> F[输出格式错误, 返回false]
E -- 是 --> G{起始坐标合法?}
G -- 否 --> H[报错并返回false]
G -- 是 --> I{目标坐标合法?}
I -- 否 --> J[报错并返回false]
I -- 是 --> K[填充from/to, 返回true]
此流程确保了从原始输入到有效坐标的完整转换链,并具备良好的容错能力。
5.1.3 输出棋盘状态的文本渲染方案
使用ASCII字符绘制棋盘是一种经典做法。以下是一个简化版的打印函数:
void printBoard(const Board& board) {
std::cout << "\n ";
for (int x = 0; x < 9; ++x) std::cout << x << " ";
std::cout << "\n";
for (int y = 0; y < 10; ++y) {
std::cout << y << " ";
for (int x = 0; x < 9; ++x) {
Piece* p = board.getPiece(x, y);
if (!p) std::cout << ". ";
else {
char c = p->getColor() == RED ?
toupper(p->getSymbol()) :
tolower(p->getSymbol());
std::cout << c << " ";
}
}
std::cout << "\n";
}
}
该函数输出如下格式:
0 1 2 3 4 5 6 7 8
0 R N B A K A B N R
1 . . . . . . . . .
其中 R=车 , N=马 , K=将 等,红色大写,黑色小写。
这种可视化方式虽简陋,但在无图形环境下的调试中极为实用。
简介:【中国象棋游戏C++】是一款基于C++语言开发的桌面策略游戏,完整实现了中国象棋的各项规则与对战功能,并支持棋局的保存与加载。项目涵盖数据结构设计、棋规逻辑判断、用户交互处理、文件读写操作等核心模块,适用于命令行或图形界面环境。通过本项目实践,开发者可深入掌握C++在游戏开发中的应用,提升对算法设计、状态管理与程序调试的综合能力,是学习C++高级编程与项目实战的理想案例。
1万+

被折叠的 条评论
为什么被折叠?



