刚过完春节,想必大家收到了各种祝福和贺卡吧~Y某我今年也为同学和家人准备了贺卡。不一样的是,我的贺卡可不是made from树,而是一行行代码凝聚而来的。
考虑到本次开发需要的功能不多,所以就没有用库件了,利用纯Html5 Canvas API来完成本次梦幻之旅:节日贺卡。虽然用到的Canvas API不多,但是效果还是蛮理想的~
首先上截图吧:
哎呀,看到了截图,各位是不是领悟了传说中的炫酷华丽(luàn qī bā zāo)?
大家可以先到测试地址里体验一下玩法,顺便观察一下这些小正方形所组成的文字有什么特点。
每次写博客和大家分享技术的时候,我都会先把原理介绍给大家,因为这样一来,大家对下文中的代码理解起来就快多了。所以原理很重要,得作为第一个研究话题。
无论是在测试地址里还是截图中,都不难发现这些文字的特点:是由小正方形拼接而成的,如果说一个小方块是1px,那么这里的文字像不像像素游戏里的文字?
如何实现这样的文字呢?我们不妨先从画像素图说起。首先,我们知道图片都是由一像素一像素组成的,如果4px*4px的一张白色图片可以看成数组,那么这个数组可以表示为这样:
[ [#FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF], [#FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF], [#FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF], [#FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF] ]
如果把第一排第一列的那个像素涂成黑色,那么数组变成:
[ [#000000, #FFFFFF, #FFFFFF, #FFFFFF], [#FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF], [#FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF], [#FFFFFF, #FFFFFF, #FFFFFF, #FFFFFF] ]
为了简化开发,我们设黑色时数组里表示为true,白色为false,那么上述数组又变成:
[ [true, false, false, false], [false, false, false, false], [false, false, false, false], [false, false, false, false] ]
如果在这张图上画个11的字样,则数组可以表示为
[ [true, false, false, true], [true, false, false, true], [true, false, false, true], [true, false, false, true] ]
可见如果我们通过数组存取文字的各像素颜色,然后遍历这个数组来获取哪些点画黑色哪些点画白色,并根据得到的颜色在界面上绘制,就能得出想要的图案。本次开发的原理就是和此类似。在demo中,粒子(在这里定义为由多个小正方形组成带有拖尾或者正在原地旋转的一个显示对象)的颜色是我随机取出的,所以在数组里,我们只用记载哪里该有一个粒子,哪里不该有。然后在添加喷射出的粒子时,我们记录下点击的位置并在剩余需要有粒子的位置列表中随机找到这个粒子该到的位置,再随机取出一个颜色并按照该颜色调用Canvas API进行渲染即可。
在本次开发中,记载哪里该有粒子哪里该是空白的数组保存在list.js中(true表示有粒子,false表示没有粒子):
var list = [[false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false],[false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false],[false,false,true,false,false,false,true,false,false,false,false,false,true,false,false,false,false,false,true,true,true,true,true,false,true,true,true,true,true,false,true,true,false,false,false,true,false,false,false,false],[false,false,true,false,false,false,true,false,false,false,false,true,false,true,false,false,false,false,true,false,false,false,true,false,true,false,false,false,true,false,false,true,false,false,false,true,false,false,false,false],[false,false,true,false,false,false,true,false,false,false,true,false,false,false,true,false,false,false,true,false,false,false,true,false,true,false,false,false,true,false,false,true,false,false,false,true,false,false,false,false],[false,false,true,true,true,true,true,false,false,false,true,false,false,false,true,false,false,false,true,false,false,false,true,false,true,false,false,false,true,false,false,true,false,false,false,true,false,false,false,false],[false,false,true,false,false,false,true,false,false,true,true,true,true,true,true,true,false,false,true,true,true,true,true,false,true,true,true,true,true,false,false,true,true,true,true,true,false,false,false,false],[false,false,true,false,false,false,true,false,false,true,false,false,false,false,false,true,false,false,true,false,false,false,false,false,true,false,false,false,false,false,false,false,false,false,false,true,false,false,false,false],[false,false,true,false,false,false,true,false,true,true,false,false,false,false,false,true,true,false,true,false,false,false,false,false,true,false,false,false,false,false,false,false,false,false,false,true,false,false,false,false],[false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,true,false,false,false,false,false,true,false,false,false,false,false,false,true,false,false,false,true,false,false,false,false],[false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,true,false,false,false,false,false,true,false,false,false,false,false,false,true,true,true,true,true,false,false,false,false],[false,false,false,false,true,false,false,false,false,true,false,true,true,true,true,false,true,false,false,false,false,false,true,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false],[false,false,false,true,false,true,false,false,false,true,false,true,false,false,false,false,true,false,false,false,false,false,true,false,false,false,false,false,true,true,true,true,false,false,false,false,false,false,false,false],[false,false,false,true,false,true,false,false,true,false,false,true,true,true,true,false,false,true,false,true,false,true,false,false,false,false,false,false,true,true,true,true,false,false,false,false,false,false,false,false],[false,false,true,false,false,false,true,false,true,false,false,true,false,false,false,false,false,true,false,true,false,true,false,false,false,false,false,false,true,true,true,true,false,false,false,false,false,false,false,false],[false,false,true,false,false,false,false,true,false,false,false,true,true,true,true,false,false,false,true,false,true,false,false,false,false,false,false,false,true,true,true,true,false,false,false,false,false,false,false,false],[false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,true,true,true,true,false,false,false,false,false,false,false,false],[false,false,true,false,false,true,false,true,true,true,true,false,false,false,false,true,false,false,false,false,true,true,true,true,false,false,false,false,true,true,true,true,false,false,false,false,false,false,false,false],[false,false,true,false,false,true,false,true,false,false,false,false,false,false,true,false,true,false,false,false,true,false,false,true,false,false,false,false,true,true,true,true,false,false,false,false,false,false,false,false],[false,false,true,true,true,true,false,true,true,true,true,false,false,true,false,false,false,true,false,false,true,true,true,true,false,false,false,false,false,true,true,false,false,false,false,false,false,false,false,false],[false,false,false,false,false,true,false,true,false,false,false,false,false,true,true,true,true,true,false,false,true,true,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false],[false,false,true,true,true,true,false,true,true,true,true,false,true,true,false,false,false,true,true,false,true,false,true,false,false,false,false,false,false,true,true,false,false,false,false,false,false,false,false,false],[false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,true,false,true,true,false,false,false,false,false,true,true,false,false,false,false,false,false,false,false,false],[false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false,false]];
这么大一堆数组就完成了“Happy New Year!”的字样。
为了方便,我做了一个编辑文字的工具,在线使用地址:
http://wyh.wjjsoft.com/demo/greeting_card/export_array_tool.html
使用方法很简单,就是在画板上用鼠标点击格子。灰黑色的格子代表在demo中有粒子,白色则相反。
上图就代表demo中的粒子需要构成“2015”的字样。点击“Export”按钮生成数组,然后把数组复制粘贴到list.js中,并保存到list变量下即可。
Ok,万事俱备只欠代码了。
先来看html代码:
<!DOCTYPE html> <html> <head> <title>Greeting Card</title> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" /> <script type="text/javascript" src="./Particle.js"></script> <script type="text/javascript" src="./Sprite.js"></script> <script type="text/javascript" src="./Txt.js"></script> <script type="text/javascript" src="./Stage.js"></script> <script type="text/javascript" src="./Main.js"></script> <script type="text/javascript" src="./list.js"></script> <script type="text/javascript" src="./common.js"></script> </head> <body style="margin: 0px; padding: 0px; font-size: 0px"> <canvas id="mycanvas"></canvas> </body> </html>
为了方便上文所提到的文字编辑器和demo相通(如画布的大小和文字画板大小相同,粒子中小正方形大小和画板格子大小相同),我们准备个common.js来保存这些常量:
var particleW = particleH = 20, angleToRad = Math.PI / 180, stageW = 800, stageH = 480;
particleW和particleH分别代表小正方形宽度、高度;angleToRad是角度和弧度换算率;stageW,stageH是指舞台的宽度和高度。
然后再是Main.js。Main.js主要是用来处理事件,循环渲染,全屏显示以及实例化舞台和文本并提供了获取哪些地方该有粒子哪些地方不该有粒子的函数接口,当然还有其他的功能不一一列举了,大伙就直接拿着代码啃吧。代码如下:
window.addEventListener("load", main, false); var canvasTag, ctx; var canvasStyleWidth, canvasStyleHeight, marginLeft = 0, marginTop = 0; var isFirefox = true, mobile = false; var instructionsTxt; var instructionsIndex = 0, instructionsContents = [ "Tap to open my greeting card~", "Well, continue~", "Don't stop tapping until you know my meaning ^_^" ]; var showList = new Array(); var positionList = new Array(); (function (n) { isFirefox = (n.toLowerCase().indexOf('firefox') >= 0); if ( n.indexOf("iPhone") > 0 || n.indexOf("iPad") > 0 || n.indexOf("iPod") > 0 || n.indexOf("Android") > 0 || n.indexOf("Windows Phone") > 0 || n.indexOf("BlackBerry") > 0 ) { mobile = true; } })(navigator.userAgent); function main () { canvasTag = document.getElementById("mycanvas"); canvasTag.width = stageW; canvasTag.height = stageH; ctx = canvasTag.getContext("2d"); fullScreen(); addStage(); addInstructions(); getParticlesPosition(); canvasTag.addEventListener( "mouseup", function (e) { if (instructionsIndex++ >= instructionsContents.length - 1) { instructionsTxt.visible = false; } else { instructionsTxt.text = instructionsContents[instructionsIndex]; } if (e.offsetX == null && e.layerX != null) { e.offsetX = e.layerX; e.offsetY = e.layerY; } var startX = scaleOffsetX(e.offsetX), startY = scaleOffsetY(e.offsetY); for (var i = 0; i < 5; i++) { addParticle(startX, startY); } }, false ); setInterval(function () { loop(); }, 1000/60); } function fullScreen () { var w = stageW, h = stageH, ww = window.innerWidth, wh = window.innerHeight; if (mobile) { if (ww / wh > stageW / stageH) { h = wh; w = stageW * wh / stageH; } else { w = ww; h = stageH * ww / stageW; } } canvasTag.style.width = w + "px"; canvasTag.style.height = h + "px"; canvasTag.style.marginLeft = (ww - w) / 2 + "px"; canvasTag.style.marginTop = (wh - h) / 2 + "px"; canvasStyleWidth = w; canvasStyleHeight = h; if (isFirefox) { marginLeft = parseInt(canvasTag.style.marginLeft); marginTop = parseInt(canvasTag.style.marginTop); } } function addStage () { var stage = new Stage(); showList.push(stage); } function addInstructions () { instructionsTxt = new Txt(instructionsContents[instructionsIndex]); showList.push(instructionsTxt); } function getParticlesPosition () { for (var i = 0, l = list.length; i < l; i++) { var item = list[i]; for (var j = 0, n = item.length; j < n; j++) { if (item[j]) { positionList.push({x : j * particleW, y : i * particleH}); } } } } function addParticle (startX, startY) { var index = Math.floor(Math.random() * (positionList.length - 1)), pos = positionList[index]; if (!pos) { return; } var particle = new Particle(startX, startY, pos.x, pos.y); showList.push(particle); positionList.splice(index, 1); } function scaleOffsetX (v) { return (v - marginLeft) * stageW / canvasStyleWidth; } function scaleOffsetY (v) { return (v - marginTop) * stageH / canvasStyleHeight; } function loop () { ctx.clearRect(0, 0, canvasTag.width, canvasTag.height); for (var i = 0, l = showList.length; i < l; i++) { showList[i].loop(); } }
由于要放到移动端运行,所以在针对移动端做了点处理。首先介绍几个全局变量:
canvasTag通过document.getElementById取出的一个canvas标签对象
ctxcanvasTag.getContext获取的一个CanvasRenderingContext2D对象
canvasStyleWidth、canvasStyleHeight这两个变量分别记载canvasTag的style属性中这是的width和height;用于处理全屏拉伸后,鼠标事件失灵的问题
marginLeft、marginTop记载canvasTag的style中marginLeft,marginTop;用于处理Firefox等浏览器中,在canvasTag的位置移动后鼠标事件取出的layerX和layerY不是相对画布左上角坐标的问题(换句话说就是让点击的位置成为粒子发射的位置)
isFirefox、mobile这俩是用来判断是否是Firefox浏览器和移动端的变量
instructionsTxt这个是一个Txt对象,负责显示说明文本(说明文本是什么?@_@!就是demo一开始那个蛊惑你点击屏幕的家伙)
instructionsIndex、instructionsContents前者是记载说明到了那一步,后者是记载说明内容的一个数组
showList显示列表,把需要渲染的对象扔进这个数组,就可以使该对象重复渲染了
positionList记载哪些地方可以出现粒子
呼~全局变量终于介绍完了,继续往下看:
(function (n) { isFirefox = (n.toLowerCase().indexOf('firefox') >= 0); if ( n.indexOf("iPhone") > 0 || n.indexOf("iPad") > 0 || n.indexOf("iPod") > 0 || n.indexOf("Android") > 0 || n.indexOf("Windows Phone") > 0 || n.indexOf("BlackBerry") > 0 ) { mobile = true; } })(navigator.userAgent);
在这个匿名函数里给isFirefox和mobile赋值。
在接下来的main函数中,任劳任怨的main需要调用全屏显示函数,加入舞台函数,加入说明文本函数,获取粒子位置函数,加入事件函数,并且还要驱动循环渲染。
值得一看的是事件部分:
canvasTag.addEventListener( "mouseup", function (e) { if (instructionsIndex++ >= instructionsContents.length - 1) { instructionsTxt.visible = false; } else { instructionsTxt.text = instructionsContents[instructionsIndex]; } if (e.offsetX == null && e.layerX != null) { e.offsetX = e.layerX; e.offsetY = e.layerY; } var startX = scaleOffsetX(e.offsetX), startY = scaleOffsetY(e.offsetY); for (var i = 0; i < 5; i++) { addParticle(startX, startY); } }, false );
在某些浏览器中获取点击位置不是用offsetX和offsetY而是layerX和layerY。所以在这里我们需要统一一下。由于在移动端我做了全屏拉伸处理,所以用scaleOffsetX和scaleOffsetY来矫正鼠标位置。
Stage这个类在Main.js中得到实例,并加入显示列表。Stage类的代码如下:
function Stage () { var s = this; s.width = canvasTag.width; s.height = canvasTag.height; s.bgColor = ctx.createRadialGradient(s.width / 2, s.height / 2, 10, s.width / 2, s.height / 2, s.width * 0.6); s.bgColor.addColorStop(0.3, "#CCCCCC"); s.bgColor.addColorStop(1.0, "#FFFFFF"); } Stage.prototype = { loop : function() { var s = this; ctx.save(); ctx.beginPath(); ctx.fillStyle = s.bgColor; ctx.rect(0, 0, s.width, s.height); ctx.fill(); if (s.isShowInstructions) { ctx.fillStyle = "black"; ctx.font = "bold 20px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("Tap to open greeting card~", stageCenterX, stageCenterY); } ctx.restore(); } };
在loop函数中,我们进行渲染,把舞台渲染出来。
再是Txt.js:
function Txt (text) { var s = this; s.x = stageW / 2; s.y = stageH / 2; s.text = text || ""; s.visible = true; } Txt.prototype = { loop : function() { var s = this; if (!s.visible) { return; } ctx.save(); ctx.fillStyle = "black"; ctx.font = "bold 20pt sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(s.text, s.x, s.y); ctx.restore(); } };
和Stage类似,通过loop来进行渲染。不同的是多了个visible属性来控制是否显示。毕竟Txt对象在demo中是可以消失的。
前面也介绍了,Particle是指许多小正方形组成的一个有拖尾的显示对象。而小正方形就是Sprite了。
先来看看Particle的代码:
function Particle (startX, startY, endX, endY) { var s = this; s.x = startX; s.y = startY; s.rotation = 0; s.endX = endX; s.endY = endY; s.displacement = Math.sqrt((startX - endX) * (startX - endX) + (startY - endY) * (startY - endY)); s.stepLength = 7; s.stepNum = s.displacement / s.stepLength; s.stepIndex = 0; s.stopAddSprite = false; s.moveCos = (endX - startX) / s.displacement; s.moveSin = (endY - startY) / s.displacement; s.childList = new Array(); s.removeList = new Array(); s.color = Particle.COLOR_LIST[Math.round(Math.random() * (Particle.COLOR_LIST.length - 1))]; } Particle.COLOR_LIST = [ "#990000", "#FF0000", "#CC3300", "#CC6600", "#CC0033", "#FFFF00", "#33FF00", "#33CC00", "#0066FF", "#00FF99", "#330099", "#990033", "#000099" ]; Particle.prototype = { loop : function () { var s = this; s.loopChild(); s.clearRemoveList(); s.updateShowProperites(); s.addChildSprite(); }, loopChild : function () { var s = this; for (var i = 0, l = s.childList.length; i < l; i++) { var o = s.childList[i]; if (!o) { continue; } o.loop(); if (o.mode == Sprite.MODE_DISAPPEAR) { s.removeList.push(o); } } }, clearRemoveList : function () { var s = this; for (var j = 0, m = s.removeList.length; j < m; j++) { var toRemoveObj = s.removeList[j]; for (var k = 0, n = s.childList.length; k < n; k++) { if (s.childList[k].index == toRemoveObj.index) { s.childList.splice(k, 1); break; } } } s.removeList.splice(0, s.removeList.length); }, updateShowProperites : function () { var s = this; s.x += s.stepLength * s.moveCos; s.y += s.stepLength * s.moveSin; s.rotation += 10; }, addChildSprite : function () { var s = this; if (s.stopAddSprite) { return; } if (++s.stepIndex >= s.stepNum) { s.x = s.endX; s.y = s.endY; var sprite = new Sprite(s.x, s.y, s.rotation, true); sprite.color = s.color; s.childList.push(sprite); s.stopAddSprite = true; return; } var sprite = new Sprite(s.x, s.y, s.rotation, false); sprite.color = s.color; s.childList.push(sprite); } };
这个类就相对于前几个要复杂一些了,接受四个参数,分别是[开始x坐标, 开始y坐标, 终点x坐标, 终点y坐标]。首先是对其属性的介绍:
x、y、rotation分别表示x坐标,y坐标,旋转角度
endX、endY记录粒子要到的位置
displacement传说中物理学里的位移!!!(根据高中物理必修一的知识,位移是矢量,但是这里我直接赋值为标量了,祈祷俺的物理老师没有看到这里吧)
stepLength粒子每次移动时,移动的长度
stepNum计算一下粒子移动到目的地需要多少步
stepIndex粒子已经移动了多少步,用于判断粒子是否该停下了
stopAddSprite用于判断是否停止添加拖尾小正方形
moveCos、moveSindisplacement代表起始点和终点中点连线的长度,stepLength的方向也是沿着该线的。我们移动对象只能移动x,y坐标,所以计算出该线的cos和sin值以便算出x方向上的增量和y方向上的增量,从而达到按任意角度移动的对象。
childList成员列表,装载小正方形拖尾的数组
removeList小正方形的透明度降为0或小于0后,需要移除这些小正方形,所以把这些小正方形加到移除列表removeList中,然后等渲染完毕后遍历移除列表,从childList中移除需要移除的对象。
color粒子的颜色,是从Particle.COLOR_LIST中随机取出的
Ok,再来看看成员函数介绍,具体的代码大家对照看吧,我只介绍一下函数执行的逻辑和功能:
loop这个函数作为其他函数的入口
loopChild从childList中取出小正方形Sprite对象进行渲染,并把透明度为0或小于0的小正方形放入removeList中。需要注意的是,我判断小正方形是否需要移除使用的是Sprite的mode属性,其实这个mode属性在Sprite透明度变为0或小于0时就会变成Sprite.MODE_DISAPPEAR,具体的代码见下文Sprite部分。
clearRemoveList进行清理需要移除的小正方形。为什么不直接在loopChild函数里进行移除操作而要准备个移除列表在渲染完成后进行移除呢?那是因为你在循环渲染时,是在遍历childList,如果立刻删除需要删除的元素,就会破坏开始遍历时childList的结构,这样一来就可能出现小正方形闪烁的现象。
updateShowProperites更新粒子的位置和旋转的角度
addChildSprite加入小正方形实现拖尾效果
至此,Particle介绍完毕。由代码可知,Particle并没有进行渲染,而是通过childList中的Sprite对象来进行的。所以该到介绍Sprite的时候了:
function Sprite (x, y, rotation, cannotDisappear) { var s = this; s.x = x; s.y = y; s.index = Sprite.INDEX++; s.rotation = rotation; s.alpha = 1; s.mode = Sprite.MODE_APPEAR; s.cannotDisappear = cannotDisappear; s.color = "#FFFFFF"; s.startDrawX = -particleW / 2; s.startDrawY = -particleH / 2; } Sprite.INDEX = 0; Sprite.MODE_APPEAR = "appear"; Sprite.MODE_DISAPPEAR = "disappear"; Sprite.prototype = { loop : function () { var s = this; ctx.save(); ctx.beginPath(); ctx.translate(s.x, s.y); ctx.rotate(s.rotation * angleToRad); ctx.globalAlpha = s.alpha; ctx.rect(s.startDrawX, s.startDrawY, particleW, particleH); ctx.fillStyle = s.color; ctx.fill(); ctx.restore(); if (s.cannotDisappear) { s.rotation += 5; return; } s.alpha -= 0.05; if (s.alpha <= 0) { s.mode = Sprite.MODE_DISAPPEAR; } } };
参数介绍:
x绘制的x坐标
y绘制的y坐标
rotation旋转角度
cannotDisppear是否减少透明度并可以被移除。如果该Sprite作为的是拖尾中的小正方形那么这个参数为false,如果是停止后原地旋转的小正方形则为true
属性介绍:
x、y、rotation同Particle类的x、y、rotation
index对象编号,移除Sprite时会用到,相当于对象的身份证
mode为Sprite.MODE_APPEAR、Sprite.MODE_DISAPPEAR;前者表示正常显示,后者表示透明度降为0或0以下,需要移除此对象
color小正方形颜色,由装载它的Particle的color决定
startDrawX、startDrawY由于小正方形旋转时是按中心旋转的,所以绘制矩形时其实坐标不能为0,而是由这两个属性决定矩形的起始点
cannotDisppear见参数cannotDisppear
函数介绍:
loop渲染函数
http://wyh.wjjsoft.com/downloads/greeting_card.zip
----------------------------------------------------------------
转载请注明:转自Yorhom's Game Box