最近使用Canvas实现了一个N宫格拼图的游戏,感觉效果还是很不错的,不过我还是觉得九宫格就好了,太多了反而就复杂了。这里我就主要讲述九宫格的实现过程,其它的只是把数据结构扩大一下了。
实现效果
图片效果
视频效果
https://www.bilibili.com/video/BV15f4y1g7EX?t=137.9
大体设计思路
鼠标移动时依次绘制网格矩形,同时判断鼠标落点所在网格起始位置;当鼠标离开或者移出时,依据鼠标落点最后所在的网格的起点绘制矩形;不能跨图形移动,不能斜着移动,只能上下左右四个方向进行移动。
乱序排列
首先需要得到一个N宫格图片,但是我们通常只有一个原图,所以需要去在程序中将图片裁剪好。上面可以看到,九宫格是去掉了一个,作为移动的空格,这个空格的位置是不固定的,但是它原来一定是整个图片的右下角,只不过它们被打乱了顺序。
我这里将N-1张图片放入一个长度为N的一维数组中,然后对它们进行一个洗牌算法,这样每次生成的拼图都是不同的了。
// 洗牌算法,用于打乱数组的顺序(打乱拼图)
function shuffle(arr) {
let len = arr.length;
while (len) {
let idx = parseInt(Math.random() * len); // [0, len-1]
[arr[idx], arr[len-1]] = [arr[len-1], arr[idx]]
len--;
}
return arr;
}
排列逻辑
这里九宫格的每一个块的位置都是固定的,这里可以直接使用程序生成。使用一个二维数组,在遍历的过程中其实就是获取每一个块的位置,顺序是按照行从左到右,从上往下进行遍历。
let row = [0, 150, 300]; // 行坐标
let col = [0, 200, 400]; // 列坐标
for (let i = 0; i < row.length; i++) {
for (let j = 0; j < col.length; j++) {
let imgData = ctx.getImageData(row[j], col[i], 150, 200);
let data = {
imgData: imgData,
x: 0, // 这里设置一个初始值
y: 0,
}
rectDatas.push({
x: row[j],
y: col[i],
});
imgDatas.push(data);
}
}
移动逻辑
这里并不是直接选中了图片,而是选中了图片所在的矩形框。实际上的控制方式是:按下鼠标,绘制图形块,然后绘制矩形框,通过比对鼠标选中的点是否在矩形框内部,判断选中的图形块(图形块和矩形框在一个位置,且等大)。当用户移动的时候,此时一直进行上述工作,不断更新鼠标当前落地所在的矩形框,知道鼠标松开的时候,此时鼠标如果落在了一个可以移入的空的矩形框,那么就将图片移入其中。这个移入的逻辑就是交换鼠标按下时选中图形框对应的图形块和最后鼠标落点处的图形块,然后重新绘制整个Canvas。这样用户就会感觉到两张图片被交换了。
完整代码
这里我觉得比较有意思的地方就是移动的逻辑设计、N宫格的位置排列和最后的数据交换,尤其是数据交换这部分一开始是真的难住我了,出现了一个奇怪的bug,最后发现是由于对象的浅克隆导致的,这是真的烦人了!因为时隔已久,很多东西都要靠回忆才能想起来了。不过如果你有什么地方有疑惑或者问题我们可以在评论中进行交流,不过在那之前希望好好运行代码、调试一番。
<!DOCTYPE html>
<html>
<head>
<title>测试</title>
<meta charset="utf-8">
</head>
<body>
<div style="width: 600px; margin: 0 auto;">
<canvas id="cs" width="450" height="600" style="border: 1px solid red"></canvas>
</div>
<script>
let canvas = document.getElementById("cs");
let ctx = canvas.getContext("2d");
let rewidth = 450;
let isDown = false;
let current = 0; // 当前图片
let next = 0; // 下一张图片
let imgDatas = []; // 图片拼图数组
let rectDatas = []; // 方块数组
ctx.strokeStyle = "rgba(255, 255, 255, 0)";
let row = [0, 150, 300];
let col = [0, 200, 400];
// 洗牌算法,用于打乱数组的顺序(打乱拼图)
function shuffle(arr) {
let len = arr.length;
while (len) {
let idx = parseInt(Math.random() * len); // [0, len-1]
[arr[idx], arr[len-1]] = [arr[len-1], arr[idx]]
len--;
}
return arr;
}
function drawRect(x, y, w, h) {
ctx.beginPath();
ctx.rect(x, y, w, h);
ctx.stroke();
}
// 根据传入的指定宽度,自动调整高度
function resize(img, width) {
let w = img.width;
let h = img.height;
return parseInt(h*width/w);
}
let img = document.createElement("img");
img.src = "./puzzle.jpg";
img.onload = () => {
let h = resize(img, rewidth);
console.log("img height => " + h);
ctx.drawImage(img, 0, 0, rewidth, h);
for (let i = 0; i < row.length; i++) {
for (let j = 0; j < col.length; j++) {
let imgData = ctx.getImageData(row[j], col[i], 150, 200);
let data = {
imgData: imgData,
x: 0, // 这里设置一个初始值
y: 0,
}
rectDatas.push({
x: row[j],
y: col[i],
});
imgDatas.push(data);
}
}
// 将最后一个置空,即拼图右下角
imgDatas[imgDatas.length-1].imgData = null;
// 执行洗牌算法,打乱拼图顺序
imgDatas = shuffle(imgDatas);
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < rectDatas.length; i++) {
imgDatas[i].x = rectDatas[i].x;
imgDatas[i].y = rectDatas[i].y;
}
// 初始可以移动的方块
for (let i = 0; i < rectDatas.length; i++) {
if (imgDatas[i].x == 300 && imgDatas[i].y == 400) {
console.log("初始可以移动的位置是:%d", (i+1));
}
}
// 绘制初始拼图,不绘制最后一个拼图
drawPuzzles();
}
// 绘制puzzle函数
function drawPuzzles() {
for (let i = 0; i < imgDatas.length; i++) {
if (imgDatas[i].imgData) {
ctx.putImageData(imgDatas[i].imgData, imgDatas[i].x, imgDatas[i].y);
}
}
}
canvas.onmousedown = e => {
isDown = true;
let x = e.pageX - canvas.offsetLeft;
let y = e.pageY - canvas.offsetTop;
for (let i = 0; i < rectDatas.length; i++) {
drawRect(rectDatas[i].x, rectDatas[i].y, 150, 200);
// 注意这个判断条件
if (ctx.isPointInPath(x, y) && imgDatas[i]) {
current = i;
console.log("点击了第 %d 张图片", (i+1));
ctx.strokeStyle = "red";
ctx.strokeRect(rectDatas[i].x, rectDatas[i].y, 150, 200);
ctx.strokeStyle = "rgba(255, 255, 255, 0)";
}
}
console.log("down");
};
canvas.onmousemove = e => {
if (!isDown) {
return;
}
let x = e.pageX - canvas.offsetLeft;
let y = e.pageY - canvas.offsetTop;
for (let i = 0; i < rectDatas.length; i++) {
drawRect(rectDatas[i].x, rectDatas[i].y, 150, 200);
if (ctx.isPointInPath(x, y)) {
next = i;
console.log("当前在 %d 个矩形中", (i+1));
}
}
};
let up_out = e => {
// 松开事件和移出事件都会执行该函数,造成重复执行了,这里进行一下过滤
if (!isDown) {
return;
}
isDown = false;
// 如果存在直接返回,必须移动到空缺位置上
if (imgDatas[next].imgData) {
return;
}
// 只能上下左右四个方向移动,不能斜着或者跨行移动
let v = next-current;
if (v != 1 && v != -1 && v != 3 && v != -3) {
console.log("只能上下左右四个方向移动,不能斜着或者跨行移动");
return;
}
// 清空canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
[imgDatas[next], imgDatas[current]] = [imgDatas[current], imgDatas[next]]
// 下面这种赋值方式会造成浅拷贝的问题,这里我使用了ES6的解构赋值
// imgDatas[next] = imgDatas[current];
// 交换图片后,坐标也需要更新
imgDatas[next].x = rectDatas[next].x;
imgDatas[next].y = rectDatas[next].y;
imgDatas[current].imgData = null;
// 赋值为空,直接对引用赋值是一个问题,应避免
// 如果不想使用解构赋值,则使用这种下面这行代码
// imgDatas[current] = {imgData: NaN, x: imgDatas[current].x, y: imgDatas[current].y};
// 重绘制
drawPuzzles();
console.log("up_out");
}
canvas.onmouseup = up_out;
canvas.onmouseout = up_out;
</script>
</body>
<html>
使用方式
这里不能直接在本地使用浏览器打开这个文件,因为这里Canvas有一个跨域的问题。所以我就在VSCode上使用Live Server打开了或者你可以使用其它方式,只要使用Http的方式打开就行了。如果你有python3环境的话,我推荐一种快速开启一个HTTP服务的方式:
python -m http.server # 默认是绑定到8000端口,当然了也可以自定义
说明
一个临时兴起的小玩意,倒也花费了一些时间来思考这个过程。我喜欢这种方式,不断的思考、解决问题,不过我倒也是懈怠了,在思考的过程中穿插着打王者。其实是在打王者的过程中穿插着思考,哈哈,不过这种做法本来就不太好,最终落得个王者没打好,实现也拖延了,因为按照我的预期实现应该挺快的。我这篇博客也是拖延了两个星期了,本来应该早就写好的。现在再让我回顾这个代码,我又删除了一些东西,因为当初测试的时候我发现了一些不符合我预期的东西,但是它不影响使用。今天当我写博客的时候,我又开始疑惑了这个设计的作用是什么?(写博客当然要严谨一些,免得落人笑柄。)最后我发现也没几个地方使用了它,我思考了一会,感觉它好像没有什么用。果然,我删除了也确实不影响,反而使得代码更加精简了。