canvas入门笔记|食物掉落小游戏设计思路&踩坑记录

996 阅读3分钟

画板备份 18@3x.png leader甩了我一张图,大概是这样,正好最近在学习canvas,决定用canvas实现下,顺便实操下理论知识,以下是我的踩坑合集,为各位初学者提供一点点🤏思路

设计思路

  1. 食物从顶部沿直线掉落,位置X随机,掉落至最下面时,食物消失;
  2. 签子随鼠标移动而移动;
  3. 当食物碰到签子顶部时,食物不再向下运动,出现在签子最底部;

食物随机掉落

单个食物随机出现

首先我们准备好食物的小切图,利用drawImage画出来

image.png 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中插入其他元素(imgcanvas元素
共有三种语法:

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的矩形(裁剪)选择框的宽度。如果不说明,整个矩形(裁剪)从坐标的sxsy开始,到image的右下角结束。
  • sHeight:(可选)需要绘制到目标上下文中的,image的矩形(裁剪)选择框的高度。
  • dximage的左上角在目标canvas上 X 轴坐标。
  • dyimage的左上角在目标canvas上 Y 轴坐标。
  • dWidthimage在目标canvas上绘制的宽度。 允许对绘制的image进行缩放。 如果不说明, 在绘制时image宽度不会缩放。
  • dHeightimage在目标canvas上绘制的高度。 允许对绘制的image进行缩放。 如果不说明, 在绘制时image高度不会缩放。

image.png 此时得到一个画出来的图片,出现时候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);
    }

image.png 此时食物出现时,位置随机,图片随机

食物掉落

食物掉落,即食物垂直运动,此时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();

屏幕录制2021-12-14 14.55.00.gif 这样,我们就完成了食物随机掉落,且到最下面之后,食物消失

多个食物同时掉落

食物的动画分成两步:生成——运动,多个食物时,我们需要将其封装起来。因为每个食物的生成和运动都是分开的,如果同时调用同一个函数,很多变量会造成干扰,由此我想到把他们封装到原型里。
代码:

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()

效果:

屏幕录制2021-12-14 15.13.20.gif

签子的运动

画签子

画签子的时候,我想了两种方法,一是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);

![屏幕录制2021-12-16 17.10.09.gif](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9a867dcf814a4dbba95c97932b443d09~tplv-k3u1fbpfcp-watermark.image?)
	oldX=stickX;
	oldY=stickY;

})

这样就可以得到一条随鼠标运动的线😂

屏幕录制2021-12-16 17.08.19.gif 但是加上食物就不乐观了,这也是我最后放弃这种方法的原因,clearRect清除时会清除掉整个画布此位置的东西,因此食物运动刷新时,会把线擦除掉,(canvas默认最新绘制的图形在最上方),或许可以用两个canvas画布。

屏幕录制2021-12-16 17.10.09.gif

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 image.png

evenodd奇偶环绕原则

平面内的任何一点P,引出一条射线,注意不要经过多边形的顶点,如果射线与多边形的交点的个数为奇数,则点P在多边形的内部;如果交点的个数为偶数,则点P在多边形的外部。

image.png

代码:

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()方法判断的是路径,因此在图片上方覆盖一个透明的矩形框,实际判断的是某一点是否在这个矩形框里。
  • isTouchtrue时,不再调用drawImage方法,并且不再运行动画requestAnimationFrame

屏幕录制2021-12-16 17.45.58.gif 然后就会发现一个新的问题,当触碰到时整个屏幕里的食物都消失了,这个问题困扰了我一下午,原因是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;
}

屏幕录制2021-12-16 17.53.38.gif

食物消失后串在签子上

接下来来到最后一步,将消失的食物串到签子上。这个时候食物肯定是仍要画出来的,但是如果仍在原来的画布上画,当食物下落被擦除时就会同时擦除签子上的图片。由此,新建一个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的异步性,否则只会渲染列表中最后一个图片。

屏幕录制2021-12-17 10.58.48.gif 可以看到已经基本实现了需求,但是很明显在签子移动时图片闪烁,因为清空画布后,图片的onload需要时间,在视觉上就会出现闪烁的情况。

优化: 将画布缩小至烤串大小,移动时利用画布移动而不是图片重绘。

image.png 在移动签子时无需重绘食物,只要改变定位即可(此时不需要清空画布)

屏幕录制2021-12-17 15.31.50.gif

后续优化: 1、食物消失时添加动画过渡效果;
2、当食物大于签子长度时,换下一只签子;
3、规则优化:只判断了签子的顶点,签子只要碰到即可;