4

先不废话,请看演示


拼图游戏开始游戏

公司要搞这么个微信活动,可现在没有前端开发,没办法,身为打杂总监只好临时顶下这个空缺了。先找了一些 JS 代码,试用了下都不太理想,好一点的写的又太复杂,改起来有难度,干脆撸起袖子自己干。

基本需求

有一个固定区域,被拆分成 c*r 个同等大小的碎片,拿走其中一块,靠近缺口的块可以向缺口方向移动;当拼出原来的图样视为完成。

依照此需求,需要经历 加载图片-》拆分图片-》随机打散-》移动碎片-》判定完成 这些步骤。为了更有可玩性,能自行选择自己的图片就更妙了。

下面就重点说明下各个步骤,为编写方便,引入 jQuery 作为辅助库。

加载图片

首先当然是载入图片,计算宽高,对比拼图区域的尺寸进行缩放,如果比例不同,还得“裁剪”掉多余的部分。

/**
 * 加载图片
 * cal 的回调参数为:
 *  ox 横向偏移
 *  oy 纵向偏移
 *  this 指向载入的图片的 jQuery 对象
 * @param {String} src 图片路径
 * @param {int} w 额定宽
 * @param {int} h 额定高
 * @param {Fucntion} cal 加载完成后的回调方法
 */
function loadr(src, w, h, cal) {
    var img  =  new Image();
    img.onload = function() {
        var xw = img.width ;
        var xh = img.height;
        var zw = xh * w / h;
        if (zw > xw) {
            // 宽度优先
            img.width   = w;
            img.height  = xh * w / xw;
            xh = (h - img.height) / 2;
            xw = 0;
        } else {
            // 高度优先
            img.height  = h;
            img.width   = xw * h / xh;
            xw = (w - img.width ) / 2;
            xh = 0;
        }

        cal.call(img, xw, xh);
    };
    img.src = src ;
}

以上的“裁剪”仅仅是计算出偏移,然后将其传递给加载就绪的回调函数。

拆分图片

图有了,已缩放,现在需要“拆分”成碎片。这里自然不是真的切割了,而是将图片 clone 出 c*r 片,然后利用负的坐标定位,其实质是用一个块遮盖了“切除”的部分,仅显示需要的碎片部分。

/**
 * 拆分图片
 * @param {jQuery} that 容器对象
 * @param {int} cols 行
 * @param {int} rows 列
 * @param {int} ew 板块宽度
 * @param {int} eh 板块高度
 * @param {int} ox 图片横向偏移
 * @param {int} oy 图片纵向偏移
 * @param {Image} im 图片对象
 */
function split(that, cols, rows, ew, eh, ox, oy, im) {
    that.empty();

    for(var j = 0 ; j < rows; j ++) {
        for(var i = 0 ; i < cols; i ++) {
            var k = i + j * rows;

            var pic = $('<div class="pt-pic"></div>');
            pic.attr("id", "pt-pic-"+k);
            pic.data("idx", k);
            pic.appendTo(that);
            pic.css ({
                "position": "relative",
                "overflow": "hidden",
                "border"  : "0",
                "width"   : ew + "px",
                "height"  : eh + "px"
            });

            var img = $(im.cloneNode());
            img.appendTo(pic);
            img.css ({
                "position": "absolute",
                "z-index" : "88",
                "border"  : "0",
                "left"    : (0 - i * ew + ox) + "px",
                "top"     : (0 - j * eh + oy) + "px"
            });

            // 因边框可能影响宽高计算, 故边框单独用一个块来放
            var bor = $('<div class="pt-bor"></div>');
            bor.appendTo(pic);
            bor.css ({
                "position": "absolute",
                "z-index" : "99",
                "width"   : "100%",
                "height"  : "100%"
            });
            // 由于样式宽高并不含边框, 故再次计算尺寸的偏移量
            bor.css ({
                "width"   : (2 * bor.width () - bor.outerWidth ()) + "px",
                "height"  : (2 * bor.height() - bor.outerHeight()) + "px"
            });
        }
    }
}

稍微注意,为方便人眼分辨碎片,最好给碎片加个边框,但加边框必然影响坐标的计算,故在图片上再覆盖一层,边框设在他上面,就算加个撕裂效果的透明图做边框都没问题了。这样碎片内图片的偏移坐标的计算就少了些麻烦了。

随机打散

这游戏当然是跟电脑玩了,总不能自己打散自己玩吧?但这个打散不能给每个图片一个随机位置,那很可能你永远也拼不回去了。小时拿那种拼图游戏板整人就干过这种事,故意抠下来把头和脚交换再打散,然后跟其他小朋友打赌。所以程序也得守规矩一块一块的移动。

/**
 * 打散图片
 * @param {jQuery} that 容器对象
 * @param {int} cols 列
 * @param {int} rows 行
 * @param {int} rand 打散步数
 */
function upset(that, cols, rows, rand) {
    var v ;
    var r = Math.floor(Math.random()  *  cols  *  rows);
    var hole = that.children().eq(r).addClass("pt-pix");
    var part ;
    var step = [];
    var dbug = [];
    for(var  i = 0, j = rand; i < j; i ++) {
        var  x = cols - 1;
        var  y = rows - 1;
        var  z = cols;
        var rx = r % cols;
        var ry = Math.floor(r / cols);
        var rv = [];

        if (rx > 0 && rx < x) {
            rv.push(r - 1, r + 1); // 可左右移动
        } else
        if (rx > 0) {
            rv.push(r - 1); // 可向左移动
        } else
        {
            rv.push(r + 1); // 可向右移动
        }
        if (ry > 0 && ry < y) {
            rv.push(r - z, r + z); // 可上下移动
        } else
        if (ry > 0) {
            rv.push(r - z); // 可向上移动
        } else
        {
            rv.push(r + z); // 可向下移动
        }

        // 排除来源位置
        if (step.length > 0) {
            v = step[step.length - 1];
            v = $.inArray(v, rv);
            if (v > -1) {
                rv.splice(v, 1 );
            }
        }
        // 排除回旋位置
        if (step.length > 2 && rv.length > 1) {
            v = step[step.length - 3];
            v = $.inArray(v, rv);
            if (v > -1) {
                rv.splice(v, 1 );
            }
        }

        // 随机方向
        r = rv[Math.floor(Math.random()* rv.length)];
        v = hole.index();
            step.push(v);

        // 交换位置
        part  = that.children().eq( r );
        if (r < v) {
            part.insertBefore(hole);
            hole.insertBefore(that.children().eq(r));
        } else {
            hole.insertBefore(part);
            part.insertBefore(that.children().eq(v));
        }

        // 调试步骤
        if (r == v + 1) {
            dbug.push("左");
        } else
        if (r == v - 1) {
            dbug.push("右");
        } else
        if (r > v) {
            dbug.push("上");
        } else
        if (r < v) {
            dbug.push("下");
        }
    }

    // 攻略
    dbug = dbug.reverse().join(" "); alert(dbug);
    console.log( "攻略: "+dbug+"\r\n此非最优解, 仅为随机打散时的逆向步骤, 上下左右为相对缺口的板块, 祝您玩的开心!" );
}

把打散的步骤记录下来,然后反转数组,就是攻略啦。

不过随机时需要避免往回走,否则出现 左->右->左 这类情况就不好玩了;还得避免其他循环,如 上->右->下->左 这样的,这会回到原点,等于什么也没干;但更大的循环没想好怎么处理,暂时不去纠结了。

移动判定

移动碎片到缺口,也就是交换碎片与缺口的位置。左右移动很简单,序号大的 insertBefore 序号小的即可。上下移动有个小坑,开始自己没注意,我原本想不管横向还是纵向,没有两次 insertBefore 搞不定的,但是如果 3 和 7 交换位置(3x3, 0~8),3 移动到 7 前,7 再移动到 3 前,此时原来的 3 变成了 6。的确,没有什么是不能两次 insertBefore 解决的,但还得考虑让序号大的先动。

/**
 * 移动板块
 * @param {jQuery} that 容器对象
 * @param {int} cols 列数
 * @param {int} rows 行数
 * @param {jQuery} hole 缺口对象
 * @param {jQuery} part 板块对象
 */
function mover(that, cols, rows, hole, part) {
    var move = false ;
    var i  = part.index();
    var j  = hole.index();
    var ix = i % cols;
    var jx = j % cols;
    var iy = Math.floor(i / cols);
    var jy = Math.floor(j / cols);

    if (iy == jy) { // 在同一行
        move  = ix == jx + 1  // 可向左边移动
             || ix == jx - 1; // 可向右边移动
    } else
    if (ix == jx) { // 在同一列
        move  = iy == jy + 1  // 可向上移动
             || iy == jy - 1; // 可向下移动
    }

    // 互换位置
    if (move) {
        if (i  <  j ) {
            part.insertBefore(hole);
            hole.insertBefore(that.children().eq(i));
        } else {
            hole.insertBefore(part);
            part.insertBefore(that.children().eq(j));
        }
    }

    // 判断是否拼图完成
    move = true;
    for (i = 0, j = cols * rows; i < j; i ++) {
        if (that.children().eq(i).data("idx") != i) {
            move = false;
        }
    }

    return  move;
}

判断是否完成就来个笨办法吧,依次遍历所有碎片,只要有一个没对上序号就是还没成功。

未处理滑动事件,以后闲了再加吧。

整合游戏程序

上面分散的几个函数用起来还是不太方便,整合成一个 jQuery 插件。

/**
 * 拼图游戏
 * @param {String} src 图片路径
 * @param {int} cols 列数
 * @param {int} rows 行数
 * @param {int} rand 打散步数
 */
$.fn.hsPintu = function(src, cols, rows, rand) {
    var that = $(this);
    var srz  = that.data("src");
    var img  = that.data("img");

    var aw = that.width ();
    var ah = that.height();
    var ew = aw / rows;
    var eh = ah / cols;

    // 状态: 0 进行中, 1 成功, 2 结束
    that.data("hsPintuStatus", 2);
    that.data("cols", cols);
    that.data("rows", rows);

    /**
     * img 存在且 src 没变化
     * 则不需要再次加载图片
     * 直接取出存储好的数据
     */
    if (img && srz === src) {
        var ox = that.data("pos_x");
        var oy = that.data("pos_y");
        console.log("Note: 图片无变化");

        split(that, cols, rows, ew, eh, ox, oy, img );

        // 未给 rand 则仅拆分而不打散
        if (rand === undefined) return;

        upset(that, cols, rows, rand);
        that.data("hsPintuStatus", 0);
        that.trigger("hsPintuLaunch");
    } else
    loadr(src, aw, ah, function(ox, oy) {
        that.data("src", src );
        that.data("img", this);
        that.data("pos_x", ox);
        that.data("pos_y", oy);
        console.log("Note: 载入新图片");

        split(that, cols, rows, ew, eh, ox, oy, this);

        // 未给 rand 则仅拆分而不打散
        if (rand === undefined) return;

        upset(that, cols, rows, rand);
        that.data("hsPintuStatus", 0);
        that.trigger("hsPintuLaunch");
    });

    // 已经初始化过就不要再绑定事件了
    if (! that.data("hsPintuInited")) {
        that.data("hsPintuInited", 1);

        that.on("click", ".pt-pic:not(.pt-pix)", function() {
            if (that.data("hsPintuStatus") === 0) {
                var cols =that.data("cols");
                var rows =that.data("rows");
                var hole =that.children(".pt-pix");
                if (mover(that, cols, rows, hole, $(this))) {
                    that.data("hsPintuStatus", 1);
                    that.trigger("hsPintuFinish");
                }
            }
        });
    }

    return  this;
};

$("#pt-box").hsPintu(图片URL, 列数, 行数[, 随机步数]); 即可初始化拼图游戏了, 拼图区域需要固定宽高;随机步数参数不提供时,仅拆解不打散。

图片没变化时没必要重新加载,避免下时间损耗。当然了,更好的办法是再判断行、列和区域尺寸,没变化则直接排列好碎片。懒得写了,先这样吧。

选择任意图片

上面都是固定的图片,参与感不好,让用户自行“上传”图片岂不更有意思。其实不必真的上传到服务器,既然“缩放”、“裁剪”上面都有了,直接加载本地图片不就好了嘛。

/**
 * 预载文件
 * @param {Function} cal 回调函数
 * @returns {jQuery} 当前文件节点
 */
$.fn.hsFileLoad = function(cal) {
    this.each(function() {
        var that = this;
        if (window.FileReader) {
            var fr = new FileReader( );
            fr.onloadend = function(e) {
                cal.call(that, e.target.result);
            };  cal.call(that);
            $.each( this.files, function(i, fo) {
                fr.readAsDataURL( fo );
            });
        } else
        if (this.getAsDataURL) {
            cal.call(that, that.getAsDataURL());
        } else {
            cal.call(that, that.value);
        }
    });
    return this;
};

这段代码也能从我的开源项目内找到 预载文件 方法,此工具包还有些其他的文件上传预览类的方法,这是我对 bootstrap-fileinput 没有图片裁剪功能(与最终服务端处理后的结果一致)而“一气之下”自己写的一点零散代码。

完整的代码及演示可在 这里 看到,有朋友说看不到图,但图片我用的百度图片搜索的缩略图,不清楚怎么回事,看不到可以自己从本地选择图片。只是那个“加载”(Image.onload)和“切片”(Image.cloneNode)比较耗时,比较大的图片请耐心等等。

当然了,也可以光顾我们的活动页玩一把 拼图抽奖


非墨
2.1k 声望44 粉丝

会出错的总会出错。知乎:[链接]