ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Canvas构建中国象棋:从坐标建模到AI对弈的完整实践

用Canvas构建中国象棋:从坐标建模到AI对弈的完整实践 简介一份基于HTML5的棋牌游戏毕业设计论文PDF源自重庆邮电大学以中国象棋为实例系统展示了HTML5网页游戏从界面搭建到逻辑实现的完整过程。论文使用canvas标签动态绘制棋盘与棋子通过监听鼠标事件完成棋子的选择与移动JavaScript负责实现行棋规则判断、吃子、悔棋、胜负判定等功能并通过算法确保每步棋的合法性、实时更新游戏状态CSS则对界面文字风格与部件布局进行美化如设置棋盘样式、棋子颜色和高亮效果。内容还涉及在Dreamweaver中编写、运行与测试游戏以及面向不同浏览器调整兼容性的方法覆盖页面结构、交互逻辑与视觉呈现等开发层面。资源共1个PDF文件大小4.76MB包含中英文摘要、目录、正文和关键词结构完整适合计算机专业毕业设计参考也适合Web前端学习者用于理解游戏化开发思路。目前已有180人学习。1. 为什么象棋棋盘要用 canvas 而不是 DOM 棋子做网页象棋第一反应往往是摆 32 个div或img当棋子用绝对定位推着走。这个方案看起来简单但把规则校验、动画、重绘状态搅在一起之后代码会迅速膨胀。这篇论文里的做法很直接用canvas统一绘制棋盘和棋子鼠标点击坐标反算棋格所有棋子状态由一个二维数组维护。这也是我在复刻 flappy bird html5 原版和做 HTML5 课程作业时反复验证过的模式——canvas 的像素级控制特别适合“格子类 状态驱动”的棋牌游戏逻辑与渲染天然解耦比 DOM 节点少一层同步开销。值得说明的是论文虽然写于 2015 年但 canvas 2D 的 API 至今没有大改这套代码在今天的主流浏览器上依然能跑。对正在做游戏毕业设计、或想用 HTML5 交一份可演示的前端作品的人来说这份资源的价值不只在于能玩的象棋更在于“坐标建模→规则校验→状态回滚→胜负判定”这条完整链路。下面按我实际拆代码的顺序把每个环节的关键实现和坑位过一遍。2. 棋盘建模与 canvas 绘制坐标编码是第一步2.1 用数组代替 DOM 位置棋盘的最小数据结构中国象棋棋盘是 9 列 × 10 行红方在下。我用一个 10×9 的二维数组存储局面值 0 表示空正数表示红方棋子负数表示黑方棋子。棋子的种类编码不直接用中文而是用数字1 帅、2 仕、3 相、4 马、5 车、6 炮、7 兵黑方取负。这样后面写规则时Math.sign(value)能快速判断归属Math.abs(value)判断兵种。// board[row][col]row 0~9col 0~8 // 初始局面红方在下row 9 为红方底线 const board Array.from({ length: 10 }, () Array(9).fill(0)); function initBoard() { // 红方 board[9][0] 5; board[9][1] 4; board[9][2] 3; board[9][3] 2; board[9][4] 1; board[9][5] 2; board[9][6] 3; board[9][7] 4; board[9][8] 5; board[7][1] 6; board[7][7] 6; // 炮 for (let i 0; i 9; i 2) board[6][i] 7; // 兵 // 黑方对称初始化具体值省略 }这套编码的好处行棋合法性判断只需要读board[fromRow][fromCol]和board[toRow][toCol]吃子就是目标位置非空且异色。悔棋时只需要把被吃的棋子存回原位置不需要操作 DOM。绘画时根据数组值查一张PIECE_TEXT映射表决定在 canvas 上画什么字。2.2 渲染先把格子坐标和像素坐标换算清楚canvas 上没有“棋格”概念只有像素。我定义棋盘左边距MARGIN_X 40上边距MARGIN_Y 40格子边长CELL 60。那么行列坐标(row, col)转像素坐标是function cellToPixel(row, col) { return { x: MARGIN_X col * CELL, y: MARGIN_Y row * CELL }; }绘制时先画外边框、十条横线和九条竖线再画“楚河汉界”四个字。棋子是半径 26 的圆填充色用浅黄边框用深棕文字用ctx.fillText居中。关键一点棋盘线交叉点才是落子位置不是格子中心所以行线要画满 9 个交叉点而竖线在河界处断开。论文里提到的“通过 canvas 标签实现游戏部件操作”指的就是这套绘制与坐标换算。function drawBoard(ctx) { ctx.clearRect(0, 0, canvas.width, canvas.height); // 1. 画外框 ctx.strokeRect(MARGIN_X - 10, MARGIN_Y - 10, 8 * CELL 20, 9 * CELL 20); // 2. 画横线10 条 for (let row 0; row 10; row) { ctx.beginPath(); ctx.moveTo(MARGIN_X, MARGIN_Y row * CELL); ctx.lineTo(MARGIN_X 8 * CELL, MARGIN_Y row * CELL); ctx.stroke(); } // 3. 画竖线中间竖线遇到河界要断开 }逻辑说明clearRect每次全屏擦除避免残影strokeRect只画外框内部线条用beginPath单独画方便设置不同线宽。实际调试中发现竖线的河界断开不能简单用判断if (row 4 row 5) continue因为那是整条线不画而应该画成两段——这一点不处理好棋盘会变成“断头线”。2.3 鼠标命中点击坐标反算棋盘坐标用户点击后拿event.offsetX/Y或getBoundingClientRect换算先判断是否落在棋盘区域内再用四舍五入反算行列canvas.addEventListener(click, (e) { const rect canvas.getBoundingClientRect(); const px e.clientX - rect.left; const py e.clientY - rect.top; if (px MARGIN_X - 20 || px MARGIN_X 8 * CELL 20) return; if (py MARGIN_Y - 20 || py MARGIN_Y 9 * CELL 20) return; const col Math.round((px - MARGIN_X) / CELL); const row Math.round((py - MARGIN_Y) / CELL); if (col 0 || col 8 || row 0 || row 9) return; handleClick(row, col); });这里有个新手容易踩的坑Math.round会把点击在交叉点附近 30 像素内的操作都吸附上去这没问题但如果想更精确可以设一个吸附阈值比如距离交叉点中心超过 25 像素就忽略。论文里的游戏没做这个限制但实际体验里加上会让误触少很多。3. 走棋规则与 JavaScript 校验马腿象眼是分水岭3.1 规则数据的组织方式中国象棋的棋子走法分两类直线型车、炮、帅和跳跃型马、相、仕。直线型需要扫描路径上的阻塞情况跳跃型只需要判断特定位置的“腿”或“眼”是否为空。我定义一个getLegalMoves(board, row, col)函数返回该棋子所有合法目标坐标的数组。这一步不直接执行移动而是先收集合法点再用于高亮和校验。用表格把六种棋子的判定条件列清楚写代码时逐条翻译棋子移动规则阻塞判定特殊约束车横竖任意格路线上不能有子无炮横竖任意格必须隔一个子才能吃不吃子时不能越子吃子条件特殊马日字马腿位置为空不能蹩腿相田字象眼位置为空不能过河仕斜线一格无只限九宫帅横竖一格无只限九宫可中线照将需单独处理3.2 车与炮的实现路径扫描要分“吃”与“走”车和炮的代码骨架一致区别在“过河后”的判定。车遇到第一个子如果是敌方可吃然后停止如果是己方则直接停止。炮要找两个子第一个子是炮架第二个子如果是敌方才能吃。function rookAndCannonMoves(board, row, col, isCannon) { const dirs [[-1,0],[1,0],[0,-1],[0,1]]; const moves []; const self board[row][col]; const selfSide Math.sign(self); for (const [dr, dc] of dirs) { let r row dr, c col dc; let skipped 0; while (r 0 r 10 c 0 c 9) { const target board[r][c]; if (target 0) { if (!isCannon || skipped 0) moves.push([r, c]); // 炮在没隔子时只能走空位 } else { if (isCannon) { if (skipped 0) { skipped 1; // 第一个子作为炮架 } else { if (Math.sign(target) ! selfSide) moves.push([r, c]); break; // 第二个子无论敌我都停止 } } else { if (Math.sign(target) ! selfSide) moves.push([r, c]); break; // 车遇到子就停 } } r dr; c dc; } } return moves; }逻辑说明skipped变量标记炮是否已经遇到第一个炮架。炮走空位时若skipped 0表示前面没有障碍可以落子一旦遇到第一个子skipped置 1后续空位不再加入因为炮不能越过炮架平移。这里最容易写错的是把炮的“不吃子移动”与“吃子间隔”混在一起炮在移动时不能越子但在吃子时必须隔一个子所以skipped 1时遇敌才加入 moves。3.3 马腿和象眼单独判断两个偏移位置马的走法是八个“日”字点。比如从 (3,3) 向上走是 (1,4) 或 (1,2)蹩腿位置分别是 (2,3) 和 (2,3)这里要按移动方向区分向上跳腿在正上方 (2,3)向右上跳col1腿在 (2,3) 和 (2,4)不对标准规则马向 (r-2, c1) 跳时需要 (r-1, c) 为空向 (r-2, c-1) 跳时需要 (r-1, c) 为空。也就是说“腿”是沿着跳跃方向第一步的落脚点。function horseMoves(board, row, col) { const moves []; const selfSide Math.sign(board[row][col]); const candidates [ [-2, -1, -1, 0], [-2, 1, -1, 0], // 上腿在正上方 [2, -1, 1, 0], [2, 1, 1, 0], // 下 [-1, -2, 0, -1], [1, -2, 0, -1], // 左 [-1, 2, 0, 1], [1, 2, 0, 1] // 右 ]; for (const [dr, dc, br, bc] of candidates) { const nr row dr, nc col dc; if (nr 0 || nr 9 || nc 0 || nc 8) continue; if (board[row br][col bc] ! 0) continue; // 蹩腿 if (Math.sign(board[nr][nc]) selfSide) continue; moves.push([nr, nc]); } return moves; }注意每个候选数组的四个数值依次是目标行列增量、蹩腿位置行列增量。左跳时如果目标是 (row-1, col-2)腿在 (row, col-1)右跳时腿在 (row, col1)。写代码时最容易漏掉的是把dr和br写混导致“马腿”反而出现在目标位置。调试技巧把这个函数单独导出用node跑单元测试对每个初始位置断言moves.length和具体坐标比在浏览器里反复点击高效得多。3.4 将帅照面与九宫限制将帅只能在九宫内移动红方列 3~5行 7~9黑方列 3~5行 0~2。另外还有一个隐藏规则两将不能在同一列且中间无任何棋子叫“将帅照面”。这个判定放在全局合法性检查里而不是单子走法里function isGeneralFacing(board) { const redGeneral findPiece(board, 1, 9); // 红帅 const blackGeneral findPiece(board, -1, 0); // 黑将 if (!redGeneral || !blackGeneral) return false; if (redGeneral.col ! blackGeneral.col) return false; // 检查中间是否有棋子 let minRow Math.min(redGeneral.row, blackGeneral.row); let maxRow Math.max(redGeneral.row, blackGeneral.row); for (let r minRow 1; r maxRow; r) { if (board[r][redGeneral.col] ! 0) return false; } return true; }这个函数需要在每次走子后调用如果不合法则这条走法被禁止。论文里的 AI 没有做这一层检查但人机对战时如果 AI 走后形成照面很容易出 bug。我建议把这个检查纳入所有走法生成器的最后过滤。4. 吃子、悔棋、胜负判断与简单 AI 估价4.1 用“走法”对象封装一次操作悔棋才有戏悔棋不是把棋子放回去那么简单还要恢复被吃棋子、切换回合、可能还要撤销一条棋谱记录。所以我在 play 脚本里定义统一的Move对象class Move { constructor(from, to, captured) { this.from from; // {row, col} this.to to; // {row, col} this.captured captured; // {row, col, value} 或 null } }执行移动时先把board[toRow][toCol]存进move.captured再移动棋子。悔棋时反向操作把棋子从to移回from若move.captured非空则恢复该位置的值最后把当前回合切换到上一方。这套结构也正好对应论文里 bill 脚本“写入棋谱”的需求每个 Move 序列化后就是fromRow, fromCol, toRow, toCol, capturedValue很容易存成文本或 JSON。function applyMove(board, move) { const piece board[move.from.row][move.from.col]; board[move.to.row][move.to.col] piece; board[move.from.row][move.from.col] 0; if (move.captured) { move.captured.value 0; // 临时清空便于回传 } } function undoMove(board, move) { const piece board[move.to.row][move.to.col]; board[move.from.row][move.from.col] piece; board[move.to.row][move.to.col] 0; if (move.captured) { board[move.captured.row][move.captured.col] move.captured.value; } }参数说明applyMove不直接修改move.captured里的 value而是假设在生成 Move 时已经保存了被吃子的原始值。这样悔棋时才能精确恢复。如果你想支持多步悔棋只需要维护一个undoStack每次执行移动后 push点击悔棋时 pop。4.2 胜负判断将棋丢失或无法走子两种判负方式一是将军被吃二是轮到某方时无任何合法走法困毙。前者在吃子后检查被吃的是否为将帅后者在走法生成后判断legalMoves.length 0。注意中国象棋没有“无子可动即输”的规则吗实际上有如果轮到一方走棋且所有棋子都无合法走法则判负。这个逻辑可以在checkGameOver()中实现function checkGameOver(board, currentSide) { // 1. 检查将帅是否还在 if (!findGeneral(board, currentSide)) return loss; // 2. 检查当前方是否无棋可走 const allMoves generateAllMoves(board, currentSide); if (allMoves.length 0) return loss; // 3. 检查是否形成“将帅照面”且当前方刚走完 if (isGeneralFacing(board) currentSide -1) return loss; // 红方刚走成照面则红负 return playing; }这里面有个细节将帅照面一旦形成应该判走成照面的一方输。但在递归搜索时不能直接返回胜负而要避免生成违反照面规则的走法。最简单的方法是在applyMove后再调用isGeneralFacing如果为 true 则撤销并跳过该走法。这样 AI 永远不会走出照面。4.3 AI基于局面分数的简易估值论文里的 AI 脚本主要做两件事生成当前方所有合法走法然后每步评估。我没有采用复杂的 alpha-beta 剪枝而是用一个静态评估函数evaluate(board)返回红绿两方的子力差。每种棋子的价值设为帅 10000、车 900、炮 500、马 400、相 200、仕 200、兵 100过河兵可加成。AI 每层深度设为 2即“我走一步你走一步”然后取评估值最大的走法。function evaluate(board) { let score 0; for (let r 0; r 10; r) { for (let c 0; c 9; c) { const v board[r][c]; if (v 0) continue; const value getPieceValue(v); score Math.sign(v) * value; // 过河兵加成 if (Math.abs(v) 7) { if (Math.sign(v) 0 r 5) score 20; if (Math.sign(v) 0 r 4) score - 20; } } } return score; // 正数表示红方优势 }逻辑说明Math.sign(v)是棋子归属1 为红-1 为黑。累加时红棋加正分黑棋减正分所以总分体现红黑差距。AI 走子时把当前局面复制深拷贝二维数组模拟走法后调用 evaluate选 score 最大对红方或最小对黑方的走法。实际体验中这个 AI 不会送吃但看不到两步以后的炮兵配合正好适合作为毕设的“智能”展示。如果想增强可以加入棋子位置表比如马在中央价值高边线马价值低。4.4 棋谱记录bill 脚本的实用化论文里的 bill 脚本负责“写入棋谱”。我以前做过一个简单版本每次移动后把 Move 对象转成车二平五这种中文描述。中文描述需要根据棋子类型、起始列和目标列映射到汉字数字还要区分前进、后退、平移。比较繁琐但作为毕设加分项很有效。这里给出一个简化版生成器function moveToText(board, move, side) { const piece Math.abs(board[move.from.row][move.from.col]); const name {1:帅,2:仕,3:相,4:马,5:车,6:炮,7:兵}[piece]; const colName [一,二,三,四,五,六,七,八,九]; const fromCol side 0 ? move.from.col : 8 - move.from.col; const toCol side 0 ? move.to.col : 8 - move.to.col; let action ; if (move.captured) action 吃; return ${name}${colName[fromCol]}${action}${colName[toCol]}; }这个函数有个问题当同列有两个相同棋子时需要额外记录“前/后”区分。我的做法是在生成走法时如果一个棋子在目标列有同类在前方就加上前后缀。这块内容论文没有细讲但做棋谱翻盘功能时就会需要。棋谱记录除了展示还能用来验证“悔棋”的确定性只要从第一步重新执行所有操作就能还原任何一局对于测试很有用。5. 界面打磨与多浏览器实测CSS 布局和事件防坑5.1 CSS 控制布局而不是 canvas论文里提到用 CSS 修饰界面文字和游戏部件布局。我的原则是canvas 只画棋盘棋子周边的按钮、状态栏、棋谱列表全部用 HTML 元素配合 CSS3 完成。这样既能利用 flexbox 做响应式也能避免每次重绘 canvas 时把按钮也画进去。举个例子div classgame-container canvas idboard width560 height620/canvas div classside-panel div idstatus红方走棋/div button idundoBtn悔棋/button button idrestartBtn重新开始/button ol idmoveList/ol /div /div.game-container { display: flex; justify-content: center; gap: 20px; font-family: Noto Serif SC, serif; } #board { border: 2px solid #8b5a2b; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .side-panel { width: 220px; display: flex; flex-direction: column; gap: 12px; }棋盘 canvas 的width属性是 560因为左边距 40 9 列 × 60 580这里要算准从 MARGIN_X 到最后一个交叉点距离是 8×60480再加右边留白 40总宽 4048040560。高度40 9×60 40 620。如果画布尺寸与坐标换算不一致点击会出现系统性偏移。我通常用canvas.width MARGIN_X * 2 8 * CELL动态计算避免手工硬编码。5.2 浏览器兼容实测canvas 没问题问题在事件坐标论文里“在不同浏览器上进行测试”这一步当年的痛点主要是canvas在 IE8 不兼容。现在主流浏览器都已经支持 canvas 2D真正要测试的是event.offsetX/offsetY——它们在 Firefox 早期版本不支持更稳妥的写法是用getBoundingClientRect计算function getCanvasPos(e) { const rect canvas.getBoundingClientRect(); return { px: e.clientX - rect.left, py: e.clientY - rect.top }; }另外移动端触屏设备上click事件有 300ms 延迟可以改为pointerdown或touchstart。这篇论文没涉及移动端但如果你把 HTML5 游戏挂到手机上看演示这步很有必要。我习惯把监听器统一绑定为一个onPointerDown内部用e.touches ? e.touches[0] : e统一坐标来源。5.3 验证小技巧用棋谱回放代替手工点击最后一招也是我想重点说的测试这类棋类游戏时不要只靠鼠标乱点。先把 bill 脚本写出的棋谱保存成数组然后写一个playbackInterval每 500ms 执行一个 Move同时检查每一步后的局面是否符合预期。这样能自动跑通“开局十几步”的稳定性还能在控制台输出每个走法的合法性。我的验证脚本大致是function replayMoves(moves) { moves.forEach((move, index) { setTimeout(() { applyMove(board, move); drawBoard(ctx); if (isGeneralFacing(board)) console.warn(第${index}步形成照面); }, index * 500); }); }如果回放过程中发现某两步棋导致棋盘状态异常基本可以断定是applyMove里的坐标写反了或者悔棋恢复不完整。这套方法比肉眼盯屏幕快得多也方便写进自动化测试——把十局棋谱跑完所有合法棋盘状态没有 undefined、没有棋子重叠才说明核心逻辑基本可靠。把 AI 深度调低后你还能让 AI 左右互搏输出整局棋谱用来进一步验证胜负判断和困毙逻辑。我在复刻 flappy bird html5 原版时也用过同样的循环渲染思路状态更新、绘制、事件响应三个函数各管一段调试起来非常清晰。这份 HTML5 象棋资源的价值就在于此你可以把它当成一个“规则完整、逻辑闭环”的前端实训样本改棋盘、换棋子样式、接 WebSocket 双人对战都只是在这个骨架上加肉而已。本文还有配套的精品资源点击获取
返回列表