leader甩了我一张图,大概是这样,正好最近在学习
canvas
,决定用canvas实现下,顺便实操下理论知识,以下是我的踩坑合集,为各位初学者提供一点点🤏思路
设计思路
- 食物从顶部沿直线掉落,位置X随机,掉落至最下面时,食物消失;
- 签子随鼠标移动而移动;
- 当食物碰到签子顶部时,食物不再向下运动,出现在签子最底部;
食物随机掉落
单个食物随机出现
首先我们准备好食物的小切图,利用drawImage
画出来
html:
<canvas id="canvasGame"></canvas>
js:
var myCanvas = document.getElementById('canvasGame');
var ctx = myCanvas.getContext('2d');
var w = myCanvas.width = window.innerWidth,
h = myCanvas.height = window.innerHeight;
var img=new Image();
img.src='./food1.png';
// drawImage要在图片加载之后
img.onload=function(){
ctx.drawImage(img,0,0,img.width/3,img.height/3);
}
drawImage
方法允许在canvas
中插入其他元素(img
或canvas元素
)
共有三种语法:
ctx.drawImage(image, dx, dy);
ctx.drawImage(image, dx, dy, dWidth, dHeight);
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
sx
:(可选)需要绘制到目标上下文中的,image
的矩形(裁剪)选择框的左上角 X 轴坐标。sy
:(可选)需要绘制到目标上下文中的,image
的矩形(裁剪)选择框的左上角 Y 轴坐标。sWidth
:(可选)需要绘制到目标上下文中的,image
的矩形(裁剪)选择框的宽度。如果不说明,整个矩形(裁剪)从坐标的sx
和sy
开始,到image
的右下角结束。sHeight
:(可选)需要绘制到目标上下文中的,image
的矩形(裁剪)选择框的高度。dx
:image
的左上角在目标canvas上 X 轴坐标。dy
:image
的左上角在目标canvas上 Y 轴坐标。dWidth
:image
在目标canvas上绘制的宽度。 允许对绘制的image
进行缩放。 如果不说明, 在绘制时image
宽度不会缩放。dHeight
:image
在目标canvas上绘制的高度。 允许对绘制的image
进行缩放。 如果不说明, 在绘制时image
高度不会缩放。
此时得到一个画出来的图片,出现时候X坐标应该是随机的,Y坐标为0,同时食物图片也是随机的
var myCanvas = document.getElementById('canvasGame');
var ctx = myCanvas.getContext('2d');
var w = myCanvas.width = window.innerWidth,
h = myCanvas.height = window.innerHeight,
foodX=Math.floor(Math.random() * (w - 40));//X坐标在0~w之间随机取,减40以防食物显示不全
var img=new Image();
var num=Math.ceil(Math.random() * 3);//以随机数来取食物
img.src=`./food${num}.png`;
console.log(img)
img.onload=function(){
ctx.drawImage(img,foodX,0,img.width/3,img.height/3);
}
此时食物出现时,位置随机,图片随机
食物掉落
食物掉落,即食物垂直运动,此时X坐标
不变,Y坐标
线性增加,需不断重绘图片,同时擦除之前绘制的图片达到运动的效果。
var myCanvas = document.getElementById('canvasGame');
var ctx = myCanvas.getContext('2d');
var w = myCanvas.width = window.innerWidth,
h = myCanvas.height = window.innerHeight,
foodX=Math.floor(Math.random() * (w - 40));
var img=new Image();
var num=Math.ceil(Math.random() * 3);
img.src=`./food${num}.png`;
img.onload=function(){
ctx.drawImage(img,foodX,0,img.width/3,img.height/3);
}
var foodY=0;
function trans(){
ctx.clearRect(foodX,foodY,img.width/3,img.height/3);//擦除之前画的食物
foodY+=1;//掉落速度可调节
ctx.drawImage(img,foodX,foodY,img.width/3,img.height/3);//重新画
if(foodY<h){
window.requestAnimationFrame(trans)
}
}
trans();
这样,我们就完成了食物随机掉落,且到最下面之后,食物消失
多个食物同时掉落
食物的动画分成两步:生成——运动,多个食物时,我们需要将其封装起来。因为每个食物的生成和运动都是分开的,如果同时调用同一个函数,很多变量会造成干扰,由此我想到把他们封装到原型里。
代码:
var myCanvas = document.getElementById('canvasGame');
var ctx = myCanvas.getContext('2d');
var w = myCanvas.width = window.innerWidth;
var h = myCanvas.height = window.innerHeight;
var count = 0,
foods = [];
var Food = function() {
this.foodX = 0;
this.foodY = 0;
this.src = '';
this.img = {};
this.num = 0,
count++;
foods[count] = this;
}
Food.prototype.draw = function(val) {
this.num = Math.ceil(Math.random() * 3) + 1;
this.img = new Image();
this.src = `./canvasGame/food${this.num}.png`;
this.img.src = this.src;
this.foodX = Math.floor(Math.random() * (w - 40));
var _this = this;
this.img.onload = function() {
ctx.beginPath();
ctx.drawImage(_this.img, _this.foodX, _this.foodY, _this.img.width / 3, _this.img.height / 3);
_this.trans();
}
}
Food.prototype.trans = function() {
this.img.src = this.src;
var _this = this;
this.img.onload = function() {
ctx.clearRect(_this.foodX, _this.foodY, _this.img.width / 3, _this.img.height / 3);
_this.foodY += 1;//速度可改
ctx.beginPath();
ctx.drawImage(_this.img, _this.foodX, _this.foodY, _this.img.width / 3, _this.img.height / 3);
}
if (this.foodY < h) {
window.requestAnimationFrame(function() {
_this.trans();
});
}
}
var times = setInterval(function() {
new Food();
foods[count].draw(count)
if (count > 20) {//可以改变控制食物生成的条件
clearInterval(times)
}
}, 1000)
其中:
Food
里面包含了每个食物私有的一些属性- 原型上包含两个方法
draw()
和trans()
- 计时器来生成食物,
draw()
完成之后调用trans()
,在trans()
中循环调用自身
⚠️注意:
1 ctx.clearRect
清除上一个绘制的图片放在图片onload
完成后,否则有的图片清除不掉;
2 每个图片是单独的Image对象,因此将new Image()
生成之后的对象和src
也放在Food
属性中,否则会出现下一个食物出现后影响之前食物的运动;
3 运动的逻辑,清除
——foodY++
——drawImage()
效果:
签子的运动
画签子
画签子的时候,我想了两种方法,一是canvas
绘画,二是html
中画,在此我选择了后者,但还是把两种方法都写出来(这里的签就是一条线,样式问题后续再优化)
1、canvas
绘画
这里用到的就是canvas
中最基础的绘制直线
方法:
moveTo(x,y)
:画笔🖌️起点lineTo(x,y)
:画笔🖌️终点strokeStyle
:线段颜色🎨lineWidth
:线段粗细stroke()
:画线
//绘制一条从(0,0)到(0,200)的线(默认宽1px)
var stickCanvas=document.createElement('canvas');
var stickCtx=stickCanvas.getContext('2d');
stickCtx.moveTo(0,0);
stickCtx.lineTo(0,200);
stickCtx.strockStyle="#000";
stickCtx.stroke();
ctx.drawImage(stickCanvas,0,0,200,200);//绘制在之前的canvas画布上
哦,对了,这种先绘制canvas
图形,再通过drawImage
绘制在画布上就是离屏技术,能很好解决canvas的性能问题。
此时得到一条线,下一步我们需要让签子随鼠标的运动而动,给canvas
增加一个监听事件,通过获取事件的坐标位置再绘制新的元素
var oldX=0,oldY=0;
// 监听touch
myCanvas.addEventListener("touchmove" , function(e){
var stickX=e.targetTouches[0].clientX,
stickY=e.targetTouches[0].clientY;
ctx.clearRect(oldX,oldY,2,200);
ctx.drawImage(stickCanvas,stickX,stickY,200,200);

oldX=stickX;
oldY=stickY;
})
这样就可以得到一条随鼠标运动的线😂
但是加上食物就不乐观了,这也是我最后放弃这种方法的原因,
clearRect
清除时会清除掉整个画布此位置的东西,因此食物运动刷新时,会把线擦除掉,(canvas
默认最新绘制的图形在最上方),或许可以用两个canvas
画布。
2、html
中绘制
在html
中绘制就简单多了,给div
设置宽高。唯一注意的是,如果想让其他html
元素在canvas
上层,只需给canvas
设置position: relative;
,其他元素设置position: absolute;
即可。
<div class="canvasDiv">
<canvas id="canvasGame"></canvas>
<div id="stickDiv"></div>
</div>
.canvasDiv {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
}
#canvasGame {
position: relative;
}
#stickDiv {
width: 2px;
height: 200px;
background-color: #000;
position: absolute;
top: 20px;
left: 20px;
z-index: 2;
}
签子随鼠标运动,这里我们直接监听window
的事件,来改变签子定位。
var stickDom = document.getElementById("stickDiv");
var stickX = -1,
stickY = -1;
window.addEventListener("touchmove", function(e) {
stickX = e.targetTouches[0].clientX;
stickY = e.targetTouches[0].clientY;
stickDom.style.left = stickX + 'px';
stickDom.style.top = stickY + 'px';
})
签子与食物建立联系
首先实现签子碰到食物时,食物消失,他们之间的联系只能通过位置信息,即stickX与stickY
是否与食物重叠。这里我想到canvas
里一个很好用的方法☝️——isPointInPath()
。
用法:判断传入的某个点(x,y)是否在当前所规划的路径内。
boolean ctx.isPointInPath(x, y);
boolean ctx.isPointInPath(x, y, fillRule);
boolean ctx.isPointInPath(path, x, y);
boolean ctx.isPointInPath(path, x, y, fillRule);
(x,y)
:传入的点的坐标fillRule
:可取两个值,nozero
|evenodd
nozero
:非零环绕原则
从点引出任意一条射线,与路径交点结果计算,如果计数不为0,那么此点就在路径范围里面,在调用fill()方法时,浏览器就会对其进行填充。如果最终值是0,那么此区域就不在路径范围内,浏览器就不会对其进行填充。
某一个方向为+1
,则相反方向为-1
evenodd
奇偶环绕原则
平面内的任何一点P,引出一条射线,注意不要经过多边形的顶点,如果射线与多边形的交点的个数为奇数,则点P在多边形的内部;如果交点的个数为偶数,则点P在多边形的外部。
代码:
var Food = function() {
this.foodX = 0;
this.foodY = 0;
this.src = '';
this.img = {};
this.num = 0,
this.isTouch = false,
count++;
foods[count] = this;
}
Food.prototype.draw = function(val) {
var n = Math.ceil(Math.random() * 3) + 1;
this.img = new Image();
this.src = `./canvasGame/food${n}.png`;
this.img.src = this.src;
this.foodX = Math.floor(Math.random() * (w - 40));
this.num=val;
var _this = this;
this.img.onload = function() {
ctx.beginPath();
ctx.drawImage(_this.img, _this.foodX, _this.foodY, _this.img.width / 3, _this.img.height / 3);
ctx.rect(_this.foodX, _this.foodY, _this.img.width / 3, _this.img.height / 3);
ctx.fillStyle='rgba(0,0,0,0)';
ctx.fill();
_this.trans();
}
}
Food.prototype.trans = function() {
this.img.src = this.src;
var _this = this;
this.img.onload = function() {
ctx.clearRect(_this.foodX, _this.foodY, _this.img.width / 3, _this.img.height / 3);
_this.isTouch = ctx.isPointInPath(stickX, stickY)
if (!_this.isTouch) {
_this.foodY += 1;
ctx.beginPath();
ctx.drawImage(_this.img, _this.foodX, _this.foodY, _this.img.width / 3, _this.img.height / 3);
ctx.rect(_this.foodX, _this.foodY, _this.img.width / 3, _this.img.height / 3);
ctx.fill();
if (_this.foodY < h) {
window.requestAnimationFrame(function() {
_this.trans();
});
}
}
}
}
- 我们之前封装的对象和方法中,新加入
isTouch
属性来存储是否触碰。上面代码中会看到drawImage
之后画了一个同样大小的rect
,原因是isPointInPath()
方法判断的是路径,因此在图片上方覆盖一个透明的矩形框,实际判断的是某一点是否在这个矩形框里。 - 当
isTouch
为true
时,不再调用drawImage
方法,并且不再运行动画requestAnimationFrame
。
然后就会发现一个新的问题,当触碰到时整个屏幕里的食物都消失了,这个问题困扰了我一下午,原因是
isPointInPath()
方法只会判断最新绘制的路径,当竹签碰到食物时,ctx.isPointInPath(stickX, stickY)
为true
,尽管isTouch
属性是每个食物的私有属性,但此时,每一个trans()
里的isTouch
都为true
,就会造成所有食物都消失。
最终我放弃了这种方法,用最原始的位置判断,判断鼠标移动的坐标点是否在图片的内部(这里还有一点问题就是,实际上判断的范围仍是一个矩形而非图片真实的路径,不过不影响)
if(stickX>=_this.foodX&&stickX<=_this.foodX+_this.img.width / 3&&stickY>=_this.foodY&&stickY<=_this.foodY+_this.img.height / 3){
_this.isTouch=true;
}
食物消失后串在签子上
接下来来到最后一步,将消失的食物串到签子上。这个时候食物肯定是仍要画出来的,但是如果仍在原来的画布上画,当食物下落被擦除时就会同时擦除签子上的图片。由此,新建一个canvas画布来放签子上的食物,层级关系应该是,最下层掉落的食物——签子——签子上的食物。 html:
<div class="canvasDiv">
<canvas id="canvasGame"></canvas>
<div id="stickDiv"></div>
<canvas id="canvasKabob"></canvas>
</div>
js:
var canvasKabob = document.getElementById('canvasKabob');
var kabobCtx = canvasKabob.getContext('2d');
canvasKabob.width = window.innerWidth;
canvasKabob.height = window.innerHeight;
function kabob(x, y) {
canvasKabob.width = canvasKabob.width;
for (var i in kabobList) {
(function(i) {
var imgKabob = new Image();
imgKabob.src = kabobList[i].src;
imgKabob.onload = function() {
var w = imgKabob.width / 3,
h = imgKabob.height / 3;
kabobCtx.beginPath();
kabobCtx.drawImage(imgKabob, x - w / 2, y + 140 - (h - 10) * i, w, h);
}
})(i)
}
}
kabobList
存放签子碰到的食物,每当签子碰到食物和签子移动时,都调用kabob()
。canvasKabob.width = canvasKabob.width
用来清空画布。使用闭包在循环中是因为imgKabob.onload
的异步性,否则只会渲染列表中最后一个图片。
可以看到已经基本实现了需求,但是很明显在签子移动时图片闪烁,因为清空画布后,图片的
onload
需要时间,在视觉上就会出现闪烁的情况。
优化: 将画布缩小至烤串大小,移动时利用画布移动而不是图片重绘。
在移动签子时无需重绘食物,只要改变定位即可(此时不需要清空画布)
后续优化:
1、食物消失时添加动画过渡效果;
2、当食物大于签子长度时,换下一只签子;
3、规则优化:只判断了签子的顶点,签子只要碰到即可;