简介:一份基于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~9,col 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);向右上跳(col+1),腿在 (2,3) 和 (2,4)?不对,标准规则:马向 (r-2, c+1) 跳时,需要 (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, col+1)。写代码时最容易漏掉的是把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 class="game-container"> <canvas id="board" width="560" height="620"></canvas> <div class="side-panel"> <div id="status">红方走棋</div> <button id="undoBtn">悔棋</button> <button id="restartBtn">重新开始</button> <ol id="moveList"></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×60=480,再加右边留白 40,总宽 40+480+40=560。高度: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 双人对战,都只是在这个骨架上加肉而已。
本文还有配套的精品资源,点击获取