
简介这是一份揭棋又称翻棋、中国象棋暗棋的开源项目面向棋类游戏爱好者和Python入门到进阶的学习者展示了如何用程序完整模拟这一策略型变体棋种。项目体量精简共2个文件其中Python脚本负责棋盘表示、棋子规则、玩家交互及对局流程并内置基于minmax搜索算法结合Alpha-Beta剪枝的机器人对手Markdown文档则提供运行方式、人机切换与自定义规则说明。整个压缩包仅4KB轻量易读适合作为游戏AI的微型教学范例。当前已有348人学习浏览。深入研读后读者可以理解揭棋翻子、走子及胜负判定的特殊规则掌握用矩阵或二维数组表示棋局的数据结构设计并梳理minmax算法递归评估与剪枝优化的实现细节项目还涉及命令行输入输出与控制台状态渲染可同步锻炼Python编程和策略思考能力。1. 揭棋项目到底在做什么先分清它和象棋的区别第一次拿到 chiness_chess_jieqi-master 这个仓库时我以为又是一份普通中国象棋源码跑起来才发现它的核心不是走子而是“翻子”棋盘上所有棋子开局都背过身去你看不见兵种只能看到一个个扣着的子。每一步你可以在翻子、走已翻开的子之间做选择而“身份隐藏”带来的不确定性和心理博弈才是揭棋真正难写的地方。这份源码把整个规则引擎和 AI 搜索放在一起适合想快速体验揭棋规则的人也适合做棋类 AI、准备把揭棋规则引擎集成到自研平台的同学。接下来我从数据结构、走子判定到避坑经验逐步拆你拿到手后能直接照着跑起来也知道怎么改。2. 从棋盘模型到翻子逻辑核心数据结构的选型先说结论揭棋引擎的第一行代码往往决定后面所有逻辑的复杂度。我拆这个仓库的时候最先看的就是它的棋盘存法和棋子状态定义因为这两处设计会直接影响到翻子、吃子、AI 搜索三块代码好不好写。下面按我自己习惯的顺序来还原。2.1 一维数组与坐标映射不要二维化直接拉平揭棋里最频繁的操作是“棋盘上一个位置翻出什么、能否走到另一个位置”。如果按传统的二维数组board[10][9]来存翻子时要写两层下标走子时要反复对比行列偏移而在一个一维数组里所有位置就是一个 0~89 的整数坐标转换只需一次除法。更关键的是初始随机布子时要打乱顺序一维数组可以直接用一个random.sample搞定不用去生成两组二维坐标。我一般会把棋盘固定成一个大一维数组空位用EMPTY表示不存在的坐标直接由数组下标约束省掉越界判断。下面这段是初始化棋盘的最小骨架SIZE 90 # 标准中国象棋棋盘10行*9列 90个交叉点 EMPTY 0 # 空位 RED_KING 1 # 红方将 BLACK_KING -1 # 黑方帅负值表示黑方 # 两个核心数组 piece_at [EMPTY] * SIZE # 每个位置摆放的兵种ID未翻开时也有效 face_state [0] * SIZE # 0空位1暗子2明子piece_at存的是“这个格点上的兵种是什么”不管它翻开还是没翻开。face_state只管“这个格点当前对玩家是可见还是不可见、是否为空”。你在很多揭棋仓库里看到的所谓“翻子”其实只是改face_state而不是改piece_at这是理解整套逻辑的第一把钥匙。如果你用的规则把棋子放在一个更小的 4*8 棋盘上只需要把SIZE改成32行列映射改成row idx // 8、col idx % 8后面的翻子和走子逻辑完全不用变。坐标转换不是每步都做只有输出棋盘给前端时才会用到def idx_to_rc(idx): return idx // 9, idx % 9在引擎内部全部用一维idx能少掉一半的边界 bug。后面所有走法缓存、局面哈希都用一维索引做 key这是最省事的方案。注意这里故意没有把“棋子放在哪些位置”写死是因为揭棋规则在不同平台有差异有的要求按中国象棋原始棋摆放有的允许随机分布。你只需要把那个位置列表换掉数据结构本身不受影响。2.2 棋盘上的三种状态空、暗、明揭棋最特殊的地方在于一个格子上可以有“暗子”和“明子”两种存在状态暗子不可见但它的身份从游戏开始就已经确定了。如果你用一个变量同时存“可见性”和“身份”翻子时就要改两个字段很容易漏吃暗子时身份消失又容易把数据弄乱。我在很多棋类项目里采用同一套做法身份和可见状态分离。身份由piece_at负责可见状态由face_state负责。这样翻子时不需要动piece_at被吃时也只需把两个数组一起清空。下面是初始化随机布子的常见写法import random RED_PIECES [1, 2,2, 3,3, 4,4, 5,5, 6,6, 7,7,7,7,7] # 红方16个 BLACK_PIECES [-1, -2,-2, -3,-3, -4,-4, -5,-5, -6,-6, -7,-7,-7,-7,-7] # 黑方16个 ALL_PIECES RED_PIECES BLACK_PIECES # 随机位置列表。若按原始布局把这里换成固定位置id即可 positions random.sample(range(SIZE), len(ALL_PIECES)) for i, pos in enumerate(positions): piece_at[pos] ALL_PIECES[i] face_state[pos] 1 # 暗子注意random.sample(range(SIZE), len(...))取出的是不重复的下标每个位置最多一个棋子。如果仓库里有“双方棋子只能放在己方半场”的约束就把positions改成先从红方与黑方各自的位置池里分别抽样再合并否则会出现逻辑上的“隔空放子”。有了这个布局后翻子就是一个状态翻转def flip_at(idx): if face_state[idx] ! 1: # 只有暗子能翻 return False face_state[idx] 2 # 变成明子 return True翻子时piece_at[idx]不变因为身份早就存在。真正重要的是吃子时如果吃到暗子需要先把被吃位置清零再让攻击子落上去否则暗子的身份会在目标位置残留导致后面走法表查到错误数据。2.3 身份与走法解耦把走法表做进规则层拿到一个揭棋源码包最值得看的是它怎么生成“某个棋子能走到哪里”。不同揭棋规则分支有的按“翻出来的兵种”走有的按“所在坐标对应的原始兵种位置”走。这两种走法差别很大所以我会在规则层用常量控制而不是散落在移动函数里。先按常见做法把走法表缓存下来位置idx到可达位置的集合在初始化时预计算一次之后查表。这里不考虑阻挡只返回几何上可达的格子是否阻挡在合法性检查时再算。例如马走日的生成def knight_moves(idx): r, c idx_to_rc(idx) cands [ (r-2, c-1), (r-2, c1), (r-1, c-2), (r-1, c2), (r1, c-2), (r1, c2), (r2, c-1), (r2, c1) ] out [] for rr, cc in cands: if 0 rr 10 and 0 cc 9: out.append(rr * 9 cc) return out这里参数idx是一维索引idx_to_rc把它拆成行和列cands是马走日的八个偏移量然后过滤边界。同样的方式可以生成车、炮、士、相、兵等每种棋子的基础走法。注意车的走法不能只看偏移量还需要判断路径阻挡所以这里只生成“到目标的几何方向”阻挡检查放到合法性函数里。如果仓库采用“按所在位置走法”的规则就把每个位置对应的原始兵种走法做一张静态表POS_MOVES[90]。如果采用“按翻出身份走”的规则则存TYPE_MOVES[兵种ID][idx]。接一个统一的取走法函数def get_targets(idx, piece_id): if MOVE_SOURCE by_position: return POS_MOVES[idx] else: return TYPE_MOVES[piece_id][idx]MOVE_SOURCE是一个规则开关。这样做的好处是无论这个仓库默认采用哪种你都能在不改调用方的前提下切换规则分支。我通常会用单测把这两种走法表分别跑一遍确认边界没有漏掉九宫外的越界点。有了预计算走法表后面判断一个动作是否合法就变成集合查找速度远快于每步临时穷举这对 AI 搜索尤其重要。如果你在源码里看到这份表先直接复用但一定要看它的索引是从 0 还是从 1 开始很多翻车都发生在坐标索引错位。3. 把规则引擎跑起来运行配置与一局对战的完整流程3.1 先看目录结构定位规则引擎入口下载源码包后不要急着打开编辑器先在命令行里过一遍目录通常能快速判断项目的成熟度。我一般这样扫find . -maxdepth 2 -type f | sort然后重点关注main.py、engine.py、rules.py、ai.py这类名字。如果只有一个入口文件就用grep找核心函数grep -rn def flip\|def legal_moves\|def apply_move\|def search .找到flip和move相关函数后先读这两个函数因为它们是整个规则引擎的门面。很多新手会先去读界面代码结果绕了半小时还没看到规则。正确顺序应该是规则引擎 - 动作枚举 - AI - UI。如果目录里有tests/或test_*.py先跑一遍测试python -m pytest -q测试全绿说明仓库自带的规则自检是完整的测试缺失后面你自己补一个随机自对弈来兜底我在第 6 章会讲。跑完测试再动手能省很多排错时间。3.2 走子合法性检查翻子、移动、吃子三条路径揭棋每个回合的动作有三种翻一个暗子、移动一个明子、用明子吃子。合法性检查必须分清楚这三条路径不能把它们写进同一个 if 里。我习惯用一个动作元组表示(action_type, from_idx, to_idx)。action_type为0表示翻子此时只用到from_idx为1表示走子/吃子此时from_idx和to_idx都要填。下面这段生成当前局面所有合法动作def legal_actions(piece_at, face_state): actions [] for idx in range(SIZE): if face_state[idx] 1: # 暗子只能翻 actions.append((0, idx, -1)) elif face_state[idx] 2: # 明子可以走 piece_id piece_at[idx] for to in get_targets(idx, piece_id): # 目标不能是自己目标若是明子且是己方棋子则不能吃 if to idx: continue if face_state[to] 2 and same_side(piece_at[idx], piece_at[to]): continue actions.append((1, idx, to)) return actions这段代码的逻辑说明先遍历所有位置只对暗子生成翻子动作对明子生成所有走子动作再去掉“目标为自己”和“目标为已方明子”的情况。这里没有直接检查路径阻挡因为get_targets给出的是几何可达集合车炮的阻挡要单独验证。参数说明same_side通过piece_id 0判断红黑红方兵种是正数黑方是负数只需比较符号即可。如果你在仓库里看到用abs(piece_id)做身份归一化多半是合理的说明兵种编号不冲突。翻子动作的合法性不用查走法只要确认位置是暗子。这一步很简单但容易漏的是某些规则要求“翻子后立即判胜负”翻出将/帅时这部分要放在apply_move里而不是legal_actions。我一般这样写核心的执行函数def apply_move(piece_at, face_state, action): atype, frm, to action if atype 0: face_state[frm] 2 return piece_at, face_state, get_winner(piece_at, face_state) # 走子/吃子 moving_piece piece_at[frm] if face_state[to] 1: # 吃暗子先把对方暗子翻出并移除 face_state[to] 0 piece_at[to] EMPTY elif face_state[to] 2: # 吃明子 piece_at[to] EMPTY face_state[to] 0 piece_at[to] moving_piece face_state[to] 2 piece_at[frm] EMPTY face_state[frm] 0 return piece_at, face_state, get_winner(piece_at, face_state)这里很关键的一行是face_state[to] 0放在piece_at[to] moving_piece之前顺序反了会把刚落子的状态覆盖掉。吃暗子时可以先不把暗子身份显示出来直接清空因为胜负判定里通常不关心被吃的暗子是谁。get_winner要单独写不能塞进移动函数里否则以后改将帅判负规则时你还要拆一堆逻辑。3.3 启动一局人机对战命令行参数与界面模式很多揭棋仓库都支持--cli和--gui两种模式因为调试时 GUI 反而拖慢节奏。先尝试用命令行模式启动同时把日志打开python main.py --mode cli --depth 3 --logfile game.log--depth 3是 AI 搜索深度--logfile把每一步动作落盘。如果仓库没有这个参数直接看入口代码里有没有argparse没有的话就在main函数里临时加一行打印。启动后你会看到类似这样的交互提示红方回合: 翻 0 | 走 9 18 翻 0表示翻开下标为 0 的暗子走 9 18表示把 9 位置的明子走到 18。下标一律是 0~89 的一维编号不是“炮二平五”那种坐标第一次用很容易输错。主循环的骨架长这样while True: cmd input(红方回合: 翻 {} | 走 {} {} .format(...)) parts cmd.split() if parts[0] 翻: action (0, int(parts[1]), -1) elif parts[0] 走: action (1, int(parts[1]), int(parts[2])) else: continue if action not in legal_actions(piece_at, face_state): print(非法动作重新输入) continue piece_at, face_state, winner apply_move(piece_at, face_state, action) # 切换回合再让AI走一步每一轮先校验输入动作是否在合法动作列表里不在则要求重输在则执行并切换回合。这条主循环看着简单但它是整局游戏的中枢界面、AI 都要挂到它上面。如果命令行模式跑通说明规则引擎本身没问题再去开 GUI 模式。万一 GUI 起不来多半是图形库版本问题而不是引擎问题别把时间耗在装桌面上。4. 避坑揭棋规则引擎最常见的五个翻车点揭棋规则引擎的坑不在语法上而在运行时的规则假设上。下面五条是我确实踩过的每条按现象、原因、解决来写前两条属于规则层后三条属于初始化与 AI 层你对照着能少走不少弯路。4.1 走法来源没定清楚AI 变成玄学棋现象同一个局面AI 有时候走车横扫有时候又走成士步人类玩家看棋谱完全看不懂它在干什么。原因揭棋规则里存在“按身份走法”和“按位置走法”两种流派代码里如果没有显式区分很容易在移动函数里混用。比如走法表用位置生成、吃子判断却按身份查表两套标准一碰就乱。解决进入仓库后第一件事搜索MOVE_SOURCE或get_targets把走法来源确认下来。我习惯在引擎初始化时打一行日志说明当前走法是by_position还是by_type这样每次运行都心里有数。如果你不确定仓库用的是哪种就找一个固定位置做自检在棋盘“象位”上放一个“车”看get_targets返回的是车直线还是象斜线。返回直线就是by_type返回斜线就是by_position。这一步能明确整局棋的走法语义不能靠猜。4.2 翻出将帅后的特殊判负被遗漏现象翻开一个暗子翻出“将”程序只把它当成普通明子继续对局后来被对手一个炮隔着马直接吃掉整局逻辑全乱。原因很多揭棋平台对“翻出将帅”有单独处理可能是立即判负、可能是该子不能移动、可能是必须放到特定位置。你仓库里如果没有这个分支就会把将帅当作普通车马炮一样对待。解决在apply_move的翻子分支里加一段检查if abs(piece_at[frm]) RED_KING: # 或用 BLACK_KING 常量 return handle_king_flip(piece_at, face_state, frm)handle_king_flip按你采用的规则返回胜负或继续。先把这条分支写清楚再去调 AI否则后面所有胜率评估都是错的。我在实际项目里会把handle_king_flip做成一个单独函数在单元测试里分别测“翻出红将”“翻出黑帅”“翻出普通子”三种情况确保这条规则分支被覆盖。4.3 炮的隔子吃统计把暗子当成空位现象炮想吃远处的子中间明明隔着两个暗子程序却提示不能吃隔着三个暗子反而能吃了。原因统计炮的吃子路径时只把face_state 2明子算作障碍face_state 1暗子被当成空位。在暗棋场景里暗子同样是占据交叉点的实体必须算障碍。解决所有路径阻挡判断都基于“这个位置是否有子”而不是“这个位置是否明子”。我统一的写法是def occupied(idx, face_state): return face_state[idx] 1 or face_state[idx] 2 def cannon_can_capture(frm, to, face_state): step 1 if to frm else -1 between 0 for i in range(frm step, to, step): if occupied(i, face_state): between 1 return between 1cannon_can_capture只统计中途被占据的格点数暗子明子都算恰好等于 1 时炮才能吃。这段代码里step是根据起始和目标相对位置确定遍历方向用一维索引时格外小心如果to frm就从小到大遍历否则从大到小。方向写反会统计到棋盘外。4.4 初始化随机布子把自己布成死局现象自对弈运行到中盘发现某方的士、相全部翻在对方半场本方老将周围全是自己的兵走一步就被卡死。原因随机布子如果完全不受区域约束确实会出现极端布局。虽然有些揭棋玩法就是全盘随机但规则要求“将帅必须在九宫”“士相必须在原始宫位附近”的场合你必须限制可放置位置。解决初始化时提供两个位置池一个palace_positions一个non_palace_positions。先用权重确保将帅落在九宫再把其余棋子随机放入剩余位置。示例palace [3,4,5, 12,13,14, 21,22,23, 66,67,68, 75,76,77, 84,85,86] # 先放将/帅再放其他子把将帅放进四个角的其中一个后面演化很容易卡死。我一般会让将帅优先落在九宫中心其次中心四个相邻点降低死局概率。这个约束要在布子阶段就做好不能等翻子后再救。另外被吃掉的位置要及时把face_state置回 0否则布子列表和实际棋盘不一致下一局初始化会串位。4.5 AI 搜索里翻子动作不加代价胜率失真现象AI 在明明可以直接吃子将杀的局面反复去翻旁边的暗子把胜势翻成了败势。原因搜索树里翻子动作和走子动作都是 legal action评估函数默认它们等代价。但实际揭棋里翻子会暴露信息需要给翻子一个负激励AI 才不会无脑翻。解决评估时对每个未翻子做一个小惩罚flip_penalty 0.05 * sum(1 for f in face_state if f 1)或者更激进一点在搜索深度内不允许连续超过两次翻子。这个参数不是固定的我一般先设 0.03~0.1再通过自对弈调。如果你发现 AI 走棋变得畏手畏脚连有把握的翻子都不敢翻就把惩罚降回 0.02如果它还是乱翻就提高到 0.15。这是一种手感调参不用追求精确只要自对弈胜率能稳定在六成以上即可。5. 把揭棋嵌入到自己的客户端核心接口与二次开发路径5.1 规则引擎接口的最小约定如果你不是要在原仓库上玩而是想把这个揭棋引擎接到自己的平台那最关键的不是看懂实现细节而是抽象出一个稳定的接口。我见过太多人直接 import 一个main.py结果界面、AI、规则全部耦合在一起一动就崩。正确做法是只暴露三个纯函数legal_actions(state)、apply(state, action)、is_game_over(state)。state统一定义为一个不可变元组(tuple(piece_at), tuple(face_state))。这么做的原因有三个一是元组可以哈希能直接作为 AI 搜索的 Memo 缓存 key二是防止外部代码无意中修改棋盘三是序列化给前端时非常方便转 JSON。接口约束代码def legal_actions(state): piece_at, face_state state # 返回动作列表 [(type, from, to), ...] def apply(state, action): piece_at, face_state state # 深度拷贝两个数组再执行返回新state而不是原地修改 def is_game_over(state): # 返回 0 未结束1 红胜2 黑胜注意第二个函数必须返回新状态原地修改会让 AI 的搜索树没法回退。如果你发现仓库里的apply_move是原地改数组就包一层copy.deepcopy别嫌慢规则引擎的正确性优先于性能。有了这三个函数前端、终端、机器人、测试脚本都可以共用同一套规则内核。5.2 用 Python 快速包装一个 HTTP 服务网页端或小程序要下棋最简单的方式是起一个本地 HTTP 服务前端每次请求发送当前局面和一个动作后端返回新局面和胜负。这里用 Flask 演示不需要重 Web 框架from flask import Flask, request, jsonify app Flask(__name__) def state_to_dict(piece_at, face_state): return {piece: list(piece_at), face: list(face_state)} app.route(/move, methods[POST]) def move(): req request.get_json() piece_at tuple(req[piece]) face_state tuple(req[face]) action tuple(req[action]) state (piece_at, face_state) if action not in legal_actions(state): return jsonify({error: illegal action}), 400 new_state apply(state, action) piece_at, face_state new_state return jsonify({ state: state_to_dict(piece_at, face_state), legal_actions: legal_actions(new_state), winner: is_game_over(new_state) })这个接口的关键是前端传来的action必须和legal_actions里的元组格式一致否则action not in legal_actions永远为真所有走法都被拒绝。我在实际项目里会把动作(0, 5, -1)序列化成 JSON 数组[0, 5, -1]前后端约定好类型别让字符串混进来。Flask 默认返回 JSON 时中文可能ensure_ascii转义如果你要返回中文提示记得把app.config[JSON_AS_ASCII] False设上。5.3 改造 AI 难度从随机到深度搜索仓库自带的 AI 可能是随机走子也可能是完整搜索。大多数情况下我们只需要把“难度”翻译成搜索深度即可。深度 0 表示纯随机深度 1 表示只看一步深度 3 以上就具备基本棋力。我提供一个替换用的 AI 函数骨架import random def ai_select(state, depth): actions legal_actions(state) if len(actions) 0: return None if depth 0: return random.choice(actions) best_action None best_score -float(inf) for action in actions: new_state apply(state, action) score evaluate(new_state) if score best_score: best_score score best_action action return best_action这里的evaluate是最简单的局面评估统计已翻开的明子价值未翻开的暗子只做惩罚。例如def evaluate(state): piece_at, face_state state score 0 for i in range(SIZE): if face_state[i] ! 2: continue pid piece_at[i] score piece_value(abs(pid)) * (1 if pid 0 else -1) score - 0.05 * sum(1 for f in face_state if f 1) return scorepiece_value可以用最简单的车 5、马 3、炮 3、兵 1、士相 2 的权值。注意这里只统计明子暗子只做惩罚项因为暗子还没翻出来不能确定价值。如果你需要更高棋力就把上面的单步搜索换成带深度的极大极小或蒙特卡洛树搜索但起点一定是这个ai_select骨架。在投入时间前先用第 6 章的自对弈验证规则引擎是完备的否则 AI 越强反而越容易暴露规则 bug。6. 验证与进阶用自对弈快速找出规则引擎的非法走子拿到一个新引擎我最先做的是跑连续自对弈。不是要它赢而是要它“不崩”。一个隐藏规则 bug 会在几百步后把棋盘状态改坏到时你想定位都不知道从哪里开始。下面这个脚本是我固定会跑的第一步import random def selfplay_once(engine, max_steps500): state engine.initial_state() for step in range(max_steps): actions engine.legal_actions(state) assert actions, fstep {step}: no actions but game not over action random.choice(actions) state engine.apply(state, action) winner engine.is_game_over(state) if winner: print(ffinished at step {step}, winner{winner}) return winner raise TimeoutError(fstep {max_steps} exceeded)调用方式把它放到仓库的测试目录里连续跑十盘只要有一盘抛AssertionError说明legal_actions返回了不可执行的动作或者apply把局面改坏。代码逻辑是每步先取合法动作随机挑一个执行如果legal_actions返回空但is_game_over还没结束说明有动作被遗漏如果apply执行后胜负状态永远不变说明将帅判负分支可能漏了。进阶技巧可以在自对弈中途打印棋谱 JSON记录每一步的action和winner。一旦某一步输了就用这个棋谱回放到出错位置附近打断点。我一般会把max_steps设成 300因为一盘随机揭棋通常不会超过 200 步超过 300 步还在走多半是双方反复翻子形成死循环。还有一个附加检查一局结束后统计被吃掉的棋子总数应当是 3032 个棋子减去胜负双方剩下的 2 个将帅。如果数字对不上说明某个吃子路径把棋子凭空弄丢了。从那以后我每次改完规则引擎都强制先跑一遍上面的随机对弈至少连续十盘不出断言错误才敢真正上桌。希望这个技巧能帮到你也让你少走几步我走过的弯路。本文还有配套的精品资源点击获取