【老脸教你做游戏】从Canvas开始

本文不允许任何形式的转载!

阅读提示

本系列文章不适合以下人群阅读,如果你无意点开此文,请对号入座,以免浪费你宝贵的时间。

1、想要学习利用游戏引擎开发游戏的朋友。本文不会涉及任何第三方游戏引擎。

2、不具备面向对象编程经验的朋友。本文的语言主要是Javascript(ECMA 2016),如果你不具备JS编程经验倒也无妨,但没有面向对象程序语言经验就不好办了。

3、想要直接下载实例代码的朋友。抱歉,我都用嘴说,基本上没有示例代码。


Web浏览器端游戏到底是个什么玩意儿

实际上Web浏览器端游戏主要是利用Html5中的canvas标签作为画布,利用canvas提供的2d和webgl画笔(也就是context,有人叫它上下文)在canvas上进行绘制图形,通过定时刷新以及配合外部的输入来做到动画效果的这么一个过程。也有一些游戏是用css3配合HTML标签来到达游戏目的的。所以,Web浏览器端游戏也叫H5游戏(HTML5)。

这里多句嘴,微信小游戏虽然也是用JS(Javascript,以下统一用JS),但是跟浏览器端是不一样的,微信只是绑定了JS VM(所以说为什么微信小游戏小程序是没有DOM的),再去调用本地接口(应该是,我也是猜的),根据我的测试,比起直接在浏览器上运行效率高不少。我在微信上的小游戏(fps稳定在60)移植到FB上,FB上的在移动端根本就无法玩,fps只有20多,当然这些都是后话了,系列文章以后会讲到。

大概剧透一下这个系列文章,初期只是最基础的canvas 2d介绍讲解,中期讲面向对象的东西多一些,主要如何构建一套library去做游戏,你可以理解成游戏引擎开发,后期会将很多东西转到webgl上。在这过程中如果有可能我会配合视频讲解。

Canvas和Context

Canvas这个标签以前HTML是没有的,更早以前要想在页面上绘制东西的话要用到一个叫svg的标签,我曾经好像还写过,但是就接触了一两天,后来也不知道发展到哪儿去了。最重要的是,在没有HTML5的和HTML5出现的早期,浏览器上的图形这一块儿东西(包括视频),全部被Adobe控制了,是的,我也写过ActionScript代码,而且那个程序至今还在使用。再看看现在Adobe Flash,唏嘘不已。

现在开始, 你可以写一个简单的html文件,里面加入一个canvas标签,像这样:

简单的HTML


一片空白

在浏览器中你就什么都看不见,因为canvas上没有进行任何绘制操作,空白一片。但是如果我们在html到吗中加入一段js,如下:

绘制一个矩形

在浏览器中就能看到一个黑色的矩形方块。这是因为我在代码中加入了canvas的绘制代码所致。

代码我一点点讲,中间会插入一些很重要东西。

let ctx = main.getContext('2d'); // 获得canvas(id是main的那个)的2d画笔

这里的main就是在Html中定义的canvas,id是main,直接用main可以引用到它,当然有些人非要用document.getElementById去找到对应的DOM节点。

getContext方法是canvas带的,这个方法可以返回对应的画笔,参数我给出的是一个字符串 '2d',canvas返回给我一个CanvasRenderingContext2D的对象(下面简称ctx),我就可以利用这个对象来对canvas进行绘制操作了。(说得好像是废话,下面还有一堆废话)

ctx.beginPath();// 开始记录绘制路径  

ctx.rect(0,0,100,100);// 绘制了一个基于【0,0】坐标,高宽都是100像素的矩形    

ctx.closePath();// 停止记录绘制路径    

ctx.fillStyle='black';// 当前绘制状态的填充色    

ctx.fill();// 填充

ctx对象有很多绘制方法以及状态变化、保存、恢复等方法,它的作用就是让开发者能够在canvas上进行简单有效的绘制操作。

这里我首先是记录绘制路径,beginPath,这个方法会让ctx的当前路径重置,也就是说如果你之前还绘制有其他东西,调用了beginPath后,那些记录在当前路径里的东西都被清空了。看清楚哦,是当前路径。

这里提到的路径(Path)是什么捏,其实就是点到点之间的线段集合而已,一个路径可能有很多个线段组成,比如一个圆形,你可以想象成有很多很多距离很小的线段组成的。我写的这个正方形就是由四个线段组成的路径。

再看跟它对应的另一个方法,closePath,这个方法是关闭当前路径(废话)。

据我推断,它至少会有两个操作,一个是将当前路径的状态改成closed,就是说,现在所有在路径里的线是一个闭合的状态;另一个就是压入一个空的路径到路径栈中,即将当前路径设为一个空的路径。

后来我在查阅了规范后证实了我的猜测,但少了一点。closePath除了要压入一个新的Path到栈内外,还要将之前Path的起始点作为新Path的起始点。

如果你看到这里有点懵逼,不急不急,马上就安排得明明白白。

最后那个rect方法,很好理解,就是让画笔绘制一个矩形而已,坐标从【0,0】开始,绘制一个宽高为100像素的矩形,对不? 真是这样理解就错了,实际上rect是一个路径绘制方法,不能理解成“我要绘制一个矩形”,而是要理解成“我要生成一个可以围成矩形的路径并加入到路径栈中,而且还是tm闭合的那种”。(继续懵逼)

那ctx.fillStyle的设置就不多说了,就是设置一个填充色,ctx.fill就是利用当前填充色把上述绘制出来的图形进行填充。

CanvasRenderingContext2D的绘制过程

这里我把状态类方法放到后面说,所以,以下提到的操作都是在同一个状态下的。

如果你拿了根笔,让你画一个黑色正方形方块,你会怎么操作?一般就是先把这个正方形的四条边都画出来,然后再拿笔在这个框里使劲划呀划,把它涂成黑色,对吧。(如果有傻比非要用笔一个点一个点戳出一个正方形当我没说)。

这个ctx其实也是这么想的:先确定好某个图形的所有线段,然后再看看在对这些线段做什么操作,是要描边呢还是要在里面填充呢。

而现实中的图形可就不一定是简单的矩形咯。如果我们从最最基本的点和线来理解,更容易明白,我们假设有一个二维图形,由很多很多个线段组成的:

二维图形 = n条线段的集合

而线段是由两个点确定的,所以,二维图形 = n个【两个点】的有序集合:

二维图形 = (点1 到 点2)+ (点3 到 点4)+..........+(点m 到 点n)

我们刚才绘制的那个正方形,假设4个顶点是ABCD,那么 :

正方形 = (点A 到 点B)+(点B 到 点C)+(点C 到 点D)+(点D 到 点A)

ctx设计者刚好跟我们想法一致,他也觉得直接用点和点的组合就能解决复杂二维图形的描述问题。他就让ctx提供两个方法,一个来确定点的位置,另一个是如何把这些点连起来。

于是就有了 moveTo(x,y) 和 lineTo(x,y)方法,这两个方法的意思很好理解吧,一个是移动到(x,y)点,另外一个是将某个点和(x,y)连起来。

别急,moveTo方法到底什么意思,什么叫做“移动到某点”,什么叫做“连接某点”?

我假设有这么一个图形,这个图形就是我们的汉字 “中”,这个字拆开看是由5跟线段组成。想象一下你拿一根笔,在刚才那个100x100的canvas上写出这个中字,我们靠想象来分解一下整个动作过程:

1、先提起笔,确定落点位置在canvas中间顶部,因为要先画那根中间的竖线

2、然后落笔,一笔画到canvas的中间底部,那么中间那根竖线就完成了

3、接着又提起笔,找到新的落点,就是在canvas最左侧四分之一处

4、再次落笔,一根横线画到最右侧四分之一处,这就完成了中间那个“口”的上面根横线

5、这次不提笔找新的落笔点了,一根竖线往下画,画到canvas右侧4分之三处

6、再接着横着画到canvas最左侧四分之三

7、不提笔接着竖着画,回到刚才我们画“口”的起点

那么这个中字就画好了。

对应刚才的moveTo和lineTo,就好理解了,我们先要moveTo一个点,让这个点成为这次绘制线段的起点,然后再lineTo到另外一个点,那这跟线段就绘制完成了。如果lineTo到的那个点正好是另一个线段的起点呢,那就没必要再moveTo找起点了对吧?直接再lineTo就好了,就跟我们写“口”字一样,写完横再写竖的时候,根本没必要再提笔找落笔点。(别跟我说你都是一笔一笔写字的,杠精怪)

好了,我们这里将刚才写中字的过程用程序实现(替换之前写的那个矩形显示程序):

let  ctx=main.getContext('2d');

ctx.beginPath();

ctx.moveTo(50,0);

ctx.lineTo(50,100);

ctx.moveTo(0,25);

ctx.lineTo(100,25);

ctx.lineTo(100,75);

ctx.lineTo(0,75);

ctx.lineTo(0,25);

ctx.closePath();

ctx.strokeStyle='black';

ctx.stroke();

有图有真相:

像不像?

讲了这么多,无非是想让读者去理解一下ctx的绘图过程,就跟我们自然画图一样的,要找落点,要去连线,要填充,要描边等等。

回头说说刚才提到的路径(Path),其实就是一个二维图形的线段集合,由多个有序的点组成。再想想,我要画一个东西出来,不一定就只有一个图形吧,很有可能是一组图形,就跟上面那个中字一样,如果我们把中字看成一个独立图形,那么它是有两个Path组成的:一条竖着的线和一个矩形。那这两个Path又由另外一个栈进行维护。

下面要划重点 所以,我们认为ctx维护了一个或者多个Path栈(2d path stack),我画个图你理解一下:

就拿刚才画的那个中字来说,绘制它所对应的Path栈应该是这个样子的:

实际上在CanvasRenderingContext2D的规范中,比起我刚才说到的要复杂一点,我所提到的Path Stack在规范里是一个Path,而我提到的Path被称为SubPath,规范里的Path也是存在一个栈里的,每个Path维护了多个SubPath,SubPath里才是存放的坐标数据。不过我倒觉得差不太多,只是比我刚才所说的多了一个栈。你要觉得我说错了那我也没办法,反正我也懒得改

根据这个栈,再结合之前的代码,我们可以大胆进行推测,moveTo方法就是将一个新的path放入到Path栈中,并且将moveTo对应的点放入到这个新的path里作为起点:

function moveTo(x,y){

pathStack.push(new Path2D());

let cp = pathStack[pathStack.length - 1]; cp.push([x,y]);

}

回头再看看那个画中字的代码,当我们完成了对中字的所有Path的创建后(实际上我们并不知道什么Path,我们就知道连哪个点,哪个点又作为起点而已),接着调用了ctx.stroke方法,这个方法的意思就是说,对之前所描述的所有path进行描边。然后ctx就会将这个Path 栈中的所有path一个一个拿出来,将整个path里的点都按照顺序逐个画线连接,每个路径都重复同样的操作。

想想刚才让人懵逼的beginPath,它的操作就是清空当前Path里的所有点,重新开始,而closePath就是将当前path的closed设置为true,然后新加入一个path到栈内以改变当前path,并且把上一个path的起点拿出来作为新生成path的起点; 但是如果当前Path已经是closed的,那就不做操作直接退出方法。

为什么beginPath不是新创建一个Path放入到栈内呢,因为这是其规范规定好的,不信的可以去查一下Canvas2D规范。

function beginPath(){

let  cp=pathStack[pathStack.length-1];

cp.length=0;// 清空path内所有点

}

function   closePath(){

   let  cp=pathStack[pathStack.length-1];

if(!cp.closed){

        cp.closed=true;// 设置路径为闭合状态

        let    lastPathStart=cp.get(0);// 拿出这个path的起点坐标数据

        let    newPath=newPath2D();// 新建一个Path

        newPath.push(lastPathStart);// 新Path的起点是上一个Path的起点

        pathStack.push(newPath);// 把新path放入栈内

 }

}

注意注意! 代码是我猜的。

也就是说,如果我们在调用beginPath之前就已经调用过很多lineTo方法了,即当前Path里已经记录下了很多的点,但一旦我们调用beginPath,这些点就全没了。你要不信可以试试这段代码, 把刚才绘制中字的代码中,我们加入一行beginPath在closePath之前:

let ctx = main.getContext('2d');

ctx.beginPath();

ctx.moveTo(50,0);

ctx.lineTo(50,100);

ctx.lineTo(100,25);

ctx.lineTo(100,75);

ctx.lineTo(0,75);

ctx.lineTo(0,25);

ctx.beginPath();// 清空当前Path的所有点

ctx.closePath();

ctx.strokeStyle ='black';

ctx.stroke();

结果就是什么都没画出来。

有人会问,这个Path的闭合状态是什么意思啊,问得好。

路径Path里的点是将会顺序连接在一起的,如果一旦这个Path是一个closed状态,说明这个Path的最后一个点是要跟起始点连在一起的,比如我们下面有两段代码可以说明这个问题。

第一段代码,调用了closePath:

let ctx = main.getContext('2d');

ctx.beginPath();

ctx.moveTo(0,25);

ctx.lineTo(100,25);

ctx.lineTo(100,75);

ctx.lineTo(0,75);

ctx.closePath();

ctx.strokeStyle ='black';

ctx.stroke();


闭合矩形

第二段没有调用closePath:

let ctx = main.getContext('2d');

ctx.beginPath();

ctx.moveTo(0,25);

ctx.lineTo(100,25);

ctx.lineTo(100,75);

ctx.lineTo(0,75);

ctx.strokeStyle ='black';

ctx.stroke();


没有闭合的矩形

说了这么多,这下明白什么是Path什么是Path栈了吗?(规范里是说的SubPath和Path)

我们看看最开始的那个rect方法,其实呢,这个方法就是由moveTo,lineTo,closePath实现的,rect不会去调用beginPath,因为它不想让之前绘制的Path里的点都被清空,绝大多数绘制图形的方法都是这样的,但他想要绘制一个新的独立的矩形,只要调用一次moveTo就好了

function rect(left, top, width, height) {

let right = left + width;

let bottom = top + height;

ctx.moveTo(left,top);

ctx.lineTo(right,top);

ctx.lineTo(right,bottom);

ctx.lineTo(left,bottom);

ctx.closePath();

}

这样,我们的Path栈里就存在了一个独立的矩形的Path。

小结

JS的canvas 2d画笔其实很简单,只要你想象一下自己用笔画图写字的过程,就能明白这些方法的使用。我们在文中所说的Path,实际上在Canvas2D中是的确存在的,是Path2D类,可在实际编写代码的过程中我们很少用到它,但又有很多人对于beginPath等很懵逼,搞不清到底什么时候该调用什么时候又不能用,我想通过刚才讲得这些,应该多少能懂一些其中的道理了。

这一节中,我提到了Path栈的概念,其实这个栈的概念会贯穿整个canvas 2d,下一节我们说一下ctx的状态和状态栈。

可能这些东西离做出游戏还很远,但要有耐心,没耐心的人看到中间就已经关了。

作业

ctx有一个方法叫做arc(x,y,radius,startAngel, endAngel),这个方法是绘制一段弧形,参数分别的是指: 弧形的中心坐标x,弧形的中心坐标y,弧形的半径,弧形的起始弧度(不是度数,是弧度!),弧形的终止弧度。

比如我们在刚才那个100x100的画布上绘制 :

ctx.arc(50,50 ,50, 0 ,180*Math.PI/180) , 会得到一个半圆形:

let ctx = main.getContext('2d');

ctx.beginPath();

ctx.arc(50,50,50,0,180*Math.PI/180);

ctx.closePath();

ctx.strokeStyle ='black';

ctx.stroke();


如果让你就用moveTo,lineTo,beginPath和closePath,你会怎么去实现这个方法?

推荐阅读更多精彩内容