一个拼图游戏的诞生

一个拼图游戏的诞生

成果预览

先放上最后的成果吧~

demo

完整的代码在文章结尾将会给出。这个小游戏一开始只是从互联网上随便找的一个代码。然后我玩起来各种不爽,就开始优(bao)雅(li)的修改起代码来。

封装

一开始我看这个代码不太爽的原因,是代码基本上没有封装,各种逻辑都夹杂在一起,第一步就是把它封装一下。首先是把所有与游戏无关的逻辑剥离出去,比如关于获奖的代码。其次,则是将其改为原型模式,方便将其给其他项目使用

原型模式是什么意思?其实并不复杂,主体仍然是一个function,只不过我们将它的prototype替换为我们自己的代码,简单地说:

function App(config) {
	//一些初始化代码
}
App.prototype = {
	//游戏相关代码
};
App.prototype.constructor = App;

当然,我们的代码也需要减少对外部变量的依赖。因为JS灵活的闭包机制,我们可以方便的使用外部变量,但这并不利于我们的封装,最好的做法则是将其封装于其中:

function App(config) {
	this.config = config;
}
App.prototype = {
	config: null,
	init: function() {
		//使用this.config操作
	}
	//此处省略几万行代码(误)
};

当然我们不可避免的会在这里面遇到一些在回调中调用自身的情况,这时候this其实已经不是原来的this了,因此,我们利用JS的闭包写法,来实现变量的传递:

App.prototype = {
	//……
	countdown: function() {
		//这里有个小细节
		var _this = this;
		//这里可以使用this来操作
		this.runtime.timer = setInterval(function() {
			//这里就不能用this了,我们用_this来代替
			_this.runtime.pastTime += 0.5;
		});
	}
};

接下来则是将变量整理整理,例如,游戏运行的数据(如拼图打乱的顺序等)将其封装到runtime中,关于DOM的变量封装到dom中,诸如此类,各位可以按照自己的爱好自由发挥:

App.prototype = {
	config: null,
	runtime: {
		//……
	},
	dom: {
		box: null,
		area: null,
		//……
	}

之后,我们可以再减少对原有HTML的依赖,例如,3×3的拼图需要9个img,之前是使用者自行创建,现在我们可以让JS来代替这个过程,这样一来的话,我们再将其改为4×4、5×5或者其他,就不需要用户再手动修改DOM了。

for (var i = 0; i < total; i++) {
	this.dom.images[i] = document.createElement('li');
	this.dom.images[i].appendChild(document.createElement('img'));
	//……
}

当然,我们游戏归游戏,光玩游戏是没啥意思的,我们还需要和业务捆绑起来,比如,我们增加一个小功能,每拼四张图就可以获得一个小宝箱。因此,我们需要自己实现一个简单的事件回调,来减少游戏逻辑和业务逻辑的耦合度:

App.prototype = {
	//我们把回调函数放到这里
	eventHandler: {
		complete: null,
		timeout: null
	},
	//……
	//这样设置回调比较简单
	setEventHandler: function(name, callback) {
		this.name = callback;
	},
	//……
	//我们在需要回调的地方手动埋点,例如:
	timeout: function() {
		this.runtime.start = 0;
		clearInterval(this.runtime.timer);
		//触发
		if (this.eventHandler.timeout !== null) {
			this.eventHandler.timeout(this);
		}
	}
}

到这一步,其实已经基本上完成了整个封装过程,我们可以将其作为单独的模块放到其他需要用到拼图的页面中:

var Game = new App({
	box: document.getElementById("game-box"),
	startTime: 120
});
Game.setEventHandler('complete', function(g) {
	console.log('拼图+1');
});
Game.setEventHandler('timeout', function(g) {
	alert('游戏结束');
});

任意数量拼图

当然了,光是3×3可不够意思,我们还得让程序支持N×N

首先,是把创建的操作改改

this.dom.images = [];
//计算每个小方块的大小
this.blockSize = Math.floor(this.dom.box.offsetWidth / this.size);
var total = this.size * this.size;
for (var i = 0; i < total; i++) {
	this.dom.images[i] = document.createElement('li');
	this.dom.images[i].appendChild(document.createElement('img'));
	//设置z-index
	this.dom.images[i].style.zIndex = this.zIndex;
	//设置大小
	this.dom.images[i].style.width = this.blockSize + "px";
	this.dom.images[i].style.height = this.blockSize + "px";
	//设置边距
	this.dom.images[i].style.top = (this.blockSize * Math.floor(i / this.size)) + "px";
	this.dom.images[i].style.left = (this.blockSize * (i % this.size)) + "px";
	this.dom.area.appendChild(this.dom.images[i]);
	//这里是用于验证是否完成拼图的数组,当然可以改为其他方式实现
	this.runtime.imgArr.push(i + 1);
	this.runtime.oriArr.push(i + 1);
}

接下来改动一下打乱图片的代码

var imgWidth = Math.floor(this.imgSize / this.size);
var ctx = this.runtime.canvas.getContext('2d');
for (var i = 0; i < this.size; i++) {
	for (var j = 0; j < this.size; j++) {
		//获取image标签,分割后的图片将会放入这里
		var im = this.dom.images[parseInt(this.runtime.imgArr[index - 1] - 1)].querySelector('img');
		//将源图像的一部分绘制到canvas
		ctx.drawImage(this.runtime.loadImage, imgWidth * j, imgWidth * i, imgWidth, imgWidth, 0, 0, 400, 400);
		//标记正确的顺序,用于最后的答案校验
		im.setAttribute('data-seq', index);
		//将canvas里的图像数据写到image标签
		im.src = this.runtime.canvas.toDataURL('image/jpeg');
		index++;
	}
}

至此,便完成了N×N的设计。当然,对于难度就是另一回事了……

拖动拼图

截止到目前为止,拼图仍然是往上划一下挪一格,这可得急死人,玩起来感觉太不友好了,我们得让拼图变成能拖动的!一开始,所有事件都是基于zepto的,zepto基于原生事件封装了swipeLeft、swipeDown等几个触摸事件,但是,原生只有touchstart、touchmove、touchend、touchcancel四个事件。其中,touchstart和touchmove事件响应中会携带坐标,touchend只会在部分浏览器中携带,很遗憾的是,据测试,大部分手机端的浏览器都没有。

现在,我们换成原生JS来实现:

//存一下开始移动的点
var startPoint = {x: 0, y: 0};
//存一下截止上次触发touchmove事件时所在的点
var lastPoint = {x: 0, y: 0};
var startIm = -1;
/**
 * 获取元素的绝对定位
 * touchstart和touchmove事件所传递的点,是相对于整个页面的定位
 * 使用offsetLeft/offsetTop获取到的定位,有时候不是相对于整个页面的
 * 例如我们的父元素是relative定位时,获取到的就是相对于父元素的定位
 */
function getOffset(el) {
	var rect = el.getBoundingClientRect();
	var win = el.ownerDocument.defaultView;
	return {
		top: rect.top + win.pageYOffset,
		left: rect.left + win.pageXOffset
	};
}
//依靠绝对定位,寻找到我们触摸点是位于哪张图片上
function findIm(x, y, exclude) {
	for (var i in _this.dom.images) {
		var elPos = getOffset(_this.dom.images[i]);
		/**
		 * 示意图:
		 * ↓这一点是(elPos.left, elPos.top)
		 * +-----------+ ←这一点是(elPos.left + _this.blockSize, elPos.top)
		 * |           |
		 * |           |
		 * |           |
		 * |           |
		 * |           |
		 * +-----------+ ←这一点是(elPos.left + _this.blockSize, elPos.top + _this.blockSize)
		 * ↑这一点是(elPos.left, elPos.top + _this.blockSize)
		 * 因此,我们判断触摸点是否位于一块拼图中
		 * 只需要比较坐标即可
		 */
		if (i != exclude && x > elPos.left && x < elPos.left + _this.blockSize && y > elPos.top && y < elPos.top + _this.blockSize) {
			return i;
		}
	}
	return -1;
}
//触摸事件开始
function onTouchStart(e) {
	/**
	 * 分别检查开始标记、触摸标记和完成标记
	 * 触摸标记是用于只响应单指触摸,防止一些奇怪的问题
	 */
	if (!_this.runtime.start || startIm !== -1 || _this.runtime.isJustComplete) {
		return;
	}
	startPoint.x = e.touches[0].pageX;
	startPoint.y = e.touches[0].pageY;
	lastPoint.x = e.touches[0].pageX;
	lastPoint.y = e.touches[0].pageY;
	startIm = findIm(startPoint.x, startPoint.y);
	//拖动单个图像会使其“浮动”到其他图片上面,我们通过设置不同的z-index实现
	_this.dom.images[startIm].style.zIndex = _this.zIndex + 1;
}
function onTouchMove(e) {
	if (!_this.runtime.start) {
		return;
	}
	lastPoint.x = e.touches[0].pageX;
	lastPoint.y = e.touches[0].pageY;
	//通过目前触摸所在点减去触摸开始点,分别获得x、y方向上的移动距离,通过设置margin实现实时移动
	_this.dom.images[startIm].style.marginTop = (lastPoint.y - startPoint.y) + "px";
	_this.dom.images[startIm].style.marginLeft = (lastPoint.x - startPoint.x) + "px";
}
function onTouchEnd(e) {
	if (!_this.runtime.start) {
		return;
	}
	//把元素移动回原来的位置
	_this.dom.images[startIm].style.marginTop = 0;
	_this.dom.images[startIm].style.marginLeft = 0;
	_this.dom.images[startIm].style.zIndex = _this.zIndex;
	var endIm = findIm(lastPoint.x, lastPoint.y, startIm);
	if (endIm === -1) {
		startIm = -1;
		return;
	}
	//交换开始的图像和结束的图像
	var t = _this.dom.images[startIm].innerHTML;
	_this.dom.images[startIm].innerHTML = _this.dom.images[endIm].innerHTML;
	_this.dom.images[endIm].innerHTML = t;
	//初始化触摸标记
	startIm = -1;
	//检查是否完成了拼图
	_this.check();
}

最后

有一些小细节我其实想单独说一下,首先,为什么大量使用var而不使用更简单、更“先进”的let、const等ES6、ES7的语法呢?主要是为了兼容性考虑。即使是在微信中运行,我们依然没有足够的时间确认是否所有的微信都能完美兼容,因此我们还是尽可能少的使用新的语法。

然后放上前端页面的代码,点击下载【密码:09tJ】