这是关于初学者的文章,他们希望通过一些简单的方法开始在网络中开发游戏开发,而不使用任何重型工具。本文提供了一个简单的步骤,开始使用html / css和javascript的2d游戏开发。在这里,我将介绍如何创建一个图像拼图游戏,您可以在其中拖放图像部分进行交换和重新排列零件以形成完整的图像。
你可以在线玩这个游戏:http://www.ikinsoft.com/3ddemo/puzzle/puzzle.html
游戏的规则很简单。你只需要拖放破碎的图像来交换它。您需要以形成正确图像的方式交换它们。将拖放图像部件所需的步骤数。所以,您可能希望考虑并尝试以尽可能最小的步骤进行。右侧提供正确的图像供您参考。
游戏画面如下所示:
游戏画面截图
我们将游戏的代码分成3部分:Html,Css和Javascript。Html部分包含形成游戏布局的简单标签。CSS提供了一些响应式设计,Javascript部分包含游戏的主要逻辑。游戏的几个重要步骤如下:
打破图像
对于图像看起来像分为nxn不同的部分,每侧的部件n数量在哪里,nxn li元素已被使用在一个ul。每个的显示属性li已设置为内嵌块,以使其显示为网格。每个背景图像li被设置为仅显示图像的1 /(n×n)部分,并且相应地设置背景图像的位置。data-value属性已被分配给每个li以标识该片段的索引。
代码如下:
打碎图片代码
在这里,您可以看到使用简单background-image和background-position风格实现了破坏的效果。在已经设置了博尔森图像之后,按照正确的顺序,随机化方法用于随机化片段。在游戏中,用户必须重新排列片段以形成完整的图像。
gridSize表示图像需要在每一侧(水平和垂直)分割多少部分。硬编码值400是盒子的大小。请注意,您可能想要摆脱这个硬编码的值。我将在下一次更新中用一个变量来更新。基于gridSize,我将拼图的级别分为3部分:容易,中等和难易。容易3x3格,中4x4和硬5x5。您可以通过更改相应的单选按钮的值,以不同的方式实现相同的方式。
随机断开零件
在设置图像损坏的部分后,如前面代码块的最后一行所示,随机化方法用于随机分割碎片。为此,创建一个小型通用随机化函数来随机化任何jquery元素集合。
随机化方法的实现如下:
随机断开零件代码
在这里,我们只是简单地循环给定选择器的每个子元素,并根据随机数改变其位置。随机数应在0和收集中的元素数之间。
拖放图片碎片
为了使每个碎片拖动,使用了jquery draggable插件。请确保您的页面中包含jquery-ui.js以实现可拖放/可拖放功能。
拖放图片碎片代码
正如您在上述代码片段中可以看到的,每次下降之后,isSorted 都将被调用来检查这些片段是否已被排序。正在根据包含li元素的data-value属性检查每个片段的排序。如果片段被排序,则表示图片已完成。
设置样式
已经使用了一个非常小的css来使其变得简单易懂。所使用的css允许页面响应,您可以在平板电脑或手机中玩游戏。没有使用css的第三方库,以便您可以轻松了解本机css样式。
计数步骤
计数步骤或任何用户操作是任何游戏中最常见的部分。这也是通过一个简单的步骤实现的。在每次下降之后,它检查图像是否形成。如果是,游戏结束,如果没有,则将stepCount 变量递增1.然后,stepCount 使用jquery 更新UI。
计时器
计时器也是大多数游戏的重要组成部分之一。基于读者提供的反馈,已经实现了一个基本的计时器来检查完成拼图所需的秒数。计时器正在游戏开始时启动,tick 每秒钟调用 方法来更新计时器。Tick方法一旦从start方法调用,然后每秒钟调用自身(使用JavaScript SetTimeout)并使用JQuery更新UI中使用的时间。当图片完成时,游戏结束,最后计时,并在使用JQuery的输出中显示。
下面是定时器方法的实现:
计时器代码
请注意,getTime()方法给出自01/01/1970以来通过的毫秒数。如果您建议更好的方法来计算两个DateTime 在javascript 之间的时间,我将不胜感激。我不想依靠1000毫秒的差距setTimeout()来增加1秒。
级别
根据用户的反馈,游戏中添加了3个难度级别:1.轻松2.中等3.硬。在我们的例子中,选择容易设置3x3矩阵中的难题,4x4矩阵中的中等,硬设置为5x5矩阵。
为了简单起见,我避免使用Html 5或CSS 3,以便它可以在大多数浏览器中使用。由于使用了JQuery版本,此游戏可能不适用于较早的浏览器<IE8。如果您希望在旧版本的旧版本中使用此游戏,您可以将脚本引用替换为较旧的JQuery版本(1.9或更早版本)。最新的JQuery版本不支持旧版浏览器。
上文的示例网址应该在大多数最新的浏览器中运行。已经在IE 11和Google Chrome中测试过。
篇文章给大家带来的内容是关于使用javascript中canvas实现拼图小游戏 ,有一定的参考价值,有需要的朋友可以参考一下,希望对你有所帮助。
如果您想要综合使用javascript中canvas、原生拖拽、本地存储等多种技术完成一个有趣的项目,那么这篇文章将非常适合您
该项目中的拼图小游戏使用javascript原创,相比于网站上类似的功能,它使用到的技术点更先进丰富,功能更强大,还包含程序开发中更多先进的思想理念,从该项目中您将能学到:
项目源码-github
下面是游戏界面的示例图:
根据游戏界面图我们可以将完成这么一个小游戏分为以下几步来实现:
从以上对小游戏制作过程的分析来看,第4步是程序功能实现的重点和难点,在以上的每个步骤中都有很多小细节需要注意和探讨,下面我就详细分析一下每个步骤的实现细节,说的不好的地方,欢迎大家留言指正。
3.1 图片内容读取和加载
在游戏开发第1步中,我们将图片拖拽到指定区域后,程序是怎样得到图片内容信息的呢?fileReader对象又是怎样将图片信息转化为base64字符串内容的?Image对象拿到图片的base64内容之后,又是怎样初始化加载的?带着这些疑问,我们来研究一下实现项目中实现了第一步的关键代码。
var droptarget = document.getElementById("droptarget"),
output = document.getElementById("ul1"),
thumbImg = document.getElementById("thumbimg");
//此处省略相关代码........
function handleEvent(event) {
var info = "",
reader = new FileReader(),
files, i, len;
EventUtil.preventDefault(event);
localStorage.clear();
if (event.type == "drop") {
files = event.dataTransfer.files;
len = files.length;
if (!/image/.test(files[0].type)) {
alert('请上传图片类型的文件');
}
if (len > 1) {
alert('上传图片数量不能大于1');
}
var canvas = document.createElement('canvas');
var context = canvas.getContext('2d');
var img = new Image(), //原图
thumbimg = new Image(); //等比缩放后的缩略图
reader.readAsDataURL(files[0]);
reader.onload = function (e) {
img.src = e.target.result;
}
//图片对象加载完毕后,对图片进行等比缩放处理。缩放后最大宽度为三百像素
img.onload = function () {
var targetWidth, targetHeight;
targetWidth = this.width > 300 ? 300 : this.width;
targetHeight = targetWidth / this.width * this.height;
canvas.width = targetWidth;
canvas.height = targetHeight;
context.clearRect(0, 0, targetWidth, targetHeight);
context.drawImage(img, 0, 0, targetWidth, targetHeight);
var tmpSrc = canvas.toDataURL("image/jpeg");
//在本地存储完整的缩略图源
localStorage.setItem('FullImage', tmpSrc);
thumbimg.src = tmpSrc;
}
//此处省略相关代码......
EventUtil.addHandler(droptarget, "dragenter", handleEvent);
EventUtil.addHandler(droptarget, "dragover", handleEvent);
EventUtil.addHandler(droptarget, "drop", handleEvent);
}
这段代码的思路就是首先获得拖拽区域目标对象droptarget,为droptarget注册拖拽监听事件。代码中用到的EventUtil是我封装的一个对元素添加事件、事件对象的兼容处理等常用功能的简单对象,下面是其添加注册事件的简单简单代码,其中还有很多其他的封装,读者可自行查阅,功能比较简单。
var EventUtil = {
addHandler: function(element, type, handler){
if (element.addEventListener){
element.addEventListener(type, handler, false);
} else if (element.attachEvent){
element.attachEvent("on" + type, handler);
} else {
element["on" + type] = handler;
}
},
//此处省略代......
}
当用户将图片文件拖放到区域目标对象droptarget时,droptarget的事件对象通过event.dataTransfer.files获取到文件信息,对文件进行过滤(限制只能为图片内容,并且最多只能有一张图片)。拿到文件内容以后,使用FileReader对象reader读取文件内容,使用其readAsDataURL方法读取到图片的base64内容,赋值给Image对象img的src属性,就可以等到img对象初始化加载完毕,使canvas对img进行下一步的处理了。这里有一个重点的地方需要说明:一定要等img加载完成后,再使用canvas进行下一步的处理,不然可能会出现图片损坏的情况。原因是:当img的src属性读取图片文件的base64内容时,可能还没有将内容加载到内存中时,canvas就开始处理图片(此时的图片是不完整的)。所以我们可以看到canvas对图片的处理是放在img.onload方法中进行的,程序后边还会有这种情况,之后就不再赘述了。
3.2 图片等比缩放和本地存储
在第一步中我们完成了对拖拽文件的内容读取,并将其成功加载到了Image对象img中。接下来我们使用canvas对图片进行等比缩放,对图片进行等比缩放,我们采取的策略是限制图片的最大宽度为300像素,我们再来看一下这部分代码吧:
img.onload = function () {
var targetWidth, targetHeight;
targetWidth = this.width > 300 ? 300 : this.width;
targetHeight = targetWidth / this.width * this.height;
canvas.width = targetWidth;
canvas.height = targetHeight;
context.clearRect(0, 0, targetWidth, targetHeight);
context.drawImage(img, 0, 0, targetWidth, targetHeight);
var tmpSrc = canvas.toDataURL("image/jpeg");
//在本地存储完整的缩略图源
localStorage.setItem('FullImage', tmpSrc);
thumbimg.src = tmpSrc;
}
确定了缩放后的宽度targetWidth和高度targetHeight之后,我们使用canvas的drawImage方法对图像进行压缩,在这之前我们最好先使用画布的clearRect对画布进行一次清理。对图片等比缩放以后,使用canvas的toDataURL方法,获取到缩放图的base64内容,赋给新的缩放图Image对象thumbimg的src属性,待缩放图加载完毕,进行下一步的切割处理。缩放图的base64内容使用localStorage存储,键名为"FullImage"。浏览器的本地存储localStorage是硬存储,在浏览器刷新之后内容不会丢失,这样我们就可以在游戏过程中保持数据状态,这点稍后再详细讲解,我们需要知道的是localStorage是有大小限制的,最大为5M。这也是为什么我们先对图片进行压缩,减少存储数据大小,保存缩放图base64内容的原因。关于开发过程中存储哪些内容,下一小节会配有图例详细说明。
3.3 缩略图切割
生成缩略图之后要做的工作就是对缩略图进行切割了,同样的也是使用canvas的drawImage方法,而且相应的处理必须放在缩略图加载完成之后(即thumbimg.onload)进行处理,原因前面我们已经说过。下面我们再来详细分析一下源代码吧:
thumbimg.onload = function () {
//每一个切片的宽高[切割成3*4格式]
var sliceWidth, sliceHeight, sliceBase64, n = 0, outputElement = '',
sliceWidth = this.width / 3,
sliceHeight = this.height / 4,
sliceElements = [];
canvas.width = sliceWidth;
canvas.height = sliceHeight;
for (var j = 0; j < 4; j++) {
for (var i = 0; i < 3; i++) {
context.clearRect(0, 0, sliceWidth, sliceHeight);
context.drawImage(thumbimg, sliceWidth * i, sliceHeight * j, sliceWidth, sliceHeight, 0, 0, sliceWidth, sliceHeight);
sliceBase64 = canvas.toDataURL("image/jpeg");
localStorage.setItem('slice' + n, sliceBase64);
//为了防止图片三像素问题发生,请为图片属性添加 display:block
newElement = "<li name=\"" + n + "\" style=\"margin:3px;\"><img src=\"" + sliceBase64 + "\" style=\"display:block;\"></li>";
//根据随机数打乱图片顺序
(Math.random() > 0.5) ? sliceElements.push(newElement) : sliceElements.unshift(newElement);
n++;
}
}
//拼接元素
for (var k = 0, len = sliceElements.length; k < len; k++) {
outputElement += sliceElements[k];
}
localStorage.setItem('imageWidth', this.width + 18);
localStorage.setItem('imageHeight', this.height + 18);
output.style.width = this.width + 18 + 'px';
output.style.height = this.height + 18 + 'px';
(output.innerHTML = outputElement) && beginGamesInit();
droptarget.remove();
}
上面的代码对于大家来说不难理解,就是将缩略图分割成12个切片,这里我给大家解释一下几个容易困惑的地方:
for (var j = 0; j < 4; j++) {
for (var i = 0; i < 3; i++) {
//此处省略逻辑代码
}
}
这个问题大家仔细想一想就明白了,我们将图片进行切割的时候,要记录下来每一个图片切片的原有顺序。在程序中我们使用 n 来表示图片切片的原有顺序,而且这个n记录在了每一个图片切片的元素的name属性中。在后续的游戏过程中我们可以使用元素的getAttribute('name')方法取出 n 的值,来判断图片切片是否都被拖动到了正确的位置,以此来判断游戏是否结束,现在讲起这个问题可能还会有些迷惑,我们后边还会再详细探讨,我给出一张图帮助大家理解图片切片位置序号信息n:
序号n从零开始是为了和javascript中的getElementsByTagName()选择的子元素坐标保持一致。
(Math.random() > 0.5) ? sliceElements.push(newElement) : sliceElements.unshift(newElement);
我们知道Math.random()生成一个[0, 1)之间的数,所以再canvas将缩略图裁切成切片以后,根据这些切片生成的web节点顺序是打乱的。打乱顺序以后重新组装节点:
//拼接元素
for (var k = 0, len = sliceElements.length; k < len; k++) {
outputElement += sliceElements[k];
}
然后再将节点添加到web页面中,也就自然而然出现了图片切片被打乱的样子了。
下面的一行代码,虽然简单,但是用的非常巧妙:
(output.innerHTML = outputElement) && beginGamesInit();
有开发经验的同学都知道 && 和 || 是短路运算符,代码中的含义是:只有当切片元素节点都添加到
WEB页面之后,才会初始化为这些节点绑定事件。
3.4 本地信息存储
代码中多次用到了本地存储,下面我们来详细解释一下本游戏开发过程中都有哪些信息需要存储,为什么要存储?下面是我给出的需要存储的信息图示例(从浏览器控制台获取):
浏览器本地存储localStorage使用key:value形式存储,从图中我们看到我们本次存储的内容有:
保存FullImage缩略图的信息是当游戏结束后显示源缩略图时,根据FullImage中的内容展示图片。而imageWidth,imageHeight,slice*,nodePos是为了防止浏览器刷新导致数据丢失所做的存储,当刷新页面的时候,浏览器会根据本地存储的数据加载没有完成的游戏内容。其中nodePos是在为缩略图切片发生拖动时存入本地存储的,并且它随着切片位置的变化而变化,也就是它追踪着游戏的状态,我们在接下来的代码功能展示中会再次说到它。
3.5 拖拽事件注册和监控
接下来我们要做的事才是游戏中最重要的部分,还是先来分析一下代码,首先是事件注册前的初始化工作:
//游戏开始初始化
function beginGamesInit() {
aLi = output.getElementsByTagName("li");
for (var i = 0; i < aLi.length; i++) {
var t = aLi[i].offsetTop;
var l = aLi[i].offsetLeft;
aLi[i].style.top = t + "px";
aLi[i].style.left = l + "px";
aPos[i] = {left: l, top: t};
aLi[i].index = i;
//将位置信息记录下来
nodePos.push(aLi[i].getAttribute('name'));
}
for (var i = 0; i < aLi.length; i++) {
aLi[i].style.position = "absolute";
aLi[i].style.margin = 0;
setDrag(aLi[i]);
}
}
可以看到这部分初始化绑定事件代码所做的事情是:记录每一个图片切片对象的位置坐标相关信息记录到对象属性中,并为每一个对象都注册拖拽事件,对象的集合由aLi数组统一管理。这里值得一提的是图片切片的位置信息index记录的是切片现在所处的位置,而我们前边所提到的图片切片name属性所保存的信息n则是图片切片原本应该所处的位置,在游戏还没有结束之前,它们不一定相等。待所有的图片切片name属性所保存的值和其属性index都相等时,游戏才算结束(因为用户已经正确完成了图片的拼接),下面的代码就是用来判断游戏状态是否结束的,看起来更直观一些:
//判断游戏是否结束
function gameIsEnd() {
for (var i = 0, len = aLi.length; i < len; i++) {
if (aLi[i].getAttribute('name') != aLi[i].index) {
return false;
}
}
//后续处理代码省略......
}
下面我们还是详细说一说拖拽交换代码相关逻辑吧,拖拽交换的代码如下图所示:
//拖拽
function setDrag(obj) {
obj.onmouseover = function () {
obj.style.cursor = "move";
console.log(obj.index);
}
obj.onmousedown = function (event) {
var scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
var scrollLeft = document.documentElement.scrollLeft || document.body.scrollLeft;
obj.style.zIndex = minZindex++;
//当鼠标按下时计算鼠标与拖拽对象的距离
disX = event.clientX + scrollLeft - obj.offsetLeft;
disY = event.clientY + scrollTop - obj.offsetTop;
document.onmousemove = function (event) {
//当鼠标拖动时计算p的位置
var l = event.clientX - disX + scrollLeft;
var t = event.clientY - disY + scrollTop;
obj.style.left = l + "px";
obj.style.top = t + "px";
for (var i = 0; i < aLi.length; i++) {
aLi[i].className = "";
}
var oNear = findMin(obj);
if (oNear) {
oNear.className = "active";
}
}
document.onmouseup = function () {
document.onmousemove = null; //当鼠标弹起时移出移动事件
document.onmouseup = null; //移出up事件,清空内存
//检测是否普碰上,在交换位置
var oNear = findMin(obj);
if (oNear) {
oNear.className = "";
oNear.style.zIndex = minZindex++;
obj.style.zIndex = minZindex++;
startMove(oNear, aPos[obj.index]);
startMove(obj, aPos[oNear.index], function () {
gameIsEnd();
});
//交换index
var t = oNear.index;
oNear.index = obj.index;
obj.index = t;
//交换本次存储中的位置信息
var tmp = nodePos[oNear.index];
nodePos[oNear.index] = nodePos[obj.index];
nodePos[obj.index] = tmp;
localStorage.setItem('nodePos', nodePos);
} else {
startMove(obj, aPos[obj.index]);
}
}
clearInterval(obj.timer);
return false;//低版本出现禁止符号
}
}
这段代码所实现的功能是这样子的:拖动一个图片切片,当它与其它的图片切片有碰撞重叠的时候,就和与其左上角距离最近的一个图片切片交换位置,并交换其位置信息index,更新本地存储信息中的nodePos。移动完成之后判断游戏是否结束,若没有,则期待下一次用户的拖拽交换。
下面我来解释一下这段代码中比较难理解的几个点:
//碰撞检测
function colTest(obj1, obj2) {
var t1 = obj1.offsetTop;
var r1 = obj1.offsetWidth + obj1.offsetLeft;
var b1 = obj1.offsetHeight + obj1.offsetTop;
var l1 = obj1.offsetLeft;
var t2 = obj2.offsetTop;
var r2 = obj2.offsetWidth + obj2.offsetLeft;
var b2 = obj2.offsetHeight + obj2.offsetTop;
var l2 = obj2.offsetLeft;
`if (t1 > b2 || r1 < l2 || b1 < t2 || l1 > r2)` {
return false;
} else {
return true;
}
}
这段代码看似信息量很少,其实也很好理解,判断两个图片切片是否发生碰撞,只要将它们没有发生碰撞的情形排除掉就可以了。这有点类似与逻辑中的非是即否,两个切片又确实只可能存在两种情况:碰撞、不碰撞。图中的这段代码是判断不碰撞的情况:if (t1 > b2 || r1 < l2 || b1 < t2 || l1 > r2),返回false, else 返回true。
2.碰撞检测完成了之后,图片切片之间又是怎样寻找左上角定点距离最近的元素呢?
代码是这个样子的:
//勾股定理求距离(左上角的距离)
function getDis(obj1, obj2) {
var a = obj1.offsetLeft - obj2.offsetLeft;
var b = obj1.offsetTop - obj2.offsetTop;
return Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
}
//找到距离最近的
function findMin(obj) {
var minDis = 999999999;
var minIndex = -1;
for (var i = 0; i < aLi.length; i++) {
if (obj == aLi[i]) continue;
if (colTest(obj, aLi[i])) {
var dis = getDis(obj, aLi[i]);
if (dis < minDis) {
minDis = dis;
minIndex = i;
}
}
}
if (minIndex == -1) {
return null;
} else {
return aLi[minIndex];
}
}
因为都是矩形区块,所以计算左上角的距离使用勾股定理,这点相信大家都能明白。查找距离最近的元素原理也很简单,就是遍历所有已经碰撞的元素,然后比较根据勾股定理计算出来的最小值,返回元素就可以了。代码中也是使用了比较通用的方法,先声明一个很大的值最为最小值,当有碰撞元素比其小时,再将更小的值最为最小值,遍历完成后,返回最小值的元素就可以了。
答案是回调函数,图片切片交换函数通过回调函数来判断游戏是否已经结束,游戏是否结束的判断函数前面我们已经说过。图片切片交换函数就是通过添加gameIsEnd作为回调函数,这样在每次图片切片移动交换完成之后,就判断一下游戏是否结束。图片切片的交换函数还是比较复杂的,有兴趣的同学可以研究一下,下面是其实现代码,大家重点理解其中添加了回调函数监控游戏是否结束就好了。
//通过class获取元素
function getClass(cls){
var ret = [];
var els = document.getElementsByTagName("*");
for (var i = 0; i < els.length; i++){
//判断els[i]中是否存在cls这个className;.indexOf("cls")判断cls存在的下标,如果下标>=0则存在;
if(els[i].className === cls || els[i].className.indexOf("cls")>=0 || els[i].className.indexOf(" cls")>=0 || els[i].className.indexOf(" cls ")>0){
ret.push(els[i]);
}
}
return ret;
}
function getStyle(obj,attr){//解决JS兼容问题获取正确的属性值
return obj.currentStyle?obj.currentStyle[attr]:getComputedStyle(obj,false)[attr];
}
function gameEnd() {
alert('游戏结束!');
}
function startMove(obj,json,fun){
clearInterval(obj.timer);
obj.timer = setInterval(function(){
var isStop = true;
for(var attr in json){
var iCur = 0;
//判断运动的是不是透明度值
if(attr=="opacity"){
iCur = parseInt(parseFloat(getStyle(obj,attr))*100);
}else{
iCur = parseInt(getStyle(obj,attr));
}
var ispeed = (json[attr]-iCur)/8;
//运动速度如果大于0则向下取整,如果小于0想上取整;
ispeed = ispeed>0?Math.ceil(ispeed):Math.floor(ispeed);
//判断所有运动是否全部完成
if(iCur!=json[attr]){
isStop = false;
}
//运动开始
if(attr=="opacity"){
obj.style.filter = "alpha:(opacity:"+(json[attr]+ispeed)+")";
obj.style.opacity = (json[attr]+ispeed)/100;
}else{
obj.style[attr] = iCur+ispeed+"px";
}
}
//判断是否全部完成
if(isStop){
clearInterval(obj.timer);
if(fun){
fun();
}
}
},30);
}
4.1 游戏中值得完善的功能
我认为该游戏中值得优化的地方有两个:
这些功能感兴趣的小伙伴可以尝试一下。
相关推荐:
用javascript实现web拼图游戏
H5的canvas实现贪吃蛇小游戏
以上就是使用javascript中canvas实现拼图小游戏的详细内容,更多请关注其它相关文章!
更多技巧请《转发 + 关注》哦!
学lufylegend.js之日,我用lufylegend.js开发了第一个HTML5小游戏——拼图游戏,还写了篇博文来炫耀一下:HTML5小游戏《智力大拼图》发布,挑战你的思维风暴。不过当时初学游戏开发,经验浅薄,所以没有好好专研游戏里的算法和代码的缺陷,导致游戏出现了很多bug,甚至拼图打乱后很可能无法复原。最近经常有朋友问起这个游戏,希望我能把代码里的bug改一下方便初学者学习,顺便我也打算测试一下自己写这种小游戏的速度,所以就抽出了一些时间将这个游戏从头到尾重新写了一遍,计算了一下用时,从准备、修改素材到最后完成游戏,一共用了大约2h的时间。
以下是游戏地址:
由于头条禁止在文章页面加入链接,大家私信我“拼图”即可获取下载地址。
这是我的游戏记录,欢迎各位挑战:
接下来就来讲讲如何开发完成这款游戏的。(按“编年体”)
准备lufylegend游戏引擎,大家可以去官方网站下载:
由于头条禁止在文章页面加入链接,大家私信我“拼图”即可获取下载地址。
引擎文档地址:
由于头条禁止在文章页面加入链接,大家私信我“拼图”即可获取下载地址。
可以说,如果没有强大的lufylegend引擎,这种html5小游戏用原生canvas制作,少说要一天呢。
准备素材(10min) + 修改素材(20min)。由于在下实在手残,不善于P图,修改图片用了大约20min,囧……
开发开始界面。游戏不能没有开始界面所以我们首先实现这部分代码。在此之前是index.html里的代码,代码如下:
<!DOCTYPE html>
<html>
<head>
<title>Puzzle</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="./lib/lufylegend-1.10.1.simple.min.js"></script>
<script type="text/javascript" src="./js/Main.js"></script>
</head>
<body style="margin: 0px; font-size: 0px; background: #F2F2F2;">
<div id="mygame"></div>
</body>
</html>
主要是引入一些js文件,不多说。然后准备一个Main.js文件,在这个文件里添加初始化界面和加载资源的代码:
/** 初始化游戏 */
LInit(60, "mygame", 390, 580, main);
var imgBmpd;
/** 游戏层 */
var stageLayer, gameLayer, overLayer;
/** 拼图块列表 */
var blockList;
/** 是否游戏结束 */
var isGameOver;
/** 用时 */
var startTime, time, timeTxt;
/** 步数 */
var steps, stepsTxt;
function main () {
/** 全屏设置 */
if (LGlobal.mobile) {
LGlobal.stageScale = LStageScaleMode.SHOW_ALL;
}
LGlobal.screen(LGlobal.FULL_SCREEN);
/** 添加加载提示 */
var loadingHint = new LTextField();
loadingHint.text = "资源加载中……";
loadingHint.size = 20;
loadingHint.x = (LGlobal.width - loadingHint.getWidth()) / 2;
loadingHint.y = (LGlobal.height - loadingHint.getHeight()) / 2;
addChild(loadingHint);
/** 加载图片 */
LLoadManage.load(
[
{path : "./js/Block.js"},
{name : "img", path : "./images/img.jpg"}
],
null,
function (result) {
/** 移除加载提示 */
loadingHint.remove();
/** 保存位图数据,方便后续使用 */
imgBmpd = new LBitmapData(result["img"]);
gameInit();
}
);
}
function gameInit (e) {
/** 初始化舞台层 */
stageLayer = new LSprite();
stageLayer.graphics.drawRect(0, "", [0, 0, LGlobal.width, LGlobal.height], true, "#EFEFEF");
addChild(stageLayer);
/** 初始化游戏层 */
gameLayer = new LSprite();
stageLayer.addChild(gameLayer);
/** 初始化最上层 */
overLayer = new LSprite();
stageLayer.addChild(overLayer);
/** 添加开始界面 */
addBeginningUI();
}
以上代码有详细注释,大家可以对照引擎文档和注释进行阅读。有些全局变量会在以后的代码中使用,大家可以先忽略。接下来是addBeginningUI函数里的代码,用于实现开始界面:
function addBeginningUI () {
var beginningLayer = new LSprite();
beginningLayer.graphics.drawRect(0, "", [0, 0, LGlobal.width, LGlobal.height], true, "#EDEDED");
stageLayer.addChild(beginningLayer);
/** 游戏标题 */
var title = new LTextField();
title.text = "拼图游戏";
title.size = 50;
title.weight = "bold";
title.x = (LGlobal.width - title.getWidth()) / 2;
title.y = 160;
title.color = "#FFFFFF";
title.lineWidth = 5;
title.lineColor = "#000000";
title.stroke = true;
beginningLayer.addChild(title);
/** 开始游戏提示 */
var hint = new LTextField();
hint.text = "- 点击屏幕开始游戏 -";
hint.size = 25;
hint.x = (LGlobal.width - hint.getWidth()) / 2;
hint.y = 370;
beginningLayer.addChild(hint);
/** 开始游戏 */
beginningLayer.addEventListener(LMouseEvent.MOUSE_UP, function () {
beginningLayer.remove();
startGame();
});
}
到此,运行代码,得到我们的开始界面:
看到这个画面,其实我自己都想吐槽一下实在是太“朴素”了,囧……
不过我这次图个制作速度,所以还望各位看官海量。
这40分钟的时间,是最关键时期,期间我们要完成整个游戏的主体部分。首先,我们需要用代码来实现以下过程:
初始化游戏界面数据(如游戏时间、所用步数)和显示一些UI部件(如图样)
|
-> 获取随机的拼图块位置
|
-> 显示打乱后的拼图块
我们将这些步骤做成一个个的函数方便我们统一调用:
function startGame () {
isGameOver = false;
/** 初始化时间和步数 */
startTime = (new Date()).getTime();
time = 0;
steps = 0;
/** 初始化拼图块列表 */
initBlockList();
/** 打乱拼图 */
getRandomBlockList();
/** 显示拼图 */
showBlock();
/** 显示缩略图 */
showThumbnail();
/** 显示时间 */
addTimeTxt();
/** 显示步数 */
addStepsTxt();
stageLayer.addEventListener(LEvent.ENTER_FRAME, onFrame);
}
函数一开始,我们把isGameOver变量设定为false代表游戏未结束,在后期的代码里,我们会看到这个变量的作用。接着我们初始化了用于表示时间和步数的time和steps这两个全局变量,另外初始化变量startTime的值用于后面计算游戏时间。
接下来,我们就要开始初始化拼图块了。见initBlockList里的代码:
function initBlockList () {
blockList = new Array();
for (var i = 0; i < 9; i++) {
/** 根据序号计算拼图块图片显示位置 */
var y = (i / 3) >>> 0, x = i % 3;
blockList.push(new Block(i, x, y));
}
}
这里我们使用了一个Block类,这个类用于显示拼图块和储存拼图块的数据,并提供了一些方法来操控拼图块,下面是其构造器的代码:
function Block (index, x, y) {
LExtends(this, LSprite, []);
var bmpd = imgBmpd.clone();
bmpd.setProperties(x * 130, y * 130, 130, 130);
this.bmp = new LBitmap(bmpd);
this.addChild(this.bmp);
var border = new LShape();
border.graphics.drawRect(3, "#CCCCCC", [0, 0, 130, 130]);
this.addChild(border);
this.index = index;
this.addEventListener(LMouseEvent.MOUSE_UP, this.onClick);
}
Block类继承自LSprite,属于一个显示对象,所以我们在这个类中添加了一个位图对象用于显示拼图块对应的图片。除此之外,我们还为拼图块添加了一个边框,在显示时用于隔开周围的拼图块。Block类有一个index属性,代表拼图块在拼图块列表blockList中的正确位置。最后,我们为此类添加了一个鼠标按下事件,用于处理鼠标按下后移动图块操作。
接下来我们还要介绍这个类的一个方法setLocation:
Block.prototype.setLocation = function (x, y) {
this.locationX = x;
this.locationY = y;
this.x = x * 130;
this.y = y * 130;
};
这个方法用于设置拼图块对象的显示位置以及保存拼图块的“数组位置”。什么是“数组位置”呢?各位看官可以通过下面的图片加以了解:
可以看到,“数组位置”就类似于二维数组中的元素下标。储存这个位置的作用在于可以很方便地从blockList中获取到附近的其他拼图块。这个方法在我们显示拼图时有调用到,在显示拼图之前,我们得先打乱拼图,见如下代码:
function getRandomBlockList () {
/** 随机打乱拼图 */
blockList.sort(function () {
return 0.5 - Math.random();
});
/** 计算逆序和 */
var reverseAmount = 0;
for (var i = 0, l = blockList.length; i < l; i++) {
var currentBlock = blockList[i];
for (var j = i + 1; j < l; j++) {
var comparedBlock = blockList[j];
if (comparedBlock.index < currentBlock.index) {
reverseAmount++;
}
}
}
/** 检测打乱后是否可还原 */
if (reverseAmount % 2 != 0) {
/** 不合格,重新打乱 */
getRandomBlockList();
}
}
打乱拼图部分直接用数组的sort方法进行随机打乱:
blockList.sort(function () {
return 0.5 - Math.random();
});
其实打乱算法有很多种,我这里采用最粗暴的方法,也就是随机打乱。这种算法简单是简单,坏在可能出现无法复原的现象。针对这个问题,就有配套的检测打乱后是否可还原的算法,具体的算法理论我借用lufy大神的评论:
此类游戏能否还原关键是看它打乱后的逆序次数之和是否为偶数
假设你打乱后的数组中的每一个小图块为obj0,obj1,obj2,…它们打乱之前的序号分别为obj0.num,obj1.num…
接下来循环数组,如果前面元素的序号比此元素后某个元素的序号大,如obj0.num > obj1.num或者obj2.num > obj4.num就表示一个逆序
当全部的逆序之和为奇数时表示不可还原,重新打乱即可,打乱后重新检测,直到逆序之和为偶数为止
举个例子,如果有一个数组为[3, 4, 2, 1],那么里面3 2, 3 1, 2 4, 4 1, 2 1是逆序的,所以逆序数是5。
上面我给出的getRandomBlockList里的代码就是在实现打乱算法和检测是否可还原算法。
还有一种打乱方式,大家可以尝试尝试:和复原拼图一样,将空白块一步一步地与周围的拼图随机交换顺序。这个打乱算法较上一种而言,不会出现无法复原的现象,而且可以根据打乱的步数设定游戏难度。
在完成打乱拼图块后,如期而至的是显示拼图块:
function showBlock() {
for (var i = 0, l = blockList.length; i < l; i++) {
var b = blockList[i];
/** 根据序号计算拼图块位置 */
var y = (i / 3) >>> 0, x = i % 3;
b.setLocation(x, y);
gameLayer.addChild(b);
}
}
显示了拼图块后,我们要做的就是添加操作拼图块的功能。于是需要拓展Block类,为其添加事件监听器onClick方法:
Block.prototype.onClick = function (e) {
var self = e.currentTarget;
if (isGameOver) {
return;
}
var checkList = new Array();
/** 判断右侧是否有方块 */
if (self.locationX > 0) {
checkList.push(Block.getBlock(self.locationX - 1, self.locationY));
}
/** 判断左侧是否有方块 */
if (self.locationX < 2) {
checkList.push(Block.getBlock(self.locationX + 1, self.locationY));
}
/** 判断上方是否有方块 */
if (self.locationY > 0) {
checkList.push(Block.getBlock(self.locationX, self.locationY - 1));
}
/** 判断下方是否有方块 */
if (self.locationY < 2) {
checkList.push(Block.getBlock(self.locationX, self.locationY + 1));
}
for (var i = 0, l = checkList.length; i < l; i++) {
var checkO = checkList[i];
/** 判断是否是空白拼图块 */
if (checkO.index == 8) {
steps++;
updateStepsTxt();
Block.exchangePosition(self, checkO);
break;
}
}
};
首先,我们在这里看到了isGameOver全局变量的作用,即在游戏结束后,阻断点击拼图块后的操作。
在点击了拼图块后,我们先获取该拼图块周围的拼图块,并将它们装入checkList,再遍历checkList,当判断到周围有空白拼图块后,即周围有index属性等于8的拼图块后,先更新操作步数,然后将这两个拼图块交换位置。具体交换拼图块位置的方法详见如下代码:
Block.exchangePosition = function (b1, b2) {
var b1x = b1.locationX, b1y = b1.locationY,
b2x = b2.locationX, b2y = b2.locationY,
b1Index = b1y * 3 + b1x,
b2Index = b2y * 3 + b2x;
/** 在地图块数组中交换两者位置 */
blockList.splice(b1Index, 1, b2);
blockList.splice(b2Index, 1, b1);
/** 交换两者显示位置 */
b1.setLocation(b2x, b2y);
b2.setLocation(b1x, b1y);
/** 判断游戏是否结束 */
Block.isGameOver();
};
还有就是Block.getBlock静态方法,用于获取给定的“数组位置”下的拼图块:
Block.getBlock = function (x, y) {
return blockList[y * 3 + x];
};
在Block.exchangePosition中,我们通过Block.isGameOver判断玩家是否已将拼图复原:
Block.isGameOver = function () {
var reductionAmount = 0, l = blockList.length;
/** 计算还原度 */
for (var i = 0; i < l; i++) {
var b = blockList[i];
if (b.index == i) {
reductionAmount++;
}
}
/** 计算是否完全还原 */
if (reductionAmount == l) {
/** 游戏结束 */
gameOver();
}
};
到这里,我们就实现了打乱和操作拼图块部分。
最后30min用于细枝末节上的处理,如显示拼图缩略图、显示&更新时间和步数,以及添加游戏结束画面,这些就交给如下冗长而简单的代码来完成吧:
function showThumbnail() {
var thumbnail = new LBitmap(imgBmpd);
thumbnail.scaleX = 130 / imgBmpd.width;
thumbnail.scaleY = 130 / imgBmpd.height;
thumbnail.x = (LGlobal.width - 100) /2;
thumbnail.y = 410;
overLayer.addChild(thumbnail);
}
function addTimeTxt () {
timeTxt = new LTextField();
timeTxt.stroke = true;
timeTxt.lineWidth = 3;
timeTxt.lineColor = "#54D9EF";
timeTxt.color = "#FFFFFF";
timeTxt.size = 18;
timeTxt.x = 20;
timeTxt.y = 450;
overLayer.addChild(timeTxt);
updateTimeTxt();
}
function updateTimeTxt () {
timeTxt.text = "时间:" + getTimeTxt(time);
}
function getTimeTxt () {
var d = new Date(time);
return d.getMinutes() + " : " + d.getSeconds();
};
function addStepsTxt () {
stepsTxt = new LTextField();
stepsTxt.stroke = true;
stepsTxt.lineWidth = 3;
stepsTxt.lineColor = "#54D9EF";
stepsTxt.color = "#FFFFFF";
stepsTxt.size = 18;
stepsTxt.y = 450;
overLayer.addChild(stepsTxt);
updateStepsTxt();
}
function updateStepsTxt () {
stepsTxt.text = "步数:" + steps;
stepsTxt.x = LGlobal.width - stepsTxt.getWidth() - 20;
}
function onFrame () {
if (isGameOver) {
return;
}
/** 获取当前时间 */
var currentTime = (new Date()).getTime();
/** 计算使用的时间并更新时间显示 */
time = currentTime - startTime;
updateTimeTxt();
}
function gameOver () {
isGameOver = true;
var resultLayer = new LSprite();
resultLayer.filters = [new LDropShadowFilter()];
resultLayer.graphics.drawRoundRect(3, "#BBBBBB", [0, 0, 350, 350, 5], true,"#DDDDDD");
resultLayer.x = (LGlobal.width - resultLayer.getWidth()) / 2;
resultLayer.y = LGlobal.height / 2;
resultLayer.alpha = 0;
overLayer.addChild(resultLayer);
var title = new LTextField();
title.text = "游戏通关"
title.weight = "bold";
title.stroke = true;
title.lineWidth = 3;
title.lineColor = "#555555";
title.size = 30;
title.color = "#FFFFFF";
title.x = (resultLayer.getWidth() - title.getWidth()) / 2;
title.y = 30;
resultLayer.addChild(title);
var usedTimeTxt = new LTextField();
usedTimeTxt.text = "游戏用时:" + getTimeTxt(time);
usedTimeTxt.size = 20;
usedTimeTxt.stroke = true;
usedTimeTxt.lineWidth = 2;
usedTimeTxt.lineColor = "#555555";
usedTimeTxt.color = "#FFFFFF";
usedTimeTxt.x = (resultLayer.getWidth() - usedTimeTxt.getWidth()) / 2;
usedTimeTxt.y = 130;
resultLayer.addChild(usedTimeTxt);
var usedStepsTxt = new LTextField();
usedStepsTxt.text = "所用步数:" + steps;
usedStepsTxt.size = 20;
usedStepsTxt.stroke = true;
usedStepsTxt.lineWidth = 2;
usedStepsTxt.lineColor = "#555555";
usedStepsTxt.color = "#FFFFFF";
usedStepsTxt.x = usedTimeTxt.x;
usedStepsTxt.y = 180;
resultLayer.addChild(usedStepsTxt);
var hintTxt = new LTextField();
hintTxt.text = "- 点击屏幕重新开始 -";
hintTxt.size = 23;
hintTxt.stroke = true;
hintTxt.lineWidth = 2;
hintTxt.lineColor = "#888888";
hintTxt.color = "#FFFFFF";
hintTxt.x = (resultLayer.getWidth() - hintTxt.getWidth()) / 2;
hintTxt.y = 260;
resultLayer.addChild(hintTxt);
LTweenLite.to(resultLayer, 0.5, {
alpha : 0.7,
y : (LGlobal.height - resultLayer.getHeight()) / 2,
onComplete : function () {
/** 点击界面重新开始游戏 */
stageLayer.addEventListener(LMouseEvent.MOUSE_UP, function () {
gameLayer.removeAllChild();
overLayer.removeAllChild();
stageLayer.removeAllEventListener();
startGame();
});
}
});
}
Ok,2h下来,整个游戏就搞定咯~不得不表扬一下lufylegend这个游戏引擎,实在是可以大幅提升开发效率。
最后奉上源代码:
由于头条禁止在文章页面加入链接,大家私信我“拼图”即可获取下载地址。
这篇博文在最初写成的时候,我没有对逆序算法进行深入研究,再加上我的测试不仔细,我没有发现算法的错误之处。因此,在博文发布后,不少读者发现游戏无解现象并将此问题反馈给了我,经过网友热心帮助,我才找到了问题所在,并更正了算法。在此对这些热心的网友表示真心的感谢,也为我学习不深入,以及误导了不少读者而感到十分内疚自责。
如果大家对本文有任何意见或不解,欢迎留言~
*请认真填写需求信息,我们会在24小时内与您取得联系。