从零开始打造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 技术,从零开始,教你怎么画图、处理鼠标事件,还有游戏的核心逻辑,…...
【数据分享】2000—2024年我国乡镇的逐年归一化植被指数(NDVI)数据(年最大值/Shp/Excel格式)
之前我们分享过2000-2024年我国逐年的归一化植被指数(NDVI)栅格数据,该逐年数据是取的当年月归一化植被指数(NDVI)的年最大值!另外,我们基于此年度栅格数据按照行政区划取平均值,得到…...
设计模式 Day 2:工厂方法模式(Factory Method Pattern)详解
继 Day 1 学习了单例模式之后,今天我们继续深入对象创建型设计模式——工厂方法模式(Factory Method)。工厂方法模式为对象创建提供了更大的灵活性和扩展性,是实际开发中使用频率极高的一种设计模式。 一方面,我们将简…...
TensorFlow SegFormer 实战训练代码解析
一、SegFormer 实战训练代码解析 SegFormer 是一个轻量级、高效的语义分割模型,结合了 ViT(视觉 Transformer) 和 CNN 的高效特征提取能力,适用于边缘 AI 设备(如 Jetson Orin)。下面,我们深入…...
51c嵌入式~单片机~合集7~※
我自己的原文哦~ https://blog.51cto.com/whaosoft/13692314 一、芯片工作的心脏--晶振 在振荡器中采用一个特殊的元件——石英晶体,它可以产生频率高度稳定的交流信号,这种采用石英晶体的振荡器称为晶体振荡器,简称晶振。 制作方法 …...
私有知识库 Coco AI 实战(一):Linux 平台部署
Coco AI 是一个完全开源、跨平台的统一搜索和生产力工具,能够连接各种数据源,包括应用程序、文件、Google Drive、Notion、Yuque、Hugo 等,帮助用户快速智能地访问他们的信息。通过集成 DeepSeek 等大型模型,Coco AI 实现了智能个…...
大模型高质量rag构建:A Cheat Sheet and Some Recipes For Building Advanced RAG
原文:A Cheat Sheet and Some Recipes For Building Advanced RAG — LlamaIndex - Build Knowledge Assistants over your Enterprise DataLlamaIndex is a simple, flexible framework for building knowledge assistants using LLMs connected to your enterpris…...
LeetCode 78.子集
问题描述 给定一个不含重复元素的整数数组 nums,返回其所有可能的子集(幂集)。 示例 输入: nums [1,2,3] 输出: [ [], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3] ]解法:回溯算法 回溯是一种 暴力…...
变量(Variable)
免责声明 如有异议请在评论区友好交流,或者私信 内容纯属个人见解,仅供学习参考 如若从事非法行业请勿食用 如有雷同纯属巧合 版权问题请直接联系本人进行删改 前言 提示:从小学解方程变量x,到中学阶段函数自变量x因变量y&…...
【STM32】最后一刷-江科大Flash闪存-学习笔记
FLASH简介 STM32F1系列的FLASH包含程序存储器、系统存储器和选项字节三个部分,通过闪存存储器接口(外设)可以对程序存储器和选项字节进行擦除和编程,(系统存储器用于存储原厂写入的BootLoader程序,用于串口…...
Dify 深度集成 MCP实现灾害应急响应
一、架构设计 1.1 分层架构 #mermaid-svg-5dVNjmixTX17cCfg {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-5dVNjmixTX17cCfg .error-icon{fill:#552222;}#mermaid-svg-5dVNjmixTX17cCfg .error-text{fill:#552222…...
2025 年上半年软考信息系统项目管理师备考计划
2025 年上半年软考信息系统项目管理师备考计划 2025 年上半年软考信息系统项目管理师考试时间为 5 月 24 日 - 27 日,从现在开始备考,需合理规划,高效学习。以下为详细备考计划: 一、基础学习阶段(现在 - 4 月上…...
Scikit-learn使用指南
1. Scikit-learn 简介 定义: Scikit-learn(简称 sklearn)是基于 Python 的开源机器学习库,提供了一系列算法和工具,用于数据挖掘、数据预处理、分类、回归、聚类、模型评估等任务。特点: 基于 NumPy、SciP…...
学习大模型需要具备哪些技术、知识和基础
数学基础 概率论与数理统计:用于理解模型中的不确定性、概率分布,以及进行数据的统计分析、评估模型的性能等。例如,通过概率分布来描述模型预测结果的可信度,利用统计方法对数据进行抽样、估计模型的参数等。线性代数࿱…...
十五届蓝桥杯省赛Java B组(持续更新..)
目录 十五届蓝桥杯省赛Java B组第一题:报数第二题:类斐波那契数第三题:分布式队列第四题:食堂第五题:最优分组第六题:星际旅行第七题:LITS游戏第八题:拼十字 十五届蓝桥杯省赛Java B…...
Flink SQL Client bug ---datagen connector
原始sql语句如下 CREATE TABLE test_source (event_time TIMESTAMP(3), -- 事件时间(精确到毫秒)click INT, -- 随机数值字段WATERMARK FOR event_time AS event_time - INTERVAL 5 SECOND WITH (connector datagen, …...
股指期货的多头套期保值是什么意思?
多头套期保值,又叫“买入套期保值”,听起来很复杂,其实很简单。它的核心就是“提前锁定价格,防止未来价格上涨”。 举个例子,假设你是一家工厂的老板,过几个月要买一批原材料。现在原材料的价格是100元/吨…...
hadoop集群配置-scp命令
scp 命令用于在不同主机之间复制文件或目录,在Hadoop集群配置中常用于将配置文件或相关资源分发到各个节点。以下是 scp 命令的基本用法和在Hadoop集群配置中的示例: 基本语法 scp [-r] [源文件或目录] [目标用户目标主机:目标路径] - -r :…...
Redis 源码硬核解析系列专题 - 结语:从源码看Redis的设计哲学
1. 引言 通过前七篇的源码解析,我们从Redis的整体架构、核心数据结构、事件驱动模型,到内存管理、持久化、主从复制与集群模式,逐步揭开了Redis高性能与简洁性的秘密。本篇将总结这些技术细节,提炼Redis的设计哲学,并探讨如何将源码学习成果应用到实际开发中。 2. Redis的…...
解决QSharedPointer栈变量的崩溃问题
目录 参考崩溃代码现象 解决 参考 QSharedPointer的陷阱 qt中的共享指针,QSharedPointer类 崩溃 代码 #include <QtCore/QCoreApplication> #include <QDebug> #include <QSharedPointer>class MyClass { public:void doSomething() {qDebug…...
Lambda 表达式是什么以及如何使用
目录 📌 Kotlin 的 Lambda 表达式详解 🎯 什么是 Lambda 表达式? 🔥 1. Lambda 表达式的基本语法 ✅ 示例 1:Lambda 基本写法 ✅ 示例 2:使用 it 关键字(单参数简化) ✅ 示例 3…...
C++自定义迭代器
实现自己的迭代器 最近在写数据结构,使用类模板实现,碰到了一些问题,其中有一个就是遍历的问题,查阅资料最后实现了自己的迭代器,让我实现的数据结构能像STL一样进行for循环遍历。 类的构成 #include <stdexcept…...
PWA 中的 Service Worker:如何实现应用离线功能
前言 在当今快速发展的互联网时代,Progressive Web App (PWA) 正在逐步成为现代 Web 开发的主流选择。PWA 将 Web 应用和原生应用的最佳特性相结合,提供了丰富的用户体验。而在 PWA 的众多技术中,Service Worker 无疑是其核心组件之一。 作…...
dockerfile制作镜像
1.docker pull centos:centos7 2.dockerfile内容 FROM centos:centos7 #指定镜像维护的作者和邮箱 MAINTAINER csdn< **********qq.com #设置环境变量mypath ENV MYPATH /usr/local #设置进入容器的默认目录是/usr/local WORKDIR $MYPATH # 下载并替换 CentOS 镜像源 RUN …...
网络空间安全(46)DevSecOps概述
一、定义与核心理念 DevSecOps是“开发(Development)、安全(Security)和运营(Operations)”的结合,它将安全实践融入软件开发生命周期的每个阶段,从需求、设计、开发、测试到部署和运…...
LeetCode 211
实现支持通配符的字典树(Trie):解决单词匹配问题 一、问题描述 我们需要设计一个数据结构,支持以下功能: 添加新单词搜索字符串是否与任何已添加的单词匹配,其中搜索字符串可能包含通配符 .(…...
Docker Compose 启动jar包项目
参考文章安装Docker和Docker Compose 点击跳转 配置 创建一个文件夹存放项目例如mydata mkdir /mydata上传jar包 假设我的jar包名称为goudan.jar 编写dockerfile文件 vim app-dockerfile按键盘上的i进行编辑 # 使用jdk8 FROM openjdk:8-jre# 设置时区 上海 ENV TZAsia/Sh…...
利用deepseek直接调用其他文生图网站生成图片
这次deepseek输入中文后,其实翻译英文后,是可以丢到比如pollinations.这个网站,来生成图片,用法如下: 你是一个图像生成助手,请根据我的简单描述,想象并详细描述一幅完整的画面。 然后将你的详…...
远程装个Jupyter-AI协作笔记本,Jupyter容器镜像版本怎么选?安装部署教程
通过Docker下载Jupyter镜像部署,输入jupyter会发现 有几个版本,不知道怎么选?这几个版本有什么差别? 常见版本有: jupyter/base-notebookjupyter/minimal-notebookjupyter/scipy-notebookjupyter/datascience-notebo…...
11. 盛最多水的容器
leetcode Hot 100系列 文章目录 一、核心操作二、外层配合操作三、核心模式代码总结 一、核心操作 最左右两边逐步往中间走,每次在左右中选取小的一个或–记录最大面积 提示:小白个人理解,如有错误敬请谅解! 二、外层配合操作…...
Selenium Web自动化如何快速又准确的定位元素路径,强调一遍是元素路径
如果文章对你有用,请给个赞! 匹配的ChromeDriver和浏览器版本是更好完成自动化的基础,可以从这里去下载驱动程序: 最全ChromeDriver下载含win linux mac 最新版本134.0.6998.165 持续更新..._chromedriver 134-CSDN博客 如果你问…...
Kotlin 基础语法解析
详细的 Kotlin 基础语法解析,结合概念说明和实用场景: --- ### **一、变量与常量** #### **1. 变量类型** - **val**(不可变变量):声明后不可重新赋值,类似 Java 的 final。 kotlin val name "Kotl…...
html 列表循环滚动,动态初始化字段数据
html <div class"layui-row"><div class"layui-col-md4"><div class"boxall"><div class"alltitle">超时菜品排行</div><div class"marquee-container"><div class"scroll-…...
【大模型基础_毛玉仁】5.4 定位编辑法:ROME
目录 5.4 定位编辑法:ROME5.4.1 知识存储位置1)因果跟踪实验2)阻断实验 5.4.2 知识存储机制5.4.3 精准知识编辑1)确定键向量2)优化值向量3)插入知识 5.4 定位编辑法:ROME 定位编辑:…...
Using SAP an introduction for beginners and business users
Using SAP an introduction for beginners and business users...
Android学习总结之RecyclerView补充篇
在 Android 开发中,列表数据更新的性能一直是关键痛点。传统的 notifyDataSetChanged() 会触发全量刷新,导致不必要的界面重绘。而 DiffUtil 作为 Android 提供的高效差异计算工具,能精准识别数据变化,实现局部更新,成…...
mapbox基础,使用geojson加载cluster聚合图层
👨⚕️ 主页: gis分享者 👨⚕️ 感谢各位大佬 点赞👍 收藏⭐ 留言📝 加关注✅! 👨⚕️ 收录于专栏:mapbox 从入门到精通 文章目录 一、🍀前言1.1 ☘️mapboxgl.Map 地图对象1.2 ☘️mapboxgl.Map style属性1.3 ☘️circle点图层样式二、🍀使用geojson加…...
函数:static和extern
0.前言 在正式开始之前先说作用域和生命周期 作用域: 作用域有分为局部变量和全局变量 局部变量:一个变量仅在其中一段代码内起作用 全局变量:所有的代码都可以使用这个变量 生命周期: 生命周期是一个代码从运行开始到结束…...
【QT】练习1
1、设计一个颜色选择器,可以输入RGB的颜色值,点击确认,可以把主界面的背景颜色改成设置的颜色 修改背景颜色:setStyleSheet(“background-color 红绿蓝颜色值”); // mainwindow.cpp #include "mainwindow.h" #include…...
GreenPlum学习
简介 Greenplum是一个面向数据仓库应用的关系型数据库,因为有良好的体系结构,所以在数据存储、高并发、高可用、线性扩展、反应速度、易用性和性价比等方面有非常明显的优势。Greenplum是一种基于PostgreSQL的分布式数据库,其采用sharednothi…...
张量-pytroch基础(2)
张量-pytroch网站-笔记 张量是一种特殊的数据结构,跟数组(array)和矩阵(matrix)非常相似。 张量和 NumPy 中的 ndarray 很像,不过张量可以在 GPU 或其他硬件加速器上运行。 事实上,张量和 Nu…...
Linux多线程编程的艺术:封装线程、锁、条件变量和信号量的工程实践
目录 📌这篇博客能带给你什么? 🔥为什么需要封装这些组件? 一、线程封装 框架设计 构造与析构 1.线程创建 2.线程分离 3.线程取消 4.线程等待 二、锁封装 框架设计 构造与析构 1.加锁 2.解锁 3.RAII模式 三、条件…...
2025年智慧能源与控制工程国际学术会议(SECE 2025)
官网:www.ic-sece.com 简介 2025年智慧能源与控制工程国际学术会议(SECE 2025)将于2025年4月18日线上会议形式召开,这是一个集中探讨全球智慧能源和控制工程领域创新和挑战的国际学术平台。旨在汇集全球领域内的学者、研究人员、…...
Android 16开发实战指南|锁屏交互+Vulkan优化全解析
一、环境搭建与项目初始化 1. 安装Android Studio Ladybug 下载地址:Android Studio官网关键配置: # 安装后立即更新SDK SDK Manager → SDK Platforms → 安装Android 16 (Preview) SDK Manager → SDK Tools → 更新Android SDK Build-Tools至34.0.0 # 通过命令行安装SDK组…...
sscanf() 用法详解
sscanf() 是 scanf() 的变体,它用于从字符串中提取格式化数据,常用于解析输入字符串。 1️⃣ sscanf() 语法 int sscanf(const char *str, const char *format, ...); str:要解析的字符串(必须是 const char*,可以用…...
0基础入门scrapy 框架,获取豆瓣top250存入mysql
一、基础教程 创建项目命令 scrapy startproject mySpider --项目名称 创建爬虫文件 scrapy genspider itcast "itcast.cn" --自动生成 itcast.py 文件 爬虫名称 爬虫网址 运行爬虫 scrapy crawl baidu(爬虫名) 使用终端运行太麻烦了,而且…...
Linux常见操作命令(2)
(一)复制和移动 复制和移动都分为文件和文件夹,具体的命令是cp和mv。 1.复制文件(复制的文件要是已创建) 格式:cp 源文件 目标文件。 示例:把filel.txt复制一份得到file2.txt。 那么对应的命令就是&#x…...
谷歌将 Android OS 完全转变为 “内部开发”
2025 年 3 月 27 日,据 Android Authority 报道,谷歌证实将从下周开始完全在内部分支机构闭门开发安卓操作系统。相关信息如下: 背景:多年来,谷歌同时维护着两大安卓主要分支,一是面向公众开放的 “安卓开源…...
移动端六大语言速记:第2部分 - 控制结构
移动端六大语言速记:第2部分 - 控制结构 本文继续对比Java、Kotlin、Flutter(Dart)、Python、ArkTS和Swift这六种移动端开发语言的控制结构,帮助开发者快速掌握各语言的语法差异。 2. 控制结构 2.1 条件语句 各语言条件语句的语法对比: …...
【 Vue 2 中的 Mixins 模式】
Vue 2 中的 Mixins 模式 在 Vue 2 里,mixins 是一种灵活的复用代码的方式,它能让你在多个组件间共享代码。借助 mixins,你可以把一些通用的选项(像 data、methods、computed 等)封装到一个对象里,然后在多…...