从零开始打造HTML5拼图游戏:一个Canvas实战项目
从零开始打造HTML5拼图游戏:一个Canvas实战项目
先看效果:




你是否曾经被那些精美的网页拼图游戏所吸引?用 HTML5 的 Canvas 技术,从零开始,教你怎么画图、处理鼠标事件,还有游戏的核心逻辑,最后实现一个完整的拼图游戏!
1. 前言:为什么选择Canvas开发拼图游戏?
在开始动手之前,我想分享一下为什么选择HTML5 Canvas来开发拼图游戏。相比于传统的DOM操作,Canvas提供了更高效的图形渲染能力和更灵活的像素级控制。对于拼图这种需要处理不规则形状和复杂交互的游戏来说,Canvas无疑是最佳选择。
在我尝试过各种技术方案后,最终确定了这套实现方案,它具有以下优势:
- 性能优异:即使在移动设备上也能流畅运行
- 视觉效果好:支持不规则拼图形状、平滑动画和精确的图像裁剪
- 交互体验佳:磁性吸附、拖放操作和触摸支持让游戏体验更加友好
- 代码结构清晰:便于理解和扩展
本文将分享我在开发这款拼图游戏过程中的经验和技巧,希望能对你的Canvas游戏开发之旅有所帮助。
2. 游戏功能与效果展示
在深入代码实现之前,让我们先来看看这个拼图游戏能做什么:
- 多种难度级别:初级(3×3)、中级(4×4)、高级(5×5)模式
- 锯齿形拼图:每个拼图碎片都有独特的锯齿形状,能够完美拼合
- 智能交互:
- 拖放操作:直观地拖动和放置拼图碎片
- 磁性吸附:当碎片接近正确位置时会有轻微的吸引力
- 自动对齐:正确放置的碎片会自动对齐并固定
- 辅助功能:提供提示功能帮助玩家找到正确位置
- 全设备支持:同时支持鼠标和触摸屏操作,可在电脑和移动设备上游玩
当你完成整个拼图时,游戏会显示祝贺信息,让玩家体验到成就感。整体来说,这是一个兼具挑战性和趣味性的小游戏,非常适合Canvas初学者作为练手项目。
3. 核心设计与数据结构
任何游戏开发的第一步都是设计合适的数据结构。在我们的拼图游戏中,需要管理拼图的状态、形状和位置,所以设计了以下核心数据结构:
3.1 游戏配置
游戏配置决定了不同难度级别的参数,主要是每边的拼图数量:
const config = {easy: { piecesPerSide: 3 }, // 3x3=9块medium: { piecesPerSide: 4 }, // 4x4=16块hard: { piecesPerSide: 5 }, // 5x5=25块irregular: { piecesPerSide: 3 } // 使用预定义的不规则形状,3x3布局
};
这种设计允许我们轻松地扩展更多难度级别,只需添加新的配置项即可。比如,如果将来想添加一个"噩梦"难度,可以简单地添加 nightmare: { piecesPerSide: 6 }。
3.2 游戏状态变量
为了跟踪游戏的当前状态,我设计了一组关键变量:
let currentLevel = "easy"; // 当前难度级别
let pieces = []; // 所有拼图碎片的数组
let draggingPiece = null; // 当前正在拖动的碎片
let hintPiece = null; // 当前提示高亮的碎片
let offsetX = 0, offsetY = 0; // 拖动时的鼠标偏移量
let gameStarted = false; // 游戏是否已开始
这些状态变量共同构成了游戏的"记忆系统",使游戏能够正确响应用户操作并维持连贯的体验。
3.3 拼图碎片的数据结构
每个拼图碎片是一个复杂的对象,包含多种属性:
{id: Number, // 唯一标识符,用于区分不同碎片shape: Array, // 形状点数组,定义碎片的多边形轮廓correctX: Number, // 正确位置的X坐标(拼图区域内)correctY: Number, // 正确位置的Y坐标(拼图区域内)width: Number, // 碎片基本宽度height: Number, // 碎片基本高度row: Number, // 行索引(在拼图网格中)col: Number, // 列索引(在拼图网格中)x: Number, // 当前X坐标(可能在操作区或拼图区)y: Number, // 当前Y坐标(可能在操作区或拼图区)fixed: Boolean // 是否已固定到正确位置
}
这个数据结构的设计反映了拼图碎片的二重性:它既有一个"应该在的位置"(correctX/Y),也有一个"当前位置"(x/y)。当玩家成功将碎片放到正确位置时,fixed属性会被设置为true,表示该碎片已完成。
3.4 游戏区域定义
游戏界面分为两个主要区域:
const puzzleArea = {x: 0,y: 0,width: 600,height: 600
};const operationArea = {x: 620,y: 0,width: 340,height: 600
};
左侧的拼图区是玩家需要完成拼图的地方,而右侧的操作区则存放未使用的拼图碎片。这种分区设计使界面清晰,玩家可以轻松区分"工作区"和"材料区"。
这些核心数据结构共同构成了拼图游戏的骨架,为后续的功能实现奠定了基础。在设计这些结构时,我特别注重了可扩展性和可维护性,使代码更容易理解和修改。
4. 初始化与资源加载
游戏开发中,初始化是至关重要的一步。我们需要设置画布、加载图片,并准备好游戏的初始状态。
4.1 Canvas与图片准备
首先,我们需要获取Canvas元素并创建2D绘图上下文:
const canvas = document.getElementById("puzzleCanvas");
const ctx = canvas.getContext("2d");
然后,加载要用于拼图的图片:
const img = new Image();
img.crossOrigin = "Anonymous"; // 添加跨域支持
img.src = "https://images.pexels.com/photos/87452/flowers-background-butterflies-beautiful-87452.jpeg?auto=compress&cs=tinysrgb&w=600";
注意我添加了crossOrigin属性,这是因为我们使用的是外部图片源。如果没有这个属性,当图片来自不同域时,Canvas会被"污染",导致无法通过toDataURL()或getImageData()方法提取像素数据。
在实际项目中,我建议尽可能使用自己服务器上的图片,以避免跨域问题和外部资源不可用的风险。
4.2 图片加载完成后的初始化
接下来是图片加载完成后的初始化逻辑:
img.onload = () => {console.log("图片加载完成:", img.width, "x", img.height);// 设置canvas大小以适应拼图区和操作区canvas.width = puzzleArea.width + operationArea.width;canvas.height = Math.max(puzzleArea.height, operationArea.height);// 默认选中初级难度document.querySelector('.difficulty-btn[data-level="easy"]').classList.add('active');// 生成拼图碎片generatePieces();gameStarted = true;console.log("游戏已初始化,难度: " + currentLevel + ",碎片数量: ", pieces.length);
};
这里我做了几件重要的事:
- 根据拼图区和操作区的大小设置Canvas的尺寸
- 通过CSS类标记当前选中的难度级别
- 调用
generatePieces()生成拼图碎片 - 设置
gameStarted为true,表示游戏已准备就绪
另外,我还添加了图片加载失败的处理:
img.onerror = () => {console.error("图片加载失败");alert("图片加载失败,请检查网络连接或刷新页面重试。");
};
良好的错误处理可以提升用户体验,让他们知道发生了什么问题,而不是面对一个无响应的界面。
5. 拼图碎片生成
拼图游戏的核心在于生成形状独特、能够完美拼合的拼图碎片。这是整个项目中最具挑战性的部分。
5.1 生成拼图碎片
先来看看生成拼图碎片的主函数:
function generatePieces() {console.log("生成拼图碎片,难度:", currentLevel);pieces = [];// 获取配置const piecesPerSide = config[currentLevel].piecesPerSide;const pieceWidth = puzzleArea.width / piecesPerSide;const pieceHeight = puzzleArea.height / piecesPerSide;// 生成所有碎片for (let row = 0; row < piecesPerSide; row++) {for (let col = 0; col < piecesPerSide; col++) {// 计算碎片在原图中的位置const x = col * pieceWidth;const y = row * pieceHeight;// 生成带锯齿的形状(确保能完美拼合)const shape = generateJigsaw(row, col, pieceWidth, pieceHeight, piecesPerSide);// 创建碎片对象const piece = {id: row * piecesPerSide + col,shape: shape,correctX: x,correctY: y,width: pieceWidth,height: pieceHeight,row: row,col: col,x: randomPosition().x,y: randomPosition().y,fixed: false};pieces.push(piece);}}// 打乱顺序pieces.sort(() => Math.random() - 0.5);console.log("生成的碎片:", pieces.length, "个");// 初始绘制drawPuzzle();
}
这个函数完成了以下工作:
- 根据当前难度级别确定拼图的数量和尺寸
- 通过嵌套循环生成网格状的拼图碎片
- 为每个碎片调用
generateJigsaw()生成带锯齿的形状 - 将每个碎片随机放置在操作区
- 打乱碎片顺序,增加游戏的随机性
- 调用
drawPuzzle()进行初始绘制
生成的碎片初始都是未固定状态(fixed: false),并且位于操作区内的随机位置。
5.2 锯齿形状生成算法
核心部分——生成锯齿形状的算法:
function generateJigsaw(row, col, width, height, piecesPerSide) {const jigSize = Math.min(width, height) * 0.15; // 锯齿大小// 基础矩形的四个角const baseRect = [{x: 0, y: 0}, // 左上{x: width, y: 0}, // 右上{x: width, y: height}, // 右下{x: 0, y: height} // 左下];const result = [];// 为每条边添加锯齿for (let i = 0; i < 4; i++) {const p1 = baseRect[i];const p2 = baseRect[(i + 1) % 4];result.push({x: p1.x, y: p1.y});// 只在内部边缘添加锯齿(不是拼图的外边缘)if ((i === 0 && row > 0) || // 上边,且不是第一行(i === 1 && col < piecesPerSide - 1) || // 右边,且不是最后一列(i === 2 && row < piecesPerSide - 1) || // 下边,且不是最后一行(i === 3 && col > 0)) { // 左边,且不是第一列// 在边的中点添加锯齿const midX = (p1.x + p2.x) / 2;const midY = (p1.y + p2.y) / 2;// 确定锯齿方向let perpX = 0, perpY = 0;if (i === 0 || i === 2) { // 上边或下边perpY = i === 0 ? -jigSize : jigSize; // 上边凸起,下边凹陷if ((row + col) % 2 === 0) perpY = -perpY; // 交替锯齿方向} else { // 左边或右边perpX = i === 3 ? -jigSize : jigSize; // 左边凸起,右边凹陷if ((row + col) % 2 === 1) perpX = -perpX; // 交替锯齿方向}// 添加锯齿点(3点组成圆滑锯齿)const ctrlDist = Math.min(width, height) * 0.1;result.push({x: (p1.x + midX) / 2 + perpX * 0.3, y: (p1.y + midY) / 2 + perpY * 0.3});result.push({x: midX + perpX, y: midY + perpY});result.push({x: (midX + p2.x) / 2 + perpX * 0.3, y: (midY + p2.y) / 2 + perpY * 0.3});}}return result;
}
这个算法的巧妙之处在于:
- 首先创建一个基础矩形,代表拼图碎片的基本形状
- 然后在每条内部边(不是拼图外边缘)的中点添加锯齿
- 通过设置锯齿的方向(凸起或凹陷),确保相邻碎片可以完美拼合
- 使用行列索引的奇偶性交替锯齿方向,创造更多变化
- 添加三个点来形成圆滑的锯齿,而不是尖锐的三角形
这种算法生成的锯齿形状既有视觉上的美感,又能确保每个碎片只能与正确的相邻碎片拼合。
5.3 随机位置生成
最后,我们需要一个函数来生成操作区内的随机位置:
const randomPosition = () => {return {x: operationArea.x + Math.random() * (operationArea.width - 200),y: Math.random() * (operationArea.height - 200)};
};
注意,我减去了200像素的边距,以确保大部分拼图碎片能完全显示在操作区内,不会超出Canvas的边界。
这三个函数共同完成了拼图碎片的生成工作。从基本的网格划分,到精细的锯齿形状设计,再到随机的初始布局,每一步都经过精心设计,以确保游戏的可玩性和视觉效果。
6. 绘图系统设计
Canvas拼图游戏的核心是绘图系统。我们需要高效、准确地绘制背景、拼图碎片和各种视觉效果。
6.1 主绘制函数
drawPuzzle函数是整个绘图系统的入口,负责协调所有绘制任务:
function drawPuzzle() {// 确保图片已加载if (!img.complete) {console.log("图片尚未加载完成,稍后重试");requestAnimationFrame(drawPuzzle);return;}// 清空画布ctx.clearRect(0, 0, canvas.width, canvas.height);// 绘制完整的原始图片作为背景ctx.drawImage(img, 0, 0, img.width, img.height, puzzleArea.x, puzzleArea.y, puzzleArea.width, puzzleArea.height);// 添加半透明遮罩,使背景图片变暗ctx.fillStyle = "rgba(0, 0, 0, 0.6)"; // 黑色遮罩,60%不透明度ctx.fillRect(puzzleArea.x, puzzleArea.y, puzzleArea.width, puzzleArea.height);// 绘制拼图区轮廓ctx.strokeStyle = "#aaa";ctx.lineWidth = 1;ctx.strokeRect(puzzleArea.x, puzzleArea.y, puzzleArea.width, puzzleArea.height);// 绘制操作区轮廓ctx.strokeStyle = "#aaa";ctx.lineWidth = 1;ctx.strokeRect(operationArea.x, operationArea.y, operationArea.width, operationArea.height);// 添加区域标签ctx.fillStyle = "#666";ctx.font = "14px Arial";ctx.fillText("拼图区域", puzzleArea.x + 10, puzzleArea.y + 20);ctx.fillText("操作区域", operationArea.x + 10, operationArea.y + 20);// 首先绘制所有已固定的碎片(在下层)pieces.forEach(piece => {if (piece.fixed) {drawFixedPiece(piece);}});// 然后绘制所有未固定的碎片(在上层)pieces.forEach(piece => {if (!piece.fixed) {drawFloatingPiece(piece);}});
}
这个函数完成了以下工作:
- 确保图片已加载完成,否则通过
requestAnimationFrame稍后重试 - 清空整个Canvas画布,准备重新绘制
- 绘制原始图片作为背景,并添加半透明遮罩使其变暗
- 绘制拼图区和操作区的边框和标签
- 先绘制已固定的碎片(在下层),再绘制未固定的碎片(在上层)
绘制顺序非常重要,它决定了哪些元素会出现在上层。在我们的设计中,未固定的碎片应该显示在固定碎片的上方,以便玩家可以轻松拖动它们。
6.2 绘制浮动碎片
drawFloatingPiece函数负责绘制尚未固定到正确位置的拼图碎片:
function drawFloatingPiece(piece) {ctx.save();// 如果正在拖动且接近正确位置,显示吸附辅助线if (draggingPiece === piece) {const distanceThreshold = 60;const targetX = puzzleArea.x + piece.correctX;const targetY = puzzleArea.y + piece.correctY;if (Math.abs(piece.x - targetX) < distanceThreshold && Math.abs(piece.y - targetY) < distanceThreshold) {// 绘制辅助线ctx.save();ctx.strokeStyle = "rgba(46, 125, 50, 0.6)";ctx.lineWidth = 2;ctx.setLineDash([5, 3]);// 绘制正确位置的轮廓ctx.beginPath();piece.shape.forEach((point, i) => {if (i === 0) ctx.moveTo(puzzleArea.x + piece.correctX + point.x, puzzleArea.y + piece.correctY + point.y);else ctx.lineTo(puzzleArea.x + piece.correctX + point.x, puzzleArea.y + piece.correctY + point.y);});ctx.closePath();ctx.stroke();ctx.restore();}}// 添加阴影效果ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';ctx.shadowBlur = 5;ctx.shadowOffsetX = 2;ctx.shadowOffsetY = 2;// 创建碎片形状路径用于裁剪ctx.beginPath();piece.shape.forEach((point, i) => {if (i === 0) ctx.moveTo(piece.x + point.x, piece.y + point.y);else ctx.lineTo(piece.x + point.x, piece.y + point.y);});ctx.closePath();// 创建裁剪路径ctx.save();ctx.clip();// 缩放因子const scaleX = img.width / puzzleArea.width;const scaleY = img.height / puzzleArea.height;// 计算矩形边界,确保覆盖整个形状let minX = Infinity, minY = Infinity;let maxX = -Infinity, maxY = -Infinity;piece.shape.forEach(point => {minX = Math.min(minX, point.x);minY = Math.min(minY, point.y);maxX = Math.max(maxX, point.x);maxY = Math.max(maxY, point.y);});// 添加一些边距确保完全覆盖const margin = 2;minX -= margin;minY -= margin;maxX += margin;maxY += margin;// 计算要绘制的矩形尺寸const rectWidth = maxX - minX;const rectHeight = maxY - minY;// 计算图像源区域,包含可能超出基本格子的部分const correctXWithOffset = piece.correctX + minX;const correctYWithOffset = piece.correctY + minY;const sourceX = correctXWithOffset * scaleX;const sourceY = correctYWithOffset * scaleY;const sourceWidth = rectWidth * scaleX;const sourceHeight = rectHeight * scaleY;// 绘制相应区域的图像到碎片位置ctx.drawImage(img,sourceX, sourceY, sourceWidth, sourceHeight,piece.x + minX, piece.y + minY, rectWidth, rectHeight);ctx.restore();// 绘制边框ctx.strokeStyle = "#666";ctx.lineWidth = 1;ctx.stroke();// 绘制提示高亮if (hintPiece === piece) {// 高亮当前碎片ctx.strokeStyle = "yellow";ctx.lineWidth = 3;ctx.stroke();// 显示目标位置ctx.globalAlpha = 0.3;ctx.fillStyle = "lime";// 在正确位置绘制形状提示ctx.beginPath();piece.shape.forEach((point, i) => {if (i === 0) ctx.moveTo(puzzleArea.x + piece.correctX + point.x, puzzleArea.y + piece.correctY + point.y);else ctx.lineTo(puzzleArea.x + piece.correctX + point.x, puzzleArea.y + piece.correctY + point.y);});ctx.closePath();ctx.fill();ctx.globalAlpha = 1.0;}ctx.restore();
}
这个函数实现了以下功能:
- 吸附辅助线:当拖动的碎片接近正确位置时,显示虚线轮廓指示目标位置
- 阴影效果:为碎片添加阴影,增强立体感
- 图像裁剪:使用碎片的形状作为裁剪路径,只显示对应区域的图像
- 边界计算:计算碎片形状的边界框,确保锯齿部分也能正确显示
- 提示高亮:当碎片被选为提示时,用黄色边框高亮显示,并在正确位置显示半透明的绿色轮廓
最关键的部分是图像裁剪和源区域计算。不同于普通的拼图,我们的锯齿形状可能超出基本的网格单元,所以需要特别计算图像的源区域和目标区域,确保锯齿部分显示正确的图像内容。
6.3 绘制已固定碎片
drawFixedPiece函数负责绘制已固定到正确位置的拼图碎片:
function drawFixedPiece(piece) {ctx.save();// 定位到拼图区中的正确位置const pieceX = puzzleArea.x + piece.correctX;const pieceY = puzzleArea.y + piece.correctY;// 创建碎片路径用于裁剪ctx.beginPath();piece.shape.forEach((point, i) => {if (i === 0) ctx.moveTo(pieceX + point.x, pieceY + point.y);else ctx.lineTo(pieceX + point.x, pieceY + point.y);});ctx.closePath();// 创建裁剪路径ctx.clip();// 缩放因子const scaleX = img.width / puzzleArea.width;const scaleY = img.height / puzzleArea.height;// 计算矩形边界,确保覆盖整个形状let minX = Infinity, minY = Infinity;let maxX = -Infinity, maxY = -Infinity;piece.shape.forEach(point => {minX = Math.min(minX, point.x);minY = Math.min(minY, point.y);maxX = Math.max(maxX, point.x);maxY = Math.max(maxY, point.y);});// 添加一些边距确保完全覆盖const margin = 2;minX -= margin;minY -= margin;maxX += margin;maxY += margin;// 计算要绘制的矩形尺寸const rectWidth = maxX - minX;const rectHeight = maxY - minY;// 计算图像源区域,考虑锯齿形状可能超出基本格子的部分const sourceX = (piece.correctX + minX) * scaleX;const sourceY = (piece.correctY + minY) * scaleY;const sourceWidth = rectWidth * scaleX;const sourceHeight = rectHeight * scaleY;// 绘制相应区域的图像到碎片位置ctx.drawImage(img,sourceX, sourceY, sourceWidth, sourceHeight,pieceX + minX, pieceY + minY, rectWidth, rectHeight);// 绘制边框ctx.strokeStyle = "#388E3C"; // 绿色边框ctx.lineWidth = 1;ctx.stroke();ctx.restore();
}
这个函数与绘制浮动碎片的函数类似,但有几个重要区别:
- 碎片位置固定在拼图区的正确位置(使用
correctX和correctY) - 没有阴影效果,使固定碎片看起来更"平"
- 使用绿色边框而不是灰色,表示碎片已正确放置
- 不需要处理拖动和提示相关的逻辑
通过这种方式,玩家可以直观地区分已固定和未固定的碎片,并得到视觉上的满足感当他们成功放置一个碎片时。
6.4 图像裁剪的技术挑战
在实现这些绘制函数时,我遇到了几个技术挑战:
- 锯齿形状的完整裁剪:由于锯齿可能超出基本矩形边界,我们需要计算完整的边界框并调整源图像区域
- 坐标系转换:需要在拼图碎片的局部坐标和Canvas的全局坐标之间进行转换
- 透明度处理:使用
globalAlpha属性时需要注意恢复默认值,以免影响后续绘制
其中最复杂的是计算正确的源图像区域。考虑一个有锯齿的拼图碎片,其锯齿部分可能伸出基本网格单元。为了显示完整的碎片,我们需要:
- 计算形状的边界框(最小/最大x和y坐标)
- 根据边界框调整源图像区域和目标绘制区域
- 使用裁剪路径确保只显示我们想要的形状部分
这种方法确保了锯齿部分显示的是正确的图像内容,而不是相邻碎片的内容,从而保证拼图能够视觉上完美拼合。
7. 交互系统实现
一个好的拼图游戏需要流畅、直观的交互体验。让我们看看如何实现拖放、吸附和其他交互功能。
7.1 鼠标拖放
// 鼠标按下 - 选择碎片
canvas.addEventListener("mousedown", (e) => {if (!gameStarted) return;const rect = canvas.getBoundingClientRect();const mouseX = e.clientX - rect.left;const mouseY = e.clientY - rect.top;// 从上往下检查(这样可以选择最上面的碎片)for (let i = pieces.length - 1; i >= 0; i--) {const piece = pieces[i];if (!piece.fixed && isPointInPiece(mouseX, mouseY, piece)) {draggingPiece = piece;// 将当前碎片移到数组末尾(绘制时会显示在最上层)pieces.splice(i, 1);pieces.push(piece);// 记录偏移量offsetX = mouseX - piece.x;offsetY = mouseY - piece.y;break;}}
});// 鼠标移动 - 拖动碎片
canvas.addEventListener("mousemove", (e) => {if (!draggingPiece) return;const rect = canvas.getBoundingClientRect();const mouseX = e.clientX - rect.left;const mouseY = e.clientY - rect.top;// 设置碎片位置draggingPiece.x = mouseX - offsetX;draggingPiece.y = mouseY - offsetY;// 磁性吸附效果const closeThreshold = 30;const targetX = puzzleArea.x + draggingPiece.correctX;const targetY = puzzleArea.y + draggingPiece.correctY;if (Math.abs(draggingPiece.x - targetX) < closeThreshold && Math.abs(draggingPiece.y - targetY) < closeThreshold) {const xDistance = targetX - draggingPiece.x;const yDistance = targetY - draggingPiece.y;const ratio = 0.2; // 吸引力强度系数draggingPiece.x += xDistance * ratio;draggingPiece.y += yDistance * ratio;}drawPuzzle();
});// 鼠标释放 - 放置碎片
canvas.addEventListener("mouseup", (e) => {if (!draggingPiece) return;// 检查是否放在拼图区域中const inPuzzleArea = (draggingPiece.x >= puzzleArea.x - 50 && draggingPiece.y >= puzzleArea.y - 50 &&draggingPiece.x < puzzleArea.x + puzzleArea.width - 50 &&draggingPiece.y < puzzleArea.y + puzzleArea.height - 50);if (inPuzzleArea) {// 检查是否接近正确位置if (isPieceNearCorrectPosition(draggingPiece)) {// 吸附到正确位置draggingPiece.x = puzzleArea.x + draggingPiece.correctX;draggingPiece.y = puzzleArea.y + draggingPiece.correctY;draggingPiece.fixed = true;} else {// 放回操作区Object.assign(draggingPiece, randomPosition());}} else {// 放回操作区Object.assign(draggingPiece, randomPosition());}drawPuzzle();draggingPiece = null;// 检测是否完成拼图if (pieces.every(p => p.fixed)) {setTimeout(() => {alert(`恭喜你完成了${currentLevel === 'easy' ? '初级' : currentLevel === 'medium' ? '中级' : currentLevel === 'hard' ? '高级' : '不规则形状'}难度的拼图!🎉`);}, 300);}
});
7.2 触摸屏支持
// 触摸开始
canvas.addEventListener("touchstart", (e) => {e.preventDefault();const touch = e.touches[0];const mouseEvent = new MouseEvent("mousedown", {clientX: touch.clientX,clientY: touch.clientY});canvas.dispatchEvent(mouseEvent);
});// 触摸移动
canvas.addEventListener("touchmove", (e) => {e.preventDefault();if (!draggingPiece) return;const touch = e.touches[0];const mouseEvent = new MouseEvent("mousemove", {clientX: touch.clientX,clientY: touch.clientY});canvas.dispatchEvent(mouseEvent);
});// 触摸结束
canvas.addEventListener("touchend", (e) => {e.preventDefault();const mouseEvent = new MouseEvent("mouseup", {});canvas.dispatchEvent(mouseEvent);
});
7.3 提示功能
function showHint() {if (!gameStarted) return;// 找出未固定的碎片const unfixedPieces = pieces.filter(p => !p.fixed);if (unfixedPieces.length === 0) {alert("所有碎片已经完成!");return;}// 随机选择一个未固定的碎片作为提示hintPiece = unfixedPieces[Math.floor(Math.random() * unfixedPieces.length)];// 更新画面drawPuzzle();// 5秒后取消提示setTimeout(() => {hintPiece = null;drawPuzzle();}, 5000);
}
7.4 切换难度
function changeDifficulty(level) {currentLevel = level;// 更新按钮样式document.querySelectorAll('.difficulty-btn').forEach(btn => {if (btn.dataset.level === level) {btn.classList.add('active');} else {btn.classList.remove('active');}});// 重新生成拼图generatePieces();
}
8. 辅助函数
8.1 点是否在多边形内
function isPointInPolygon(x, y, polygon, offsetX, offsetY) {let inside = false;for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {let xi = polygon[i].x + offsetX, yi = polygon[i].y + offsetY;let xj = polygon[j].x + offsetX, yj = polygon[j].y + offsetY;let intersect = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);if (intersect) inside = !inside;}return inside;
}
8.2 判断碎片是否在正确位置附近
function isPieceNearCorrectPosition(piece) {const distanceThreshold = 60; // 吸附距离阈值const targetX = puzzleArea.x + piece.correctX;const targetY = puzzleArea.y + piece.correctY;return (Math.abs(piece.x - targetX) < distanceThreshold && Math.abs(piece.y - targetY) < distanceThreshold);
}
8.3 生成随机位置
const randomPosition = () => {return {x: operationArea.x + Math.random() * (operationArea.width - 200),y: Math.random() * (operationArea.height - 200)};
};
9. 优化和改进
-
碎片边缘处理:通过计算边界框并添加适当的边距,确保锯齿部分的图像正确渲染。
-
背景遮罩:使用半透明遮罩使背景变暗,让拼图碎片更加突出。
-
磁性吸附:当碎片接近正确位置时,会有轻微的吸引力引导用户。
-
视觉反馈:提供碎片放置正确时的边框颜色变化,以及接近正确位置时的辅助线。
-
图像裁剪精度:通过计算精确的图像源区域,确保每个碎片显示正确的图像部分。
10. 总结
这个拼图游戏通过HTML5 Canvas实现了一个具有现代交互体验的拼图游戏。核心技术包括:
- Canvas绘图API用于渲染游戏界面
- 复杂的多边形生成算法创建锯齿形状
- 图像裁剪和绘制确保碎片显示正确图像
- 鼠标/触摸事件处理实现拖放功能
- 算法判断点是否在多边形内以处理交互
游戏还具有响应式设计,支持多种难度级别,以及辅助功能如提示和磁性吸附,提供了良好的用户体验。
相关文章:
从零开始打造HTML5拼图游戏:一个Canvas实战项目
从零开始打造HTML5拼图游戏:一个Canvas实战项目 先看效果: 你是否曾经被那些精美的网页拼图游戏所吸引?用 HTML5 的 Canvas 技术,从零开始,教你怎么画图、处理鼠标事件,还有游戏的核心逻辑,…...
每日一题洛谷P8649 [蓝桥杯 2017 省 B] k 倍区间c++
P8649 [蓝桥杯 2017 省 B] k 倍区间 - 洛谷 (luogu.com.cn) #include <iostream> #include <vector> using namespace std; #define int long long signed main() {int n, k;cin >> n >> k;vector<int> a(n 1);vector<int> sum(n 1);vec…...
在uniapp中,video比普通的标签层级高解决问题
<view style"position: relative;"><video style"position: absolute;z-index:-1"></video><view style"position: absolute;z-index:999"></view> </view> 上面代码并没有解决view的层级比video高的问题&…...
《探索边缘计算:重塑未来智能物联网的关键技术》
最近研学过程中发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击链接跳转到网站人工智能及编程语言学习教程。读者们可以通过里面的文章详细了解一下人工智能及其编程等教程和学习方法。下面开始对正文内容的…...
Linux(十二)信号
今天我们就要来一起学习信号啦!!!还记得小编在之前的文章中说过的ctrlc吗?之前小编没有详细介绍过,现在我们就要来学习啦!!! 一、信号的基本介绍 首先,小编带领大家先一…...
LeetCode算法题(Go语言实现)_30
题目 给定单链表的头节点 head ,将所有索引为奇数的节点和索引为偶数的节点分别组合在一起,然后返回重新排序的列表。 第一个节点的索引被认为是 奇数 , 第二个节点的索引为 偶数 ,以此类推。 请注意,偶数组和奇数组内…...
无线通信技术(三):5G NR通信频带划分与应用场景
目录 一.5G NR频带划分概述 二.全球运营商5G频带分配对比 三.5G频带的应用场景 5G网络的发展离不开频谱资源的合理分配。不同的频段决定了5G的覆盖范围、传输速率和应用场景。本文将系统介绍5G NR频带划分,并结合实际应用场景,理解不同频段的特性及其适用环境。 …...
【读书笔记·VLSI电路设计方法解密】问题61:扫描插入的目的是什么
如问题60所述,要构建可测试电路,必须确保电路中每个节点都具有可控性和可观测性。但对于包含时序元件(如触发器、锁存器等存储元件)的电路,若不采取特殊设计则难以实现这两项特性。这是因为时序元件关联节点的逻辑状态不仅取决于当前输入,还受其先前存储状态影响——它们…...
VirtualBox安装FnOS
1.下载FnOS镜像 下载网址: https://www.fnnas.com/2.创建虚拟机 虚拟机配置如图所示(注意操作系统类型和网卡配置) (注意启动顺序) 3.启动虚拟机 网卡类型选择桥接的Virtual Adapter 如果没有IP地址或者IP地址无法…...
Pycharm(十二)列表练习题
一、门和钥匙 小X在一片大陆上探险,有一天他发现了一个洞穴,洞穴里面有n道门, 打开每道门都需要对应的钥匙,编号为i的钥匙能用于打开第i道门, 而且只有在打开了第i(i>1)道门之后,才能打开第i1道门&#…...
集合与容器:List、HashMap(II)
一、ArrayList 是集合框架中最核心的动态数组实现,高频使用的容器之一。 1. 核心数据结构 基于数组实现,维护elementData数组存储元素: transient修饰的elementData不会被默认序列化(通过自定义序列化逻辑优化存储)…...
Eclipse 视图(View)
Eclipse 视图(View) Eclipse 视图(View)是 Eclipse 界面的重要组成部分,它提供了用户交互的平台,使得用户可以通过图形界面来编辑、调试、分析代码等。在本文中,我们将深入探讨 Eclipse 视图的功能、使用方法以及它们在软件开发中的作用。 1. 视图的功能 Eclipse 视图具…...
《AI大模型应知应会100篇》第3篇:大模型的能力边界:它能做什么,不能做什么
第3篇:大模型的能力边界:它能做什么,不能做什么 摘要 在人工智能飞速发展的今天,大语言模型(LLM)已经成为许多领域的核心技术。然而,尽管它们展现出了惊人的能力,但也有明显的局限性…...
Springboot----@Role注解的作用
Role(BeanDefinition.ROLE_INFRASTRUCTURE) 是 Spring 框架中的一个注解,用于显式标记 Bean 的角色,表明该 Bean 是 Spring 容器内部的基础设施组件(如后置处理器、工具类等),而非用户直接使用的业务 Bean。其核心作用…...
小程序API —— 58 自定义组件 - 创建 - 注册 - 使用组件
目录 1. 基本介绍2. 全局组件3. 页面组件 1. 基本介绍 小程序目前已经支持组件化开发,可以将页面中的功能模块抽取成自定义组件,以便在不同的页面中重复使用;也可以将复杂的页面拆分成多个低耦合的模块,有助于代码维护࿱…...
前端页面鼠标移动监控(鼠标运动、鼠标监控)鼠标节流处理、throttle、限制触发频率(setTimeout、clearInterval)
文章目录 使用lodashjs库手动实现节流(通过判断之前设定的定时器setTimeout是否存在) 使用lodashjs库 <!DOCTYPE html> <html lang"zh-CN"><head><meta charset"UTF-8"><meta http-equiv"X-UA-Com…...
在 Android Studio 中运行安卓应用到 MuMu 模拟器
一、准备工作 1、确保 MuMu 模拟器已正确安装并启动 从官网下载安装最新版 MuMu 模拟器。启动后,建议在设置中调整性能参数(如 CPU 核心数和内存分配),以保证流畅运行。 2、配置 Android Studio 环境(按…...
从文本到多模态:如何将RAG扩展为支持图像+文本检索的增强生成系统?
目录 从文本到多模态:如何将RAG扩展为支持图像文本检索的增强生成系统? 一、为什么需要扩展到多模态? 二、多模态 RAG 系统的基本架构 三、关键技术点详解 (一)多模态嵌入(Embedding)技术 …...
【JavaEE】网络原理详解
1.❤️❤️前言~🥳🎉🎉🎉 Hello, Hello~ 亲爱的朋友们👋👋,这里是E绵绵呀✍️✍️。 如果你喜欢这篇文章,请别吝啬你的点赞❤️❤️和收藏📖📖。如果你对我的…...
Python项目-基于Flask的个人博客系统设计与实现(2)
源代码 续 {% extends base.html %}{% block title %}评论管理{% endblock %}{% block content %} <div class"container py-4"><div class"row"><div class"col-md-3"><div class"list-group mb-4"><a h…...
洛谷题单3-P1720 月落乌啼算钱(斐波那契数列)-python-流程图重构
题目描述 给定一个整数 N N N,请将该数各个位上数字反转得到一个新数。新数也应满足整数的常见形式,即除非给定的原数为零,否则反转后得到的新数的最高位数字不应为零(参见样例 2)。 输入格式 一个整数 N N N。 …...
NOIP2013提高组.华容道
题目 509. 华容道 算法标签: 搜索, b f s bfs bfs, s p f a spfa spfa 思路 不难发现, 在人移动的过程中, 箱子是不动的, 从当前位置到下一个箱子旁边的位置不会移动箱子, 可以预处理出人在每个位置到其他位置的距离预处理, 从某一个状态出发, 走到另一个状态的最短路使…...
政安晨【超级AI工作流】—— 基于COZE探索有趣的主题互动问答工作流(同宇宙儿童提问机)
政安晨的个人主页:政安晨 欢迎 👍点赞✍评论⭐收藏 希望政安晨的博客能够对您有所裨益,如有不足之处,欢迎在评论区提出指正! 本例,我们将从零展示如何创建一款专门针对儿童对某项主题进行问答的对话流智能体…...
Derivatives and Differentiation (导数和微分)
Derivatives and Differentiation {导数和微分} 1. Derivatives and Differentiation (导数和微分)1.1. Visualization Utilities 2. Chain Rule (链式法则)3. DiscussionReferences For a long time, how to calculate the area of a circle remained a mystery. Then, in Anc…...
P17_ResNeXt-50
🍨 本文为🔗365天深度学习训练营 中的学习记录博客🍖 原作者:K同学啊 一、模型结构 ResNeXt-50由多个残差块(Residual Block)组成,每个残差块包含三个卷积层。以下是模型的主要结构࿱…...
Ubuntu上离线安装ELK(Elasticsearch、Logstash、Kibana)
在 Ubuntu 上离线安装 ELK(Elasticsearch、Logstash、Kibana)的完整步骤如下: 一.安装验证 二.安装步骤 1. 在联网机器上准备离线包 (1) 安装依赖工具 #联网机器 sudo apt update sudo apt install apt-rdepends wget(2) 下载 ELK 的 .deb 安装包 #创建目录将安装包下载…...
PyCharm 下载与安装教程:从零开始搭建你的 Python 开发环境
PyCharm 是一款专为 Python 开发设计的集成开发环境(IDE),它提供了强大的代码编辑、调试、版本控制等功能,是 Python 开发者的必备工具之一。如果你是初学者,或者正在寻找一款高效的开发工具,这篇文章将帮助…...
TSMaster在新能源汽车研发测试中的硬核应用指南
——从仿真到标定,全面赋能智能汽车开发 引言:新能源汽车测试的挑战与TSMaster的破局之道 新能源汽车的快速发展对研发测试提出了更高要求:复杂的电控系统、高实时性通信需求、多域融合的验证场景,以及快速迭代的开发周期。传统测…...
C/C++的条件编译
一、什么是条件编译? 条件编译是指在编译阶段根据某些条件来决定是否编译某段代码。这通常通过预处理指令来实现,比如 #if、#ifdef、#ifndef、#else、#elif 和 #endif。 二、为什么使用条件编译? 跨平台开发:不同的操作…...
使用 requests 和 BeautifulSoup 解析淘宝商品
以下将详细解释如何通过这两个库来实现按关键字搜索并解析淘宝商品信息。 一、准备工作 1. 安装必要的库 在开始之前,确保已经安装了 requests 和 BeautifulSoup 库。如果尚未安装,可以通过以下命令进行安装: bash pip install requests…...
