拼图游戏的诞生

@泷涯  December 29, 2017

成果预览

先放上最后的成果吧~

2017-12-27_140658.png

完整的代码在文章结尾将会给出。这个小游戏一开始只是从互联网上随便找的一个代码。然后我玩起来各种不爽,就开始优(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】

文章作者:微光网络工作室 泷涯


添加新评论