Canvas实现二娃翠花回家之路小游戏demo解析
作者:forrest酱
一、玩法介绍
Canvas是HTML5中的一个非常有用的技术,它可以用于实现各种图形化效果。本文将介绍使用Canvas实现的小游戏——“二娃翠花回家之路”。这个小游戏非常有趣,玩家需要通过绘制角色的行走路线来控制他们的行动,并避免他们相撞。
- 玩家需要通过绘制二娃和翠花的行走路线,来控制他们的行动。
- 二娃和翠花需要分别回到各自的房子才能过关。
- 玩家需要避免二娃和翠花的行走的路上发生碰撞,否则游戏失败。
- 玩家可以使用“开始”按钮开始游戏,使用“重置”按钮重新开始游戏。
二、预览效果
三、开发难点
在实现“二娃翠花回家之路”小游戏的过程中,我遇到了如下几个技术难点:如何绘制路径不被页面刷新影响、如何计算两条路线交叉点最相近坐标距离、如何判断碰撞、如何计算人物的移动速度和步长。
- 绘制路线:在Canvas中,通过监听鼠标事件获取鼠标的坐标,并根据鼠标的移动轨迹来绘制路径。在绘制路线时需要保存路径的坐标,以便于后续的操作。需要考虑多个角色之间的交互,页面刷新函数调用时机影响着路径的绘制。
- 计算距离:需要计算二娃和翠花之间的距离,以及二娃和翠花与路线之间的距离。距离的计算需要使用勾股定理:
- 需要注意单位的转换和精度的控制。
- 判断碰撞:需要在角色行走时,判断角色与另一个角色的距离是否小于一定的阈值。如果小于阈值,则需要根据一定的几率避免碰撞,或者直接暂停游戏并提示失败。需要考虑多个角色之间的相互作用。
- 人物移动速度和步长计算:需要计算人物与目标点之间的距离,以及人物的速度,计算出人物的移动步长。需要注意人物在路径交叉点位置碰撞的问题。这里我的解决方案是:计算两条路径的最近的两个坐标(因为交叉点不一定存在坐标,这是canvas绘制线条存在的可能性),然后拿到这两个坐标,找出原路径起点到该坐标的路径保存起来,再计算路径长度,得到长度用公式v=s/t的到速度v。
💡 划重点: 针对人物移动速度和步长计算。我的实现方案虽然可以得到两个人物的各自独立的移动速度,但是仍然无法保证人物在路径交叉点位置碰撞,这里我暂时没有好的解决方案,希望各位掘友读者们,能把代码fork过去,帮忙解决这个问题,后在评论区,附上你的解决方案。
四、核心实现步骤
1、创建画布和按键元素
使用HTML和JavaScript来创建了一个画布和两个按键元素。首先创建了一个HTML文件,然后在其中添加了一个画布和两个按键。然后使用JavaScript来获取画布和按键元素,并设置了它们的属性和事件监听器。最后为画布创建了一个绘图环境,并在画布上绘制了两个人物和他们的家。代码实现在本文第四部分。
2、创建人物类
创建一个人物类character
,并在其中实现绘制角色draw()
、绘制家drawHouse()
、计算路径总长度calculatePathLength(def_path)
这里def_path
是为了后面找到两条路线交叉点最相近坐标到各自路线起点的路线备用的。计算两个坐标的距离distance(x1,y1,x2,y2)
、人物移动move()
等方法。通过这些方法,我们可以实现人物的移动和路径的划分。在实现路径划分时,我们可以通过计算路径总长度,将路径划分成若干个点,使角色在这些点之间移动。代码实现在本文第四部分。
3、创建两个人物实例
创建两个人物实例,并设置它们的属性和方法。我们将为每个人物实例设置起点、终点和当前位置,姓名和颜色属性。在移动时判断碰撞。
const A_Axis = [10, canvas.height - 20, canvas.width - 55, 10]; const B_Axis = [canvas.width - 10, canvas.height - 20, 5, 10]; const A = new Character('(红)二娃', ...A_Axis, 'red'); const B = new Character('(蓝)翠花', ...B_Axis, 'blue');
4、监听鼠标事件
在 Canvas 元素上监听鼠标事件,并根据鼠标的移动轨迹来绘制路径。我们将使用鼠标事件监听器来获取鼠标的坐标,并使用 Canvas API 来绘制路径。在绘制路线时需要保存路径的坐标,以便于后续的操作。
//在鼠标事件中会调用该方法绘制路径 function drawPath(path, color, width) { ctx.beginPath(); ctx.moveTo(path[0].x, path[0].y); for (let i = 1; i < path.length; i++) { ctx.lineTo(path[i].x, path[i].y); } ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.stroke(); ctx.closePath() } //事件监听 canvas.addEventListener('mousedown', handleMouseDown); canvas.addEventListener('mousemove', handleMouseMove); canvas.addEventListener('mouseup', handleMouseUp); resetBtn.addEventListener('click', resetGame); startBtn.addEventListener('click', startGame);
5、绘制路径
根据绘制好的路径,将路径按照一定的长度划分成若干个点。这些点可以作为人物移动的目标位置。然后,我们可以在每个点上计算出人物应该移动的目标位置,从而实现人物的移动。
在 Canvas 中,可以使用 moveTo
和 lineTo
方法来绘制路径。使用 stroke
方法来绘制路径。可以设定 lineWidth
和 strokeStyle
属性来设置路径的颜色和宽度。
算法部分,可以使用距离阈值来判断两个点之间的距离是否超过了阈值。如果超过了阈值,我们就将路径划分成两部分,分别计算出每个部分的长度。然后,选择较短的路径作为人物移动的路径。这样可以避免人物走过太多的弯路,从而增加游戏的流畅度。
//核心部分 //...... //...... if (var_distance > DISTANCE_THRESHOLD) { this.x += dx / var_distance * speed; this.y += dy / var_distance * speed; } else { this.x = target.x; this.y = target.y; this.path.shift(); //...... //...... }
6、判断碰撞
在角色行走时,判断角色与另一个角色的距离是否小于一定的阈值。如果小于阈值,则需要根据一定的几率避免碰撞,或者直接暂停游戏并提示失败。需要考虑多个角色之间的相互作用。
//核心部分 //...... if (this.path.length > 0 && this.distance(target.x, target.y, this === A ? B.x : A.x, this === A ? B.y : A.y) < COLLISION_THRESHOLD) { const avoidCollision = Math.random() < COLLISION_AVOIDANCE_RATE; // 以一定的几率避免碰撞 if (avoidCollision) { this.path.splice(0, 1); // 直接移动到下个点位 } else { gameStatus = GAME_PAUSE_STATUS; alert(`${this === A ? A.uname : B.uname} 碰到了对方,游戏失败`); } }
7、开始和重置游戏
实现开始和重置游戏的功能,包括重置路径、重置人物位置等。当游戏开始时,需要计算两个人物最短的路径,并将其保存到对应的路径数组中。当游戏结束时,将人物位置重置,并清空路径数组和绘制的路径。
function resetGame() { A.x = A_Axis[0]; A.y = A_Axis[1]; A.path = []; A.moving = false; A.total_distance = 0; B.x = B_Axis[0]; B.y = B_Axis[1]; B.path = []; B.moving = false; B.total_distance = 0; drawing = false; path = []; gameStatus = GAME_LOOPING_STATUS; init(); } function startGame() { update(); if (!A.path.length || !B.path.length) { alert('请先绘制人物回家路线'); return; } let close_coord = getClosestCoords(A.path, B.path); console.log('得出的路径:', ...close_coord) let A_def_path = getRoute(close_coord[0], A.path); let B_def_path = getRoute(close_coord[1], B.path); let A_def_path_len = A.calculatePathLength(A_def_path); let B_def_path_len = B.calculatePathLength(B_def_path); let A_B_def_path_max_len = Math.max(A_def_path_len, B_def_path_len); console.log('最长的是', A_def_path_len, B_def_path_len, '--->', A_B_def_path_max_len) A.total_distance = A_def_path_len; B.total_distance = B_def_path_len; drawing = false; path = []; A.moving = true; B.moving = true; }
8、实现动画效果
动画效果的实现主要是通过 requestAnimationFrame 方法来实现的。requestAnimationFrame 是一个用来优化动画效果的方法,可以让动画更流畅自然。具体地,requestAnimationFrame 方法会在下一帧动画之前调用一个回调函数,以便于更新动画效果。在这个回调函数中,可以实现人物的移动、路径的绘制等等,从而达到动画效果。
动画效果的实现主要在人物类(Character)中。每个人物都有自己的动画状态和动画参数,包括位置、速度、目标位置等等。在每一帧的动画中,都会根据当前位置和目标位置之间的距离来计算移动的距离和速度,并且不断更新人物的位置和状态。这个过程中,使用了一些基本的数学计算,比如计算两点之间的距离、计算两点之间的角度等等。
function update() { if (!A.path.length && !B.path.length) { gameStatus = GAME_PAUSE_STATUS; ctx.clearRect(0, 0, canvas.width, canvas.height); A.drawHouse(); B.drawHouse(); A.draw(true); B.draw(true); } if (gameStatus === GAME_LOOPING_STATUS) { ctx.clearRect(0, 0, canvas.width, canvas.height); A.drawHouse(); B.drawHouse(); A.draw(); B.draw(); A.path.length && drawPath(A.path, DRAW_LINE_COLOR, DRAW_LINE_WIDTH); B.path.length && drawPath(B.path, DRAW_LINE_COLOR, DRAW_LINE_WIDTH); A.moving && A.move(); B.moving && B.move(); } requestId = requestAnimationFrame(update); }
五、完整的代码实现
下面是实现该游戏的完整代码,这里着重强调几个关键函数,在代码中找到对应的**注释**
,这跟我在上面文章第二部分划重点的提问有关哦!。
主要的涉及的逻辑是,每个人物类有一个属性total_distance
用于存储距离,当用户点击开始回家按钮后,在startGame
方法里会计算两个人物回家路径最近的两个坐标点,并获得这两个坐标所在路径到所在路径起点的路径坐标数组,然后计算该新路径的长度保存到total_distance
中,在人物移动方法move()
中会计算人物移动速度和人物移动步长。
1. 页面布局
页面的布局相对简单,基本就是一个画布+按钮。当然想要游戏体验更好,可以更改页面布局,增加更多互动元素。
<!DOCTYPE html> <html> <head> <title>二娃、翠花的回家之路</title> </head> <body> <h1>二娃和翠花的回家之路</h1> <div class="tool_bar"> <span>红方:二娃</span> <span>蓝方:翠花</span> <div> 攻略:鼠标绘制二娃、翠花的回家之路,不要让他们相撞哦!<br />他们回家的速度看心情哦 </div> </div> <canvas id="canvas" width="600" height="500"></canvas> <div> <button id="startBtn">开始回家</button> <button id="resetBtn">重新开始</button> </div> <script src="game.js"></script> </body> </html>
2. 页面css样式
样式的话,相信不用讲太多,大家一看就知道了。
body { text-align: center; } canvas { border: 1px solid gray; border-radius: 5px; box-shadow: 0 0 20px 0px #ccc; margin: 10px 0 5px 0px; } #startBtn, #resetBtn { background-color: #4caf50; border: none; color: white; padding: 15px 32px; text-align: center; text-decoration: none; display: inline-block; font-size: 16px; margin: 4px 2px; cursor: pointer; } #resetBtn { background-color: red; } .tool_bar span { background-color: blue; color: white; padding: 2px 3px; margin: 0 5px; border-radius: 5px; } .tool_bar span:first-child { background-color: red; }
3. js代码 (有点长,耐心点哈😀)
这部分代码着实有点长,需要点耐心来阅读,利用代码中的注释或者变量和方法名称来辅助理解。本来不想贴完整代码的,但是,为了保证大家能够更好的理解这个游戏,还是贴上来吧,相信可以第一时间阅读完整代码的感觉还是挺好的😮。
// 游戏状态 const GAME_LOOPING_STATUS = 'looping'; const GAME_PAUSE_STATUS = 'pause'; // 绘制路线的颜色和宽度 const DRAW_LINE_COLOR = 'deepskyblue'; const DRAW_LINE_WIDTH = 8; // 碰撞的阈值 const COLLISION_THRESHOLD = 30; // 碰撞避免的概率 const COLLISION_AVOIDANCE_RATE = 0.001; // 路径点之间的距离阈值 const DISTANCE_THRESHOLD = 1.5; let requestId = null,//动画控制句柄 //画布、按元素节点 const canvas = document.querySelector('#canvas'); const ctx = canvas.getContext('2d'); const startBtn = document.querySelector('#startBtn'); const resetBtn = document.querySelector('#resetBtn'); //人物类 class Character { path = []; oving = false; total_distance = 0; moveTime = 500; constructor(uname, x, y, houseX, houseY, color) { this.uname = uname; this.x = x; this.y = y; this.houseX = houseX; this.houseY = houseY; this.color = color; } //绘制角色 draw(isInit = false) { ctx.beginPath(); let h = isInit ? 20 : Math.random() * 30; let w = isInit ? 10 : Math.random() * 15; ctx.moveTo(this.x, this.y); ctx.lineTo(this.x - w, this.y + h); ctx.lineTo(this.x + w, this.y + h); ctx.closePath(); ctx.fillStyle = this.color; ctx.fill(); ctx.closePath() } //绘制角色的家 drawHouse() { ctx.beginPath(); ctx.arc(this.houseX + 25, this.houseY + 25, 25, 0, 2 * Math.PI); ctx.fillStyle = 'white'; ctx.fill(); ctx.strokeStyle = this.color; ctx.lineWidth = 5; ctx.stroke(); ctx.closePath() } // 计算路径总长度 calculatePathLength(def_path) { let path = this.path if (Array.isArray(def_path) && def_path.length > 0) { path = def_path } let length = 0; for (let i = 1; i < path.length; i++) { length += this.distance(path[i].x, path[i].y, path[i - 1].x, path[i - 1].y); } return length; } //计算两个坐标的距离 distance(x1, y1, x2, y2) { const dx = x1 - x2; const dy = y1 - y2; return Math.sqrt(dx * dx + dy * dy); } //人物移动 move() { if (this.path.length === 0) { this.moving = false; return; } const target = this.path[0]; const dx = target.x - this.x; const dy = target.y - this.y; const var_distance = this.distance(target.x, target.y, this.x, this.y); const speed = this.total_distance / this.moveTime; if (var_distance > DISTANCE_THRESHOLD) { this.x += dx / var_distance * speed; this.y += dy / var_distance * speed; } else { this.x = target.x; this.y = target.y; this.path.shift(); if (this.path.length > 0 && this.distance(target.x, target.y, this === A ? B.x : A.x, this === A ? B.y : A.y) < COLLISION_THRESHOLD) { const avoidCollision = Math.random() < COLLISION_AVOIDANCE_RATE; // 以一定的几率避免碰撞 if (avoidCollision) { this.path.splice(0, 1); // 直接移动到下个点位 } else { gameStatus = GAME_PAUSE_STATUS; alert(`${this === A ? A.uname : B.uname} 碰到了对方,游戏失败`); } } } } } const A_Axis = [10, canvas.height - 20, canvas.width - 55, 10]; const B_Axis = [canvas.width - 10, canvas.height - 20, 5, 10]; const A = new Character('(红)二娃', ...A_Axis, 'red'); const B = new Character('(蓝)翠花', ...B_Axis, 'blue'); let gameStatus = GAME_LOOPING_STATUS; let drawing = false; let path = []; //绘制路径 function drawPath(path, color, width) { ctx.beginPath(); ctx.moveTo(path[0].x, path[0].y); for (let i = 1; i < path.length; i++) { ctx.lineTo(path[i].x, path[i].y); } ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.stroke(); ctx.closePath() } //计算两条路径最近的两个坐标点 function getClosestCoords(arr1, arr2) { let minDistance = Number.MAX_VALUE; let closestCoords = []; for (let i = 0; i < arr1.length; i++) { for (let j = 0; j < arr2.length; j++) { const distance = Math.sqrt( Math.pow(arr1[i].x - arr2[j].x, 2) + Math.pow(arr1[i].y - arr2[j].y, 2) ); if (distance < minDistance) { minDistance = distance; closestCoords = [arr1[i], arr2[j]]; } } } return closestCoords; } //获取坐标点到路径起点的路径数组 function getRoute(coord, targetRoute) { let res = []; for (let i = 0; i < targetRoute.length; i++) { res.push(targetRoute[i]) if (targetRoute[i].x === coord.x && targetRoute[i].y === coord.y) { return res; } } } //鼠标按下事件句柄 function handleMouseDown(event) { if (event.target.id === 'canvas') { if (A.distance(event.offsetX, event.offsetY, A.x, A.y) < COLLISION_THRESHOLD) { drawing = true; path.push({ x: A.x, y: A.y }); } else if (A.distance(event.offsetX, event.offsetY, B.x, B.y) < COLLISION_THRESHOLD) { drawing = true; path.push({ x: B.x, y: B.y }); } } } //鼠标移动事件句柄 function handleMouseMove(event) { if (drawing) { path.push({ x: event.offsetX, y: event.offsetY }); drawPath(path, DRAW_LINE_COLOR, DRAW_LINE_WIDTH); } } //鼠标松开事件句柄 function handleMouseUp(event) { if (drawing) { if (A.distance(event.offsetX, event.offsetY, A.houseX + 25, A.houseY + 25) < 35) { path.push({ x: A.houseX + 25, y: A.houseY + 25 }); drawPath(path, DRAW_LINE_COLOR, DRAW_LINE_WIDTH); A.path = path; } else if (A.distance(event.offsetX, event.offsetY, B.houseX + 25, B.houseY + 25) < 35) { path.push({ x: B.houseX + 25, y: B.houseY + 25 }); drawPath(path, DRAW_LINE_COLOR, DRAW_LINE_WIDTH); B.path = path; } path = []; drawing = false; } } //重置游戏 function resetGame() { A.x = A_Axis[0]; A.y = A_Axis[1]; A.path = []; A.moving = false; A.total_distance = 0; B.x = B_Axis[0]; B.y = B_Axis[1]; B.path = []; B.moving = false; B.total_distance = 0; drawing = false; path = []; gameStatus = GAME_LOOPING_STATUS; init(); } //开始游戏 function startGame() { update(); if (!A.path.length || !B.path.length) { alert('请先绘制人物回家路线'); return; } let close_coord = getClosestCoords(A.path, B.path); console.log('得出的路径:', ...close_coord) let A_def_path = getRoute(close_coord[0], A.path); let B_def_path = getRoute(close_coord[1], B.path); let A_def_path_len = A.calculatePathLength(A_def_path); let B_def_path_len = B.calculatePathLength(B_def_path); let A_B_def_path_max_len = Math.max(A_def_path_len, B_def_path_len); console.log('最长的是', A_def_path_len, B_def_path_len, '--->', A_B_def_path_max_len) A.total_distance = A_def_path_len; B.total_distance = B_def_path_len; drawing = false; path = []; A.moving = true; B.moving = true; } //刷新游戏界面 function update() { if (!A.path.length && !B.path.length) { gameStatus = GAME_PAUSE_STATUS; ctx.clearRect(0, 0, canvas.width, canvas.height); A.drawHouse(); B.drawHouse(); A.draw(true); B.draw(true); } if (gameStatus === GAME_LOOPING_STATUS) { ctx.clearRect(0, 0, canvas.width, canvas.height); A.drawHouse(); B.drawHouse(); A.draw(); B.draw(); A.path.length && drawPath(A.path, DRAW_LINE_COLOR, DRAW_LINE_WIDTH); B.path.length && drawPath(B.path, DRAW_LINE_COLOR, DRAW_LINE_WIDTH); A.moving && A.move(); B.moving && B.move(); } requestId = requestAnimationFrame(update); } //初始化 function init() { window.cancelAnimationFrame(requestId); ctx.clearRect(0, 0, canvas.width, canvas.height); A.drawHouse(); B.drawHouse(); A.draw(true); B.draw(true); } //事件监听 canvas.addEventListener('mousedown', handleMouseDown); canvas.addEventListener('mousemove', handleMouseMove); canvas.addEventListener('mouseup', handleMouseUp); resetBtn.addEventListener('click', resetGame); startBtn.addEventListener('click', startGame); init();
六、写在最后
Canvas开发游戏是一项充满趣味性和挑战性的任务。使用Canvas可以实现各种各样的游戏效果和交互方式,例如动画、碰撞检测、路径计算等等。通过开发游戏,可以锻炼我们自己的编程能力和解决问题的能力,提高自己的代码质量和效率。
在开发过程中,常常需要考虑游戏的用户体验和界面设计。例如,我们可以为游戏添加音效和动画效果,以增加游戏的趣味性和互动性。还可以为游戏添加计分板和排行榜,以便于玩家比较成绩和分享游戏。
以上就是Canvas实现二娃翠花回家之路小游戏demo解析的详细内容,更多关于Canvas回家之路小游戏的资料请关注脚本之家其它相关文章!