尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Cocos Creator 中国象棋开发:从规则建模到 AI 对战
简介一套基于 Cocos Creator 开发的中国象棋单机游戏工程适合游戏开发初学者与对 AI 博弈算法感兴趣的开发者。项目内置简单、普通、困难三档电脑棋力困难模式采用经典 Alpha-Beta 剪枝算法实战能力较强普通玩家在困难难度下已难以取胜。压缩包共 730 个文件、约 17.89MB核心资源包括 184 个 PNG 棋盘棋子素材、70 个 Prefab 预制体、49 个 JS 游戏逻辑与 AI 脚本以及 219 个 JSON 数据配置并附带移动端 css、ttf 字体、fire 动画等辅助文件目录结构清晰便于直接运行、裁剪和二次开发。目前已有 1283 人学习浏览该资源。通过阅读源码可以系统掌握中国象棋的走法生成、局面评估、Alpha-Beta 搜索剪枝等关键模块的工程实现也能学习 Cocos Creator 场景组织、预制体复用、UI 交互与资源打包的常见做法对于想入门棋类游戏开发或研究 Alpha-Beta 算法的开发者来说是一份很实用的参考资料。1. 中国象棋 Cocos Creator 版开发先把项目当成一个规则系统来做做一个中国象棋 Cocos Creator 版开发别一上来就找棋子素材或拖节点。我做过的几个棋类项目里真正的难点从来不是画一张棋盘而是让“数据模型”和“规则判定”严格一致。很多人把 32 颗棋子摆得整整齐齐结果走两步就出现吃己方、马乱跳、将军不判负。这篇笔记按我自己的实现顺序讲先定义棋盘数组和坐标映射再写走法生成与合法性校验接上点击交互和走子动画最后给一个会吃子的 AI 对手。适合用 Cocos Creator 做入门项目或课程设计的人也适合想把棋类逻辑补完整的中级开发者。2. 棋盘与棋子的数据建模用数组把九宫和河界画出来2.1 棋盘坐标系与二维数组的映射关系中国象棋棋盘本质是一个 10 行 9 列的网格。我通常把它建模成board[10][9]的二维数组行索引row对应棋盘最上方向下到最下方列索引col对应左到右。红方在下初始布局在 row 6 到 row 9黑方在上初始布局在 row 0 到 row 3。数组中存的是棋子数值而不是节点对象。这样走法生成、将军检测、AI 模拟都只操作数据UI 节点只负责表现。为了方便红黑双方共用同一套规则我习惯用正数表示红方棋子负数表示黑方棋子用绝对值表示兵种1 车、2 马、3 相/象、4 士/仕、5 帅/将、6 炮、7 兵/卒0 表示空位。这样在规则代码里只需要一句Math.sign(p)就能判断阵营Math.abs(p)得到兵种后面做合法性校验和 AI 评分都会很顺手。export const EMPTY 0; export const PIECE_JU 1; export const PIECE_MA 2; export const PIECE_XIANG 3; export const PIECE_SHI 4; export const PIECE_JIANG 5; export const PIECE_PAO 6; export const PIECE_BING 7; export const RED 1; export const BLACK -1; const INIT_BOARD: number[][] [ [ -PIECE_JU, -PIECE_MA, -PIECE_XIANG, -PIECE_SHI, -PIECE_JIANG, -PIECE_SHI, -PIECE_XIANG, -PIECE_MA, -PIECE_JU ], [ EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY ], [ EMPTY, -PIECE_PAO, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, -PIECE_PAO, EMPTY ], [ -PIECE_BING, EMPTY, -PIECE_BING, EMPTY, -PIECE_BING, EMPTY, -PIECE_BING, EMPTY, -PIECE_BING ], [ EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY ], [ EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY ], [ PIECE_BING, EMPTY, PIECE_BING, EMPTY, PIECE_BING, EMPTY, PIECE_BING, EMPTY, PIECE_BING ], [ EMPTY, PIECE_PAO, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, PIECE_PAO, EMPTY ], [ EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY ], [ PIECE_JU, PIECE_MA, PIECE_XIANG, PIECE_SHI, PIECE_JIANG, PIECE_SHI, PIECE_XIANG, PIECE_MA, PIECE_JU ], ];逻辑说明这个数组就是双方初始布局的标准位置。-PIECE_*表示黑方正数表示红方。之所以把阵营符号放在数值里是因为后续生成走法时同一个兵种函数不需要关心自己是哪一方只要在过滤目标点时判断目标值的符号即可。参数说明如果你想用对象数组存储棋子信息也可以但数字数组在模拟走子、克隆快照、AI 搜索时内存开销最小这也是棋类程序的老做法。棋盘数组只负责“逻辑坐标”。Cocos 节点坐标和逻辑坐标的映射关系是设棋盘节点左上角第一个交叉点在节点局部坐标里的位置是ORIGIN_X, ORIGIN_Y格子宽CELL_W、格子高CELL_H那么第row行第col列的交叉点坐标是x ORIGIN_X col * CELL_Wy ORIGIN_Y - row * CELL_H。注意这里一定是减法因为数组第 0 行在视野最上面而 Cocos 的 y 轴向上。很多新手在这一步把符号写反出来的棋盘就是上下颠倒的。2.2 用 Cocos Creator 节点树创建棋盘底图棋盘底图常见做法是放一张美术图。开发初期没有素材时可以在一个空节点上加Graphics组件手动画线这样格子尺寸可以随时调后续替换图片也不用改棋子坐标逻辑。以 Cocos Creator 3.x 为例核心画线代码可以写成import { Graphics, Node } from cc; function drawBoard(boardNode: Node) { const g boardNode.getComponent(Graphics) || boardNode.addComponent(Graphics); const originX -4 * CELL_W; // 让棋盘横向居中 const originY 4.5 * CELL_H; // 棋盘顶部 g.clear(); g.lineWidth 2; g.strokeColor { r: 120, g: 70, b: 30, a: 255 }; // 横向边线从 row 0 到 row 9 for (let row 0; row 9; row) { const y originY - row * CELL_H; g.moveTo(originX, y); g.lineTo(originX 8 * CELL_W, y); } // 竖向线左右边界贯穿中间竖线在河界处分段 for (let col 0; col 8; col) { const x originX col * CELL_W; if (col 0 || col 8) { g.moveTo(x, originY); g.lineTo(x, originY - 9 * CELL_H); } else { g.moveTo(x, originY); g.lineTo(x, originY - 4 * CELL_H); g.moveTo(x, originY - 5 * CELL_H); g.lineTo(x, originY - 9 * CELL_H); } } g.stroke(); }逻辑说明这里把棋盘第一个交叉点放在逻辑左上角也就是 Cocos 坐标里的(originX, originY)。画横线从 row 0 到 row 9画竖线时在河界处断开符合中国象棋棋盘“中间有河界、竖线不连通”的视觉样式。如果你用美术图这段代码就不需要但依然要用同一套ORIGIN_X / ORIGIN_Y / CELL_W / CELL_H来摆棋子否则棋子压不到交叉点上。参数说明originX -4 * CELL_W是为了让棋盘中心落在节点原点实际项目里应根据 Canvas 的设计分辨率重新计算不要把这些数散落在多个文件里。我一般会在Config.ts里统一导出ORIGIN_X / ORIGIN_Y / CELL_W / CELL_H所有坐标换算都从那里取。九宫斜线、炮位、兵卒位等小标记可以继续用同一套Graphics画也可以单独放一个节点。我的做法是棋盘底图只画边框和格子线九宫和定位点交给另一个节点绘制避免后续叠加“可走点高亮”时图形层级不好控制。2.3 把棋子挂到正确的位置锚点与坐标换算棋盘底图画完开始生成棋子。常见做法是做一个ChessPiece.ts组件挂在一个 Prefab 上Prefab 内放一个 Label 子节点显示汉字。创建时实例化 Prefab设置父节点为棋盘节点再调用一个统一的位置换算函数定位。import { Vec3, instantiate, Node } from cc; export function getWorldPos(row: number, col: number): Vec3 { const x ORIGIN_X col * CELL_W; const y ORIGIN_Y - row * CELL_H; return new Vec3(x, y, 0); } function createPiece(row: number, col: number, value: number) { const node instantiate(piecePrefab); node.setParent(boardNode); node.setPosition(getWorldPos(row, col)); node.getComponent(ChessPiece).init(row, col, value); pieceNodes[row][col] node; }逻辑说明pieceNodes是一个二维数组和棋盘数据一一对应。如果不维护它每次点击都要用坐标反算 row/col很容易算错。我在ChessPiece组件里保存了row和col每次走棋后同步更新这两个值事件回调里直接读取不需要解析节点名或位置。参数说明init(row, col, value)内部要根据Math.sign(value)设置棋子文字颜色红方偏红、黑方偏黑汉字映射根据绝对值走车、马、相、士、帅、炮、兵一一对应。这里注意红方是“帅”黑方是“将”但规则和数值可以完全共用。一个容易被忽略的点Cocos 节点的锚点决定了setPosition的基准。默认UITransform锚点是(0.5, 0.5)节点中心正好落在交叉点上如果你把锚点改成了(0, 0)棋子就会整体偏移到交叉点的右上角或右下角。所以生成棋子时不要随便改锚点或者统一按锚点公式换算。另一个习惯是在init里给每个棋子节点存一份value之后 AI 评分和将军检测需要知道“这颗棋子是谁”从pieceNodes里取节点就能拿到。提示如果发现棋子对不齐棋盘线先检查getWorldPos里的 row/col 是不是按“先列后行”传入。很多低级错误都来自参数顺序写反。3. 走法规则与合法性校验让每个棋子按规矩动3.1 走法表车马炮兵相仕帅各自的移动规则怎么落代码中国象棋的走法看似多其实可以分成两类一类是车、炮这样沿直线扫描的另一类是马、相、士、帅这样按固定偏移表跳跃的。我通常把每个兵种的“目标点生成”写成独立函数最后统一汇总。这样后续做将军检测、AI 搜索都能复用同一套generateAllMoves。车和炮的直线扫描代码type Pos { row: number; col: number }; const DIRS [ { row: -1, col: 0 }, { row: 1, col: 0 }, { row: 0, col: -1 }, { row: 0, col: 1 }, ]; function getRookTargets(row: number, col: number, board: number[][]): Pos[] { const targets: Pos[] []; for (const dir of DIRS) { let r row dir.row; let c col dir.col; while (inBoard(r, c)) { const p board[r][c]; if (p EMPTY) { targets.push({ row: r, col: c }); } else { targets.push({ row: r, col: c }); // 吃子 break; } r dir.row; c dir.col; } } return targets; } function getCannonTargets(row: number, col: number, board: number[][]): Pos[] { const targets: Pos[] []; for (const dir of DIRS) { let r row dir.row; let c col dir.col; let screen false; // 是否已经隔了一个棋子 while (inBoard(r, c)) { const p board[r][c]; if (!screen) { if (p EMPTY) { targets.push({ row: r, col: c }); } else { screen true; } } else { if (p ! EMPTY) { targets.push({ row: r, col: c }); break; } } r dir.row; c dir.col; } } return targets; }逻辑说明车扫到第一个棋子时把该位置加入目标然后break因为车不能越过棋子。炮的代码用一个布尔值screen记录是否已经跨过棋子第一阶段没有炮架时只能走空格遇到第一个棋子后把screen置为 true第二阶段遇到下一个棋子时就是炮打子的目标遇到空格继续扫。用“两段式”比统计中间棋子数量更直观也不容易把己方棋子当炮架数进去。马、相、士、帅的移动范围有限适合用查表法。马的 8 个目标点和 8 个蹩腿点直接写在一张表里const HORSE_MOVES [ { target: [-2, -1], leg: [-1, 0] }, { target: [-2, 1], leg: [-1, 0] }, { target: [2, -1], leg: [1, 0] }, { target: [2, 1], leg: [1, 0] }, { target: [-1, -2], leg: [0, -1] }, { target: [-1, 2], leg: [0, 1] }, { target: [1, -2], leg: [0, -1] }, { target: [1, 2], leg: [0, 1] }, ]; function getHorseTargets(row: number, col: number, board: number[][]): Pos[] { const targets: Pos[] []; for (const m of HORSE_MOVES) { const r row m.target[0]; const c col m.target[1]; const legR row m.leg[0]; const legC col m.leg[1]; if (!inBoard(r, c)) continue; if (board[legR][legC] ! EMPTY) continue; // 蹩马腿 targets.push({ row: r, col: c }); } return targets; }逻辑说明蹩马腿的位置是“马当前所在点沿目标方向直走一步”的位置不是目标点旁边的点。比如目标点是(row-2, col-1)腿点是(row-1, col)也就是target的分量减半。HORSE_MOVES 表把 target 和 leg 成对写代码就不会多点一处、少点一处。相象的塞象眼逻辑类似相走田字的斜两步目标点是(±2, ±2)象眼是(±1, ±1)相不能过河所以要对 row 做边界过滤。士和帅都限制在九宫内帅还被将帅照面约束。这些过滤我统一放在合法性校验里处理。兵种走法特殊限制车直线任意距离不能越子马日字蹩马腿相/象田字两步塞象眼不能过河士/仕九宫斜一步不能出九宫帅/将九宫直一步不能出九宫不能照面炮直线走空格隔子吃吃子时必须隔一个炮架兵/卒未过河只能向前过河可横红方向上黑方向下3.2 落子校验不能过河、不能吃自己、不能送将目标点生成后不能直接用。至少需要过滤三类不合法走法目标点被己方棋子占据、相/士/帅超出九宫、移动后己方将帅被攻击。第一类在汇总时过滤第二类在对应兵种生成函数里判断第三类是全局检查也是象棋程序的灵魂。把“某位置是否被某阵营攻击”做成公共函数function isAttacked(row: number, col: number, board: number[][], side: number): boolean { for (let r 0; r 10; r) { for (let c 0; c 9; c) { const p board[r][c]; if (p EMPTY || Math.sign(p) ! side) continue; const targets getTargetsByPiece(r, c, board); if (targets.some(t t.row row t.col col)) return true; } } return false; }逻辑说明side表示“攻击方”阵营遍历攻击方每个棋子检查其移动目标里有没有(row, col)。这里直接复用规则生成函数车、炮、马、兵的攻击范围都能自动覆盖不需要单独维护一套“攻击范围表”。注意getTargetsByPiece内部只做兵种自己的目标生成不做送将检查否则会递归死循环。将帅照面这一隐含规则也靠这个函数兜住但要记得在兵种规则里让将/帅沿竖线扫描。有了攻击检测合法走法判断就变成模拟后找将function isLegalMove(board: number[][], from: Pos, to: Pos, side: number): boolean { if (!inBoard(to.row, to.col)) return false; const targetPiece board[to.row][to.col]; if (targetPiece ! EMPTY Math.sign(targetPiece) side) return false; const movingPiece board[from.row][from.col]; // 模拟走子 board[from.row][from.col] EMPTY; board[to.row][to.col] movingPiece; const kingPos findKing(board, side); let safe true; if (kingPos) { safe !isAttacked(kingPos.row, kingPos.col, board, oppositeSide(side)); } // 还原棋盘状态 board[from.row][from.col] movingPiece; board[to.row][to.col] targetPiece; return safe; }逻辑说明先做吃自己的过滤再做模拟。模拟后找己方将/帅位置看它是否被对方攻击。findKing要在模拟后的棋盘上找因为如果你移动的恰好是将/帅老位置已经空了。还原时要把被吃的子放回去否则后续判断会少子。这个函数绝对不能改变真实棋盘状态所以一定要有还原步骤。oppositeSide(side)就是-side因为红黑分别用 1 和 -1。把目标点生成和合法性校验汇总就是求当前阵营所有合法走法function generateAllMoves(board: number[][], side: number): { from: Pos; to: Pos }[] { const moves: { from: Pos; to: Pos }[] []; for (let r 0; r 10; r) { for (let c 0; c 9; c) { const p board[r][c]; if (p EMPTY || Math.sign(p) ! side) continue; const targets getTargetsByPiece(r, c, board); for (const t of targets) { if (isLegalMove(board, { row: r, col: c }, t, side)) { moves.push({ from: { row: r, col: c }, to: t }); } } } } return moves; }这个函数后面会被 AI 和胜负判断大量调用。它每次都重新计算所有走法效率不高但中国象棋单机版的搜索量不大完全够用。等你要加深搜索深度时再把它改成增量生成或加剪枝。3.3 悔棋与吃子逻辑用栈记录每一步走子前必须保存上一步状态否则悔棋就是空谈。常见做法是维护一个历史栈每个元素记录起点、终点和被吃子interface MoveRecord { from: Pos; to: Pos; captured: number; // 被吃棋子0 表示没有吃子 } const history: MoveRecord[] []; function applyMove(from: Pos, to: Pos) { const sourcePiece board[from.row][from.col]; const captured board[to.row][to.col]; history.push({ from: { ...from }, to: { ...to }, captured }); board[from.row][from.col] EMPTY; board[to.row][to.col] sourcePiece; }逻辑说明先把被吃子存进历史再移动。悔棋时倒着弹栈恢复 from、to 和被吃子并同步 UI 节点。注意captured保存的是被吃棋子的完整数值包含正负号这样恢复时不用额外判断阵营。如果你把红黑分别存成两个数组悔棋时就要再判断一次颜色容易出错。悔棋函数function undoMove() { const rec history.pop(); if (!rec) return; board[rec.from.row][rec.from.col] board[rec.to.row][rec.to.col]; board[rec.to.row][rec.to.col] rec.captured; // UI把 from 的节点移回若 captured 不为空则重新创建被吃节点 }逻辑说明逻辑数组的恢复很简单。UI 层如果你之前销毁了被吃子节点悔棋就要重新实例化一个相同棋子放回原位如果你只是隐藏了节点悔棋时调用setPosition并active true更省资源。我习惯隐藏而不是销毁因为被吃后悔棋的概率不高隐藏能省掉节点创建的代码。这里容易踩的坑是悔棋后currentSide也要回退。因为悔棋相当于把时间倒回上一步如果当前轮到你走悔棋后应该还是你走。所以undoMove里要同时把currentSide反转回去并重置busy锁。很多象棋项目在这里没处理导致悔棋后电脑立刻又走一步体验很怪。4. 游戏主流程与交互从点棋子到走棋的完整闭环4.1 触摸事件绑定选中棋子、高亮可走点交互层我遵循“数据先算UI 后表现”。在ChessPiece组件初始化时给节点注册触摸事件node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this); private onTouchEnd() { if (busy) return; const row this.row; const col this.col; const value board[row][col]; if (Math.sign(value) ! currentSide) return; selectPiece(row, col); }逻辑说明busy是全局互斥锁防止动画过程中响应其他点击。Math.sign(value)与currentSide比较保证只能选己方选不了空位和对方棋子。selectPiece会记录当前选中的棋子计算它的合法目标并在每个目标点生成一个高亮节点。高亮节点可以是一个半透明绿色 Sprite也可以是小圆圈标记我用的是 60x60 的半透明Sprite透明度 120玩家能看清可走位置。高亮节点的坐标必须复用getWorldPos不要再用另一套公式。如果棋盘底图本身是一个很大的节点它可能会挡住子节点的事件所以底图节点不要添加BlockInputEvents组件或者把底图放在棋子节点的上一层实际项目中我通常把底图放在棋盘节点的最下层棋子在最上层高亮在中间。Cocos 的触摸派发会按层级顺序走底图默认不会吃掉子节点事件。点击目标点的高亮节点执行走棋因为高亮节点本身就是合法目标回调解法直接取出to.row和to.col不需要判断点击坐标落在哪个格子。这个做法比在棋盘空白处点击、再反算行列要省很多边界判断尤其是棋盘边缘点击时误差很容易把点映射到格子外。4.2 走棋动画与状态切换Tween 移动、吃子移除走棋动画不是必须的但加上后体验会好很多。Cocos Creator 3.x 的tween写移动动画很简洁。核心流程是先更新逻辑数据再播放节点动画function doMove(from: Pos, to: Pos) { busy true; const movingPiece board[from.row][from.col]; const movingNode pieceNodes[from.row][from.col]; // 先取被吃子节点避免下面覆盖 const capturedNode board[to.row][to.col] ? pieceNodes[to.row][to.col] : null; // 先更新逻辑数组和节点映射 board[from.row][from.col] EMPTY; board[to.row][to.col] movingPiece; pieceNodes[from.row][from.col] null; pieceNodes[to.row][to.col] movingNode; movingNode.getComponent(ChessPiece).updateRowCol(to.row, to.col); tween(movingNode) .to(0.12, { position: getWorldPos(to.row, to.col) }) .call(() { if (capturedNode) capturedNode.active false; busy false; afterMove(); }) .start(); }逻辑说明很多人喜欢先播放动画动画结束再改数组但这样动画期间 AI 判断和历史栈会拿到旧状态容易错乱。我坚持先更新逻辑数组再让节点追过去。capturedNode必须在覆盖pieceNodes[to.row][to.col]之前取到否则吃子节点就丢了。busy锁在动画完成回调里释放因为释放早了玩家会在动画过程中点其他棋子。这里有个细节movingNode.getComponent(ChessPiece).updateRowCol(...)要在tween开始前调用这样如果动画中途有事件回调读到的坐标已经是最新的。参数说明动画时长 0.12 秒是手感比较合适的值太快像瞬移太慢影响连点。如果你要表现“吃大子”的停顿可以改成 0.2 秒并在回调里加一个延迟但不要超过 0.3 秒否则高频走棋会显得拖沓。隐藏被吃子节点用active false而不是destroy()是为了方便悔棋恢复。如果要释放内存可以在悔棋栈超过一定长度后把最早的节点真正销毁。4.3 胜负与将军检测循环遍历把将死判断做出来每走完一步切换玩家function afterMove() { currentSide currentSide RED ? BLACK : RED; if (currentSide AI_SIDE) { aiMove(); } else { checkGameEnd(); } }checkGameEnd里先判断当前走棋方是否还有合法走法function checkGameEnd(): boolean { if (generateAllMoves(board, currentSide).length 0) return false; // 当前玩家无合法走法判负 const winner oppositeSide(currentSide); endGame(winner); return true; }逻辑说明这一行代码同时覆盖将死和困毙。在中国象棋里轮到走棋的一方没有任何合法走法无论是否正被将军都判负。很多项目只检查“被将军时无解”结果出现“没被将军但无子可动”时游戏不结束这是错的。AI 那边也走同样的判断所以电脑被将死后同样触发结束。有些项目还想显示“将军”提示那在afterMove里额外调用if (isGeneralAfterMove(board, currentSide)) { toast(将军); }实现方式是从当前玩家方所有棋子中找有没有能直接吃到对方将的走法。常见做法是调用generateAllMoves(board, sideToMove)再检查其中是否有to等于对方将的位置。因为它会遍历所有走法性能开销有点大但每回合只调用一次完全不必担心。到这里游戏循环已经完整点击选中、目标高亮、走子动画、胜负判断、AI 回合。剩下的各种边角问题我集中放到下一章。5. 中国象棋 Cocos Creator 开发避坑五个必查问题5.1 棋盘上下颠倒原点方向算反现象红黑双方棋子整体上下换了位置红方跑到了棋盘上方。原因Cocos 2D 坐标的 y 轴向上逻辑数组第 0 行在棋盘上方。如果坐标换算写成y originY row * cellHrow 越大 y 越大棋子就会从上往下反向排列。解决统一用y originY - row * cellH并把换算封装进getWorldPos(row, col)。所有棋子初始化、高亮、动画目标点一律走这个函数不要在某处手写第二个换算公式。如果棋盘底图是图片也要保证图片左上角对应的节点坐标是(originX, originY)。出现上下颠倒时我一般先检查Config.ts里ORIGIN_Y是不是负值再看getWorldPos是不是用了加号。5.2 动画期间状态错乱busy 锁不及时释放现象棋子还没到目标位置玩家快速点击另一个棋子有时棋子会被瞬移有时被吃的子又消失后出现。原因走棋是异步动画数组里的数据已经改了节点还停在旧位置。此时再点这个节点事件里读到的row/col是旧值会把已经移动走的棋子再当一次起点导致逻辑数组和节点对不上。解决在整个动画期间用busy标志关闭所有交互包括选中、点击高亮和 AI 启动。在tween的.call()里统一释放锁不要在动画中间提前解锁。同时棋子组件里的row/col要在动画开始前更新不要等回调里再改。调试时可以在busy true的地方打日志看看是否有第二个走子请求进来。5.3 蹩马腿位置算错目标方向与蹩腿点不匹配现象马可以斜着跳过一颗紧邻自己的棋子。原因马的蹩腿点在“起点沿目标方向直走一步”的位置不是目标点的对角位置。例如目标(row-2, col-1)腿点是(row-1, col)。如果把腿点写成(row-1, col-1)就会在四个方向上都漏判。解决把 8 组{target, leg}写成常量表见 3.1 节。写完用一组用例验证红马在(8,1)黑方在(7,0)有一颗棋子此时马跳(6,2)的腿点是(7,1)没被蹩跳(6,0)的腿点是(7,1)也没被蹩实际上要仔细算。我一般会给四个方向各写一个用例把 HORSE_MOVES 表逐行跑一遍输出每个目标点和腿点肉眼检查是否和棋盘一致。5.4 炮架计数错误炮隔着两个子也能吃现象炮前方同一条线上有三个棋子它照样吃掉最远的那个。原因炮的移动分成两段第一段没有炮架时只能走空格第二段经过一个炮架后遇到敌人才能吃。如果把统计炮架的循环写错比如把己方棋子也当炮架或者提前break就会允许隔多子吃。解决用状态机式写法screen false表示还没跨过炮架。从炮位置往外扫遇到第一个棋子时只置screen true不加入目标遇到第二个棋子时判断敌我决定是否可吃然后break。写完后用“炮、马、车、卒”一条线来验证炮吃卒合法炮吃车不合法炮隔两个棋子不能吃卒。5.5 将帅照面中空对脸没被判负现象双方将/帅落到同一列中间没有棋子这步还被认为是合法走法。原因将/帅除了上下左右移动之外还有一条隐含规则不能与对方将/帅在无遮挡的同一条竖线上直接相对。很多实现只检查了普通目标点攻击忘了把对方将/帅算作攻击源。解决在isAttacked的实现里除了常规兵种的攻击目标还要加入“将帅沿竖线互相看”的扫描。也就是当攻击方棋子是将/帅时它沿上下方向能看到的位置只要中间没有棋子都算攻击范围。这样送将检查会自动把照面非法走法拦下来。这个漏点比较隐蔽我也是被测试人员反馈后才补上。你可以在isAttacked里对Math.abs(p) PIECE_JIANG的棋子单独加一段竖线扫描或者把将/帅的兵种目标生成写成直线扫描效果一样。6. 给 AI 加点选择策略让电脑走一步好棋6.1 评分走法先让电脑会吃子做中国象棋 Cocos Creator 版如果只想一个人调试会发现 AI 怎么都不走。最常见的起步不是做博弈树而是给所有合法走法打分选最高分那步。评分只考虑两个信号吃子价值和将军提示。权重表放在常量里const PIECE_SCORE: Recordnumber, number { [PIECE_JIANG]: 0, [PIECE_JU]: 9, [PIECE_PAO]: 4.5, [PIECE_MA]: 4.5, [PIECE_XIANG]: 2, [PIECE_SHI]: 2, [PIECE_BING]: 1, };因为将不能吃给 0 分。AI 函数function generateAIMove(side: number) { const moves generateAllMoves(board, side); if (moves.length 0) return null; let bestScore -Infinity; let bestMoves: { from: Pos; to: Pos }[] []; for (const m of moves) { const captured board[m.to.row][m.to.col]; let score 0; if (captured ! EMPTY) { score PIECE_SCORE[Math.abs(captured)]; } // 如果走完后将军对手额外加 2 分 if (moveCausesCheck(m, side)) score 2; // 随机因子让每局有变化 score Math.random() * 0.3; if (score bestScore) { bestScore score; bestMoves [m]; } else if (score bestScore) { bestMoves.push(m); } } return bestMoves[Math.floor(Math.random() * bestMoves.length)]; }逻辑说明moveCausesCheck内部做一次走子模拟然后判断对手将/帅是否被攻击。这个“贪心吃子 将军优先”的走法已经能欺负完全随机乱走的棋子。随机因子 0.3 让相同局面不会每次都走一样的子避免调试时索然无味。如果发现 AI 总是送车就把车的价值从 9 调到 10或者把“被吃子价值”改成“目标点威胁”这很容易。6.2 从贪心到一层的搜索参数怎么定如果你想在单机版上再进一步可以把贪心扩展成一层极小极大当前玩家走完对手也会选择一步取两步后的局面分差。但中国象棋平均分支数高深度超过 2 在手机端会卡。我自己的经验是评分函数比搜索深度更容易出效果。先把“会吃”和“会逃”调对再谈算得深。搜索层的评估函数可以继续用PIECE_SCORE加上兵种位置表。位置表可以是个二维数组马在中路加分、边路减分炮在自己阵地的开局阶段加一点分。这些参数不需要一开始就完美先跑几局看 AI 被哪种走法打穿再针对性加减。每次我把中国象棋 Cocos Creator 版开发做到这个程度都会先把历史栈、坐标换算、走法生成这三样当成固定模板其他东西丢了都能快速重建。做 AI 前先保证随机走子不报错再一步步加评分这是最稳妥的路。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

数据中台设计方法论:从边界界定到架构分层与落地实践

数据中台设计方法论:从边界界定到架构分层与落地实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/10/14 1:19:55
嵌入式Flash时钟:掉电不丢的时间锚点设计与实战

嵌入式Flash时钟:掉电不丢的时间锚点设计与实战

简介:本资源是一份基于Adobe Flash平台开发的桌面时钟实现方案,面向ActionScript初学者与Flash动画爱好者,解决动态时钟组件开发与桌面集成的实际问题。压缩包共3个文件(562KB),含核心源码文件时钟的制作.f…

📅 2026/10/14 1:19:55
免安装Origin 8.0实战:绿色版数据处理与绘图避坑指南

免安装Origin 8.0实战:绿色版数据处理与绘图避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/10/14 1:19:55
MORE NEWS

更多资讯

📰

jQuery DateTimePicker实战:日期时间选择与表单校验全指南

简介:jQuery日期与时间选择器插件DateTimePicker,面向需要为网页快速集成日期输入组件的Web前端开发者,提供了一套轻量易用的日期时间交互解决方案。插件内置多语言、自定义显示格式、日周月年四种视图、时分秒选择、禁用指定日期、事件回调、…

📰

UML复习重点:类图关系判定与动态图采分点全梳理

简介:UML系统分析与设计复习试题资料包,面向正在学习统一建模语言、备战期末考试或相关资格认证的高校学生及软件开发初学者。文档以问答形式整理近20个高频考点,覆盖关联多重度判断、类与类之间组合与聚合关系、客户与订单数量对应、顺序图与…

📰

ThinkPHP区块链商城源码解析:分层设计与链上存证实践

简介:这份Thinkphp区块链商城源码面向PHP后端开发者与区块链应用学习者,提供一套可直接部署运行的商城系统完整实现,用于研究区块链积分、订单与商品模块的代码组织方式。资源包含程序源码与配套数据库,环境基于Apache 2.4.41、My…

📰

GEO团体标准10月10日正式实施:企业主怎么用5项新规避开“保排名“忽悠

GEO团体标准10月10日正式实施:企业主怎么用5项新规避开"保排名"忽悠 10月10日,《生成式引擎优化(GEO)》系列团体标准正式实施。这是中国商务广告协会牵头、覆盖通用术语、服务商评估、计价测量、可信语料、流程合规五个…

📰

车载以太网TCP/IP协议栈实战:DoIP诊断、SOME/IP与性能调优

直接聊正事。我最近一两年大部分时间都在跟车载以太网和SOA架构打交道,TCP/IP协议栈在车上的戏份越来越重,这已经不是你把它当“黑盒子”接上就能跑的阶段了。从CAN总线往以太网迁移的过程里,我在某车型项目上被一堆“看起来正常、跑起来掉链…

📰

基于五要素模型的派出所综合信息管理系统架构设计与落地实践

简介:这份资源是面向公安信息化建设人员、派出所基层民警及系统集成从业者的派出所综合信息管理系统设计方案文档,围绕“金盾工程”一期一类应用系统的重点建设方向,针对当前公安局应用现状与存在问题,给出系统建设的总体目标、思…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬