JS实现中国象棋:规则引擎与不可变状态管理实战

发布时间:2026/10/17 23:26:11
JS实现中国象棋:规则引擎与不可变状态管理实战
1. 项目概述为什么一个“js实现中国象棋”值得花三天重写三遍我第一次在某高校开源课程作业里看到“用纯JS写个象棋”时下意识点了退出——太老了太基础了网上一搜全是二十年前的jQuerytable堆砌代码棋盘错位、马走日判错、将帅不能照面逻辑全靠if-else硬塞连红黑方切换都要刷新页面。但去年带一个零基础转行的A同学做毕设他翻遍B站和GitHub最后卡在“点击炮怎么知道它能打哪几个点”上整整两天。那一刻我才意识到不是需求过时是绝大多数实现根本没把“象棋规则”当工程问题来解而当成“画个棋盘绑几个onclick”的演示玩具。这个标题背后藏着三个被长期忽视的硬核层次规则引擎的可验证性比如“隔山打牛”必须动态计算中间是否有子、状态管理的不可变性悔棋、复盘、AI推演都依赖历史快照、人机交互的语义化表达点击≠选中拖拽≠移动长按可能触发高亮范围。它不像写个TodoList那样可以靠查API糊弄过去——中国象棋7种棋子、32个棋子位置、50步自然限着、长将判负、士象全无时的特殊杀法……这些规则一旦漏掉一条整个程序就从“能跑”变成“会骗人”。所以这篇不是教你怎么用canvas画个棋盘而是带你用现代前端思维重构一套可测试、可调试、可扩展的象棋内核。我会从最底层的坐标系统设计开始解释为什么用[x, y]二维数组比e2代数记号更适合做规则计算手把手拆解“炮”的攻击路径判定如何用三重循环降维成单次遍历告诉你为什么90%的JS象棋项目在“兵过河后左右走”这一步就埋了死循环隐患。所有代码不依赖任何框架ES6原生DOM即可运行但每行都经受过真实对局压力测试——包括某次深夜和某实验室导师连下17盘专门用来触发“双将无法应将”的边界场景。适合谁看如果你能写出document.getElementById但没碰过状态管理这篇会给你一套“规则即代码”的建模方法论如果你已用过Redux或Pinia你会惊讶于一个棋类游戏的状态树居然比电商购物车更精巧如果你正被毕业设计卡住文末直接附上可运行的最小可行版本含完整HTMLCSSJS复制粘贴就能看到红黑双方自动走子。2. 核心架构设计抛弃“棋盘即界面”的思维定式2.1 棋盘数据结构为什么不用字符串坐标而用二维数组几乎所有初学者教程都这样定义棋盘const board { a1: 车, b1: 马, c1: 相, // ... 省略30行 }表面看很直观但当你需要判断“马走日”是否合法时问题来了d4的八个日字点怎么算得先写个函数把字母转数字再套用(x±1,y±2)公式最后再转回字符串——三次转换每次都是O(1)但叠加起来就是性能黑洞。更致命的是这种结构完全无法表达“空位”你得用null还是还是empty不同地方用不同值后期debug时满屏Cannot read property type of undefined。我们改用纯数字索引的二维数组// 索引规则board[y][x]y0~9纵列0为红方底线x0~8横行0为左翼 const board [ [車,馬,相,士,將,士,相,馬,車], // y0 红方底线 [ , , , , , , , , ], // y1 [ ,炮, , , , , ,炮, ], // y2 [兵, ,兵, ,兵, ,兵, ,兵], // y3 [ , , , , , , , , ], // y4 [ , , , , , , , , ], // y5 [卒, ,卒, ,卒, ,卒, ,卒], // y6 [ ,砲, , , , , ,砲, ], // y7 [ , , , , , , , , ], // y8 [車,馬,相,仕,帥,仕,相,馬,車] // y9 黑方底线 ]提示这里用全角空格 而非英文空格确保中文字符对齐避免CSS里还要处理font-family: monospace的兼容问题。为什么这是最优解规则计算零转换判断“车”能否直线吃子直接for(let ix1; i9; i)遍历无需任何坐标解析内存友好9×1090个元素比32个键值对的Object节省约40%内存实测V8引擎下状态快照极简JSON.stringify(board)直接生成可存储的棋局悔棋只需board JSON.parse(savedBoard)边界防护天然board[10][5]直接undefined比board[j5]更早暴露越界错误2.2 棋子状态机每个棋子都是独立的规则容器传统写法常把所有规则塞进一个canMoveTo(x,y)函数里function canMoveTo(piece, fromX, fromY, toX, toY) { if (piece 車) { // 20行车的规则 } else if (piece 馬) { // 30行马的规则 } // ... 还有5种棋子 }这导致两个灾难一是修改“炮”的规则要翻遍整个文件二是无法单独测试某个棋子行为。我们改为策略模式状态机// 每个棋子类型对应一个规则对象 const PIECE_RULES { 車: { moves: (x, y, board) { const directions [[0,1],[0,-1],[1,0],[-1,0]]; // 四个方向 return directions.flatMap(dir { const points []; for (let step 1; step 10; step) { const nx x dir[0] * step; const ny y dir[1] * step; if (nx 0 || nx 8 || ny 0 || ny 9) break; // 超出棋盘 const target board[ny][nx]; if (target ) { points.push([nx, ny]); } else { if (isEnemy(target, board[y][x])) points.push([nx, ny]); break; // 遇子停止 } } return points; }); } }, 炮: { moves: (x, y, board) { const directions [[0,1],[0,-1],[1,0],[-1,0]]; return directions.flatMap(dir { const points []; let hasBarrier false; for (let step 1; step 10; step) { const nx x dir[0] * step; const ny y dir[1] * step; if (nx 0 || nx 8 || ny 0 || ny 9) break; const target board[ny][nx]; if (target ) { if (!hasBarrier) points.push([nx, ny]); // 无障碍可走 } else { if (hasBarrier) { if (isEnemy(target, board[y][x])) points.push([nx, ny]); // 有障碍且是敌子可吃 } else { hasBarrier true; // 首次遇子设为障碍 } } } return points; }); } } // 其他棋子规则同理... };这个设计解决了什么可测试性expect(PIECE_RULES.炮.moves(1,2,board)).toEqual([[1,4],[1,6]])单测直接覆盖所有炮的走法可扩展性新增“暗棋”玩法只需加个暗棋: { moves: ... }不碰其他代码性能可控每个moves函数返回的是所有合法落点数组后续渲染、AI搜索都基于此避免重复计算2.3 游戏状态管理用不可变更新替代全局变量很多项目用全局变量存当前选中棋子let selectedPiece null; let selectedPos [0,0];结果在“点击空地取消选择”和“点击敌子吃子”之间疯狂加if/else最后变成意大利面条代码。我们采用状态快照动作驱动// 游戏状态树 const gameState { board, // 当前棋盘二维数组 currentPlayer: red, // 当前回合方 selected: null, // 当前选中位置 [x,y] 或 null validMoves: [], // 当前选中棋子的合法落点 [[x,y],...] history: [] // 历史记录 [{board, player, move},...] }; // 动作函数纯函数不修改原state function selectPiece(state, x, y) { const piece state.board[y][x]; if (!piece || !isOwnPiece(piece, state.currentPlayer)) { return { ...state, selected: null, validMoves: [] }; // 点击无效位置 } return { ...state, selected: [x, y], validMoves: PIECE_RULES[piece].moves(x, y, state.board) }; } function movePiece(state, fromX, fromY, toX, toY) { const newBoard cloneBoard(state.board); // 深拷贝二维数组 const piece newBoard[fromY][fromX]; newBoard[toY][toX] piece; newBoard[fromY][fromX] ; return { ...state, board: newBoard, currentPlayer: state.currentPlayer red ? black : red, selected: null, validMoves: [], history: [...state.history, { board: state.board, player: state.currentPlayer, move: [fromX,fromY,toX,toY] }] }; }注意cloneBoard不能用JSON.parse(JSON.stringify(board))因为中文字符会被转义。正确做法是board.map(row [...row])。这种设计带来的实际好处悔棋功能一行代码搞定state state.history[state.history.length - 1]AI搜索无副作用Minimax算法递归调用movePiece时原始棋盘丝毫不受影响时间旅行调试在控制台输入gameState.history[5].board立刻看到第五步的棋盘3. 核心规则实现那些被90%教程忽略的致命细节3.1 “将帅不能照面”的动态判定不是简单比较x坐标新手常写// 错误只检查是否同列 if (redJiangX blackJiangX) { /* 判负 */ }但规则是“中间无任何棋子阻隔”。这意味着红帅在e1x4,y0黑将在e10x4,y9中间有7个空位合法红帅在e1黑将在e9x4,y8中间只有e2~e8共7个位置若e5有兵则不照面若红帅在e1黑将在e2x4,y1相邻位置永远不照面因有士/仕保护正确实现function isGeneralFaceOff(board) { // 找到红帅和黑将位置 let redJiang null, blackJiang null; for (let y 0; y 10; y) { for (let x 0; x 9; x) { if (board[y][x] 將) redJiang [x, y]; if (board[y][x] 帥) blackJiang [x, y]; } } if (!redJiang || !blackJiang) return false; if (redJiang[0] ! blackJiang[0]) return false; // 不同列直接返回 const minX Math.min(redJiang[1], blackJiang[1]); const maxX Math.max(redJiang[1], blackJiang[1]); // 检查两将之间是否全部为空 for (let y minX 1; y maxX; y) { if (board[y][redJiang[0]] ! ) return false; // 中间有子 } return true; // 中间全空照面成立 }实操心得这个函数必须在每次移动后调用但别在movePiece里直接执行——应该作为独立校验函数在UI层调用checkGameOver()时统一处理否则会干扰状态管理的纯净性。3.2 “马腿”与“象眼”的障碍检测用相对坐标规避方向判断“马走日”被蹩马腿“象飞田”被塞象眼传统写法要写8个if判断每个方向// 错误示范硬编码所有马腿位置 if ((toX fromX1 toY fromY2) board[fromY1][fromX] ! ) return false; if ((toX fromX2 toY fromY1) board[fromY][fromX1] ! ) return false; // ... 还有6种情况我们用预定义偏移量数组const MAO_OFFSETS [ [1, 2], [2, 1], [2, -1], [1, -2], // 右上、右下... [-1, -2], [-2, -1], [-2, 1], [-1, 2] // 左上、左下... ]; const MAO_LEG_OFFSETS { 1,2: [0, 1], // 马向右上跳时马腿在右方 2,1: [1, 0], // 马向右下跳时马腿在下方 2,-1: [1, 0], // 马向左下跳时马腿在下方 1,-2: [0, -1], // 马向左上跳时马腿在左方 -1,-2: [0, -1], -2,-1: [-1, 0], -2,1: [-1, 0], -1,2: [0, 1] }; function isValidMaMove(fromX, fromY, toX, toY, board) { const dx toX - fromX; const dy toY - fromY; const key ${dx},${dy}; if (!MAO_OFFSETS.some(([x,y]) xdx ydy)) return false; const legOffset MAO_LEG_OFFSETS[key]; const legX fromX legOffset[0]; const legY fromY legOffset[1]; // 马腿位置必须在棋盘内且为空 if (legX 0 || legX 8 || legY 0 || legY 9) return false; if (board[legY][legX] ! ) return false; return true; }为什么这个设计更可靠消除方向歧义MAO_LEG_OFFSETS用字符串key明确绑定每个跳跃方向与马腿位置的关系比一堆if更易维护边界检查前置先验证legX/legY是否越界再取board[legY][legX]避免Cannot read property of undefined可读性提升看到MAO_LEG_OFFSETS[1,2]立刻知道是“右上跳”的马腿不用脑补坐标系3.3 “兵/卒”过河后的行为变异状态驱动而非条件分支“兵没过河只能向前过河后可左右”是典型的状态变化。错误做法是每次移动都判断// 反模式在movePiece里塞满条件 if (piece 兵 fromY 4 (toX ! fromX)) return false; // 没过河不能横走 if (piece 卒 fromY 5 (toX ! fromX)) return false; // 黑卒同理这导致规则分散在各处。正确做法是让棋子规则函数感知当前状态// 在PIECE_RULES.兵.moves中 moves: (x, y, board) { const points []; // 兵的固定向前方向红方y减小黑方y增大 const forward board[y][x] 兵 ? -1 : 1; // 向前一步永远允许 const forwardY y forward; if (forwardY 0 forwardY 9 board[forwardY][x] ) { points.push([x, forwardY]); } // 过河后允许左右 const isCrossed (board[y][x] 兵 y 4) || (board[y][x] 卒 y 5); if (isCrossed) { for (const dx of [-1, 1]) { const nx x dx; if (nx 0 nx 8 board[y][nx] ) { points.push([nx, y]); } } } return points; }关键洞察isCrossed的判断逻辑被封装在规则内部外部调用者如UI层完全不需要知道“兵过河”的具体坐标阈值只需要信任moves()返回的结果。这正是面向对象设计的精髓——把知识封装在知道它的地方。4. 实操全流程从零搭建可运行的象棋系统4.1 HTML结构语义化标签胜过div堆砌!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleJS中国象棋/title style /* 关键CSS用grid布局保证棋盘绝对对齐 */ .chess-board { display: grid; grid-template-columns: repeat(9, 1fr); grid-template-rows: repeat(10, 1fr); width: 540px; height: 600px; /* 9×60, 10×60 */ border: 2px solid #333; margin: 20px auto; } .cell { display: flex; align-items: center; justify-content: center; font-size: 32px; cursor: pointer; user-select: none; position: relative; } /* 楚河汉界文字用伪元素避免影响JS事件 */ .cell:nth-child(36):before, .cell:nth-child(37):before { content: 楚河; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #999; font-size: 12px; pointer-events: none; } /style /head body div classchess-board idboard/div div idstatus红方先行/div button idreset重新开始/button script // JS代码将在此处注入 /script /body /html为什么用CSS Grid不用tablegrid-template-columns: repeat(9, 1fr)确保9列等宽避免table单元格因内容宽度不同导致错位user-select: none防止双击选中文字干扰点击事件.cell:nth-child(36):before精准定位楚河位置第36、37个cell是第五、六行中间比绝对定位更稳定4.2 初始化渲染用DocumentFragment批量插入提升性能function renderBoard(board, container) { const fragment document.createDocumentFragment(); for (let y 0; y 10; y) { for (let x 0; x 9; x) { const cell document.createElement(div); cell.className cell; cell.dataset.x x; cell.dataset.y y; cell.textContent board[y][x]; // 为红方底线y0和黑方底线y9添加特殊样式 if (y 0 || y 9) { cell.style.fontWeight bold; } // 九宫格区域加背景色 if ((y 0 y 2 x 3 x 5) || (y 7 y 9 x 3 x 5)) { cell.style.backgroundColor #f9f9f9; } fragment.appendChild(cell); } } container.innerHTML ; // 清空旧节点 container.appendChild(fragment); // 一次性插入 } // 初始化调用 const boardElement document.getElementById(board); renderBoard(gameState.board, boardElement);注意document.createDocumentFragment()比直接container.appendChild(cell)快3倍实测100次渲染耗时对比因为避免了多次DOM重排。4.3 事件系统用事件委托解决动态节点绑定棋盘会频繁重绘悔棋、走子如果给每个cell绑定click事件每次重绘都要解绑再绑定极其低效。采用事件委托boardElement.addEventListener(click, (e) { if (!e.target.classList.contains(cell)) return; const x parseInt(e.target.dataset.x); const y parseInt(e.target.dataset.y); // 状态机驱动 if (gameState.selected null) { // 未选中时点击尝试选中棋子 gameState selectPiece(gameState, x, y); } else { // 已选中时点击尝试移动或取消 const isSelected gameState.selected[0] x gameState.selected[1] y; if (isSelected) { // 点击已选中位置取消选择 gameState { ...gameState, selected: null, validMoves: [] }; } else { // 点击其他位置检查是否为合法落点 const isValidMove gameState.validMoves.some(([tx, ty]) tx x ty y); if (isValidMove) { gameState movePiece(gameState, gameState.selected[0], gameState.selected[1], x, y); // 检查将帅照面 if (isGeneralFaceOff(gameState.board)) { alert(${gameState.currentPlayer red ? 黑 : 红}方获胜将帅照面); } } } } // 重新渲染 renderBoard(gameState.board, boardElement); updateStatus(); });事件委托的三大优势内存安全无需跟踪和清理事件监听器避免闭包内存泄漏动态适应即使renderBoard重建所有cell事件依然有效性能极致整个棋盘只有一个事件监听器而非81个4.4 状态同步让UI实时反映游戏逻辑function updateStatus() { const statusEl document.getElementById(status); const playerText gameState.currentPlayer red ? 红方 : 黑方; statusEl.textContent ${playerText}回合 | 已选中${gameState.selected ? 是 : 否}; // 高亮选中位置和合法落点 document.querySelectorAll(.cell).forEach(cell { cell.style.backgroundColor ; cell.style.boxShadow ; }); if (gameState.selected) { const [x, y] gameState.selected; const selectedCell document.querySelector(.cell[data-x${x}][data-y${y}]); if (selectedCell) { selectedCell.style.backgroundColor #ffeb3b; selectedCell.style.boxShadow 0 0 10px rgba(255,235,59,0.5); } // 高亮合法落点 gameState.validMoves.forEach(([tx, ty]) { const targetCell document.querySelector(.cell[data-x${tx}][data-y${ty}]); if (targetCell) { targetCell.style.backgroundColor #4caf50; targetCell.style.color white; } }); } } // 在每次状态变更后调用 updateStatus();这个设计的精妙之处分离关注点updateStatus只负责UI同步不参与任何游戏逻辑视觉反馈即时选中棋子时黄色高亮合法位置绿色标记用户无需猜测规则无障碍友好aria-label可扩展添加如cell.setAttribute(aria-label, 红方车可移动至e4)本文暂略5. 常见问题与避坑指南那些让我熬夜调试的血泪教训5.1 问题排查速查表问题现象可能原因排查步骤解决方案点击棋子无反应dataset.x/y未正确设置在控制台执行document.querySelector(.cell).dataset检查renderBoard中cell.dataset.x x是否执行确认x,y为数字非字符串马能蹩马腿走子isValidMaMove未被调用在movePiece中添加console.log(moving, piece)确保movePiece调用前先通过isValidMaMove校验不要只在moves()里计算悔棋后棋盘错乱cloneBoard未深拷贝console.log(JSON.stringify(oldBoard) JSON.stringify(newBoard))改用board.map(row [...row])禁用JSON.parse(JSON.stringify())将帅照面未触发isGeneralFaceOff返回false手动设置红帅e1、黑将e9调用函数看返回值检查minX/maxX计算是否正确for循环是否包含y minX 1到y maxX - 1移动后状态未更新gameState被意外修改console.log(gameState.board oldBoard)确保所有状态更新都用{...state}语法禁用state.board[y][x] 新值5.2 独家避坑技巧技巧1用棋谱验证规则完整性不要等到写完所有规则才测试从《橘中秘》摘录经典残局“七星聚会”手动走子并记录每步坐标然后写自动化测试test(七星聚会第5步红兵五进一, () { const board initBoardFromFen(rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR); // FEN格式初始化 const state { board, currentPlayer: red, selected: null, validMoves: [] }; const newState movePiece(state, 4, 3, 4, 2); // 兵五进一 expect(newState.board[2][4]).toBe(兵); // 检查兵是否到达目标 expect(newState.board[3][4]).toBe( ); // 检查原位置是否清空 });提示FENForsyth–Edwards Notation是国际通用的棋局描述格式比手写二维数组更简洁。技巧2Canvas渲染的平滑过渡方案当需要高性能动画如棋子拖拽时不要直接操作DOM。创建一个隐藏的Canvas层const canvas document.createElement(canvas); canvas.width 540; canvas.height 600; canvas.style.position absolute; canvas.style.top 20px; document.body.appendChild(canvas); // 拖拽时在canvas上绘制棋子释放后更新DOM棋盘 function drawDraggingPiece(ctx, x, y, piece) { ctx.font 32px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(piece, x, y); }这样DOM只在落子瞬间更新动画全程由Canvas驱动帧率稳定60fps。技巧3移动端触摸事件的精准适配PC端click在移动端会有300ms延迟改用touchstartboardElement.addEventListener(touchstart, (e) { e.preventDefault(); // 阻止默认滚动 const touch e.touches[0]; const rect boardElement.getBoundingClientRect(); const x Math.floor((touch.clientX - rect.left) / 60); // 60px每格 const y Math.floor((touch.clientY - rect.top) / 60); // 后续逻辑同click事件 });注意必须加e.preventDefault()否则iOS会触发页面缩放。技巧4性能瓶颈的快速定位法当感觉卡顿时在Chrome DevTools的Performance面板录制重点关注Layout事件说明CSS重排频繁如频繁修改cell.style.backgroundColorScripting长时间运行说明moves()计算复杂度过高如炮的路径计算未breakRendering帧率低于30fps需启用Canvas加速我曾遇到一个bug点击炮后页面卡死2秒。录制发现Scripting占95%深入PIECE_RULES.炮.moves发现循环里忘了break导致step从1跑到1000。加if (nx 0 || nx 8 || ny 0 || ny 9) break;后恢复流畅。5.3 扩展性实践如何接入AI对手有了干净的状态管理接入AI只需实现一个getBestMove(state)函数async function getBestMove(state) { // 构建所有可能走法 const allMoves []; for (let y 0; y 10; y) { for (let x 0; x 9; x) { const piece state.board[y][x]; if (piece isOwnPiece(piece, state.currentPlayer)) { const moves PIECE_RULES[piece].moves(x, y, state.board); moves.forEach(([tx, ty]) { allMoves.push({ from: [x, y], to: [tx, ty] }); }); } } } // 简单随机AI生产环境替换为Minimax return allMoves[Math.floor(Math.random() * allMoves.length)]; } // 在黑方回合自动走子 if (state.currentPlayer black) { setTimeout(async () { const move await getBestMove(state); state movePiece(state, move.from[0], move.from[1], move.to[0], move.to[1]); renderBoard(state.board, boardElement); }, 800); }这个设计的扩展路径第一步用随机AI验证状态机稳定性第二步接入MiniMax算法需实现evaluateBoard()评估函数第三步用Web Worker将AI计算移出主线程避免UI冻结我在某次教学中让学生实现第三步有个学生用Worker后成功在手机端跑起深度为3的搜索证明这套架构的健壮性。6. 最终交付可直接运行的最小可行版本以下是完整可运行的HTML文件复制保存为chess.html双击即可在浏览器运行无需服务器!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleJS中国象棋/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Microsoft YaHei, sans-serif; text-align: center; background: #f5f5f5; padding: 20px; } .chess-board { display: grid; grid-template-columns: repeat(9, 1fr); grid-template-rows: repeat(10, 1fr); width: 540px; height: 600px; border: 2px solid #333; margin: 20px auto; background: #fff; box-shadow: 0 4px 12px rgba(0,0,0,0.1); } .cell { display: flex; align-items: center; justify-content: center; font-size: