Leafer 开发小游戏-拼图游戏
前言
本篇文章,我将带领你使用 Leafer 创建一个简单的拼图游戏。通过实现思路、步骤讲解以及代码演示,可以带你轻松上手使用 Leafer 编写游戏,同时可以让没有使用过 Leafer 的开发也能轻松理解并动手尝试。
最终效果图
思路
拼图游戏实现思路简单,功能也复杂,借助 Leafer 我们可以快速实现一个拼图游戏项目。Leafer 提供了高度封装的 Canvas 操作API,让我们能专注于游戏逻辑,而不需过多关注底层实现,整体开发体验非常良好。
拼图游戏的核心逻辑包括以下几步:
创建拼图容器:用于存储和显示所有的拼图块。 拆分图像:将一张完整的图片切割成若干小块,形成拼图。 标记并打乱图像块:为每一块拼图标记正确的位置,然后将它们打乱顺序。 拖拽与交互:允许玩家拖动拼图块,并在合适的地方释放。 检查排序:实时检查拼图块的当前位置是否符合其最初的位置,确定游戏是否完成。
实现
下面将详细讲解如何使用 Leafer 进行实现,只实现相关核心步骤,不会贴出完整代码,相关完整代码已经开源到 github,链接放在文章末尾,感兴趣自行 clone 查阅。
创建拼图容器
使用 Leafer 的 App 结构来初始化我们的游戏环境。这不仅便于我们管理游戏中的元素,也为后续可能的扩展提供了便利。
function createGameApp(view) {
const app = new App({
view,
fill: 'transparent',
move: {
disabled: true
},
zoom: {
disabled: true
}
})
app.tree = app.addLeafer();
return app;
}
我们使用 Box
创建了一个 500x500 的拼图容器。使用 Box
的好处在于它可以自动处理边界检测,让后续的图片拖拽逻辑更加简洁,不在需要我们自己实现容器范围内的边界检测。。
function createWrapper() {
return new Box({
width: 500,
height: 500,
x: 0,
y: 0,
stroke: '#3aafff',
fill: 'transparent',
});
}
将容器添加到 app 之后,就完成了我们的容器创建部分。
const app = createGameApp('game')
const wrapper = createWrapper();
app?.tree.add(wrapper)
拆分图像
开发拼图游戏的核心功能在于将一张图片拆分成 n * n 张图片。在 Leafer 中,我们通过设置 Rect
的 fill
属性来实现图片的展示,这样我们可以方便的使用 clip
模式从原图中裁剪出所需区域。
首先,我们创建一个 500x500 的矩形,并将其 fill
属性设置为图片:
new LeaferRect({
width: 500,
height: 500,
x: 0 ,
y: 0,
fill: {
type: 'image',
url: url,
},
});
接下来,我们可以通过设置 fill.mode
为 'clip'
和 fill.offset
来指定图片的裁剪区域。例如,如果我们要从一张 500x500 的图片中裁剪出 100x100 的区域,效果如下:
我们将 rect
填充模式设置为 clip
后,切割位置设置为 {x: 100, y: 100}
new LeaferRect({
width: 500,
height: 500,
x: 0 ,
y: 0,
fill: {
type: 'image',
url: url,
// clip 模式
mode: 'clip',
// 切割的位置
offset: {x: 100, y: 100}
},
});
这样,我们就可以看到图片的红色部分被裁剪出来了,而灰色的背景实际效果是透明。
如果 offset
的坐标为负数,则图片会向左/向上平移,从而裁剪出不同的区域。
offset: {x: -100, y: -100}
效果如下:
我们现在已经知道了图片平移的方式,在 clip
模式下,图片不会自适应宽高,为了控制裁剪后图片的大小,我们还可以设置 width
和 height
属性。例如,我们想要显示一个 100x100 的小方块:
new LeaferRect({
width: 100,
height: 100,
x: 0 ,
y: 0,
fill: {
type: 'image',
url: url,
// clip 模式
mode: 'clip',
// 切割的位置
offset: {x: 0, y: 0}
},
});
对于拼图游戏来说,我们可以根据 offset
的坐标值,有规律地裁剪出 n * n 个小方块,然后将它们拼接起来,就能得到一个完整的图片。
例如,第一个方块的 offset
为 { x: 0, y: 0 }
,第二个为 { x: -100, y: 0 }
,第三个为 { x: -200, y: 0 }
,依此类推。当第一行的 5 个方块裁剪完成后,第二行的第一个方块的 offset
就可以设置为 { x: 0, y: -100 }
,以此类推,直到完成所有的方块。
现在我们只要根据上面得出的规律将 5x5 张图片进行裁剪即可。
// 拆分格数
const count = 5
// 每格大小
const size = 100
for(let i = 0; i < Math.pow(count, 2); i++) {
// 计算当前 x 位置: 通过取余获取每行的第几个
const x = (i % count) * size;
// 计算当前 y 位置: 每 count 个往下换一行
const y = Math.floor(i / count) * size;
const img = new Rect({
x,
y,
width: size,
height: size,
fill:{
type: 'image',
url: '/puzzle/500x500.jpg',
mode: 'clip',
// 这时通过 -x, -y 刚好也能满足图片展示的平移位置
offset: {x: -x, y: -y}
}
})
wrapper.add(img)
}
标记并打乱图像块
根据上面的算法,我们已经拆分成了 n * n 个图片,现在我们需要给每一块拼图标记正确的顺序,用于后续校验。
data
属性是 leafer 提供用户存储数据的,我们可以在里面存储自定义数据,通过设置 draggable
和 dragBounds
我们可以限制图片在 box
内拖拽。
const count = 5
const size = 100
// 创建一个数组存储所有图片
let images = [];
for(let i = 0; i < Math.pow(count, 2); i++) {
const x = (i % count) * size;
const y = Math.floor(i / count) * size;
const img = new Rect({
x,
y,
width: size,
height: size,
fill:{
type: 'image',
url: '/puzzle/500x500.jpg',
mode: 'clip',
offset: {x: -x, y: -y}
},
// 存储当前的 序号
data: {sortId: i},
// 使图片可拖拽
draggable: true,
// 设置 dragBounds: 'parent' 后,拖拽时将会自动检测是否在 box 范围内
// 通过这个属性,我们省去了容器边界检测的工作。
dragBounds: 'parent',
})
images.push(img)
wrapper.add(img)
}
我们直接通过 wrapper.children 打乱这些图块供玩家重新排序,因为 images 数组我们是按序存放的,所以乱序后,我们再遍历所有 wrapper.children 再从 images 中取出原图片位置进行替换,同时给图片设置一个 current 属性标记乱序后的位置,这样我们最后只需要检查所有图片的 sortId 是否等于 current 即可。
function shuffleImages() {
const imagePos = images.map(item => ({x: item.x, y: item.y}))
wrapper.children.sort(() => Math.random() > Math.random() ? -1 : 1)
wrapper.children.forEach((node, idx) => {
// 更新图片的位置
node.set(imagePos[idx])
//
node.data!.current = idx;
})
images = [...wrapper.children]
}
拖拽与交互
监听每个图片拖拽事件,同时记录拖拽的节点和原始的 x
, y
, 再通过 DragEvent.setData
使 drop
时可以读取到数据。
let dragNode = null;
let [x, y] = [0, 0];
image.on(DragEvent.START, (evt) => {
const node = evt.target;
if (!node) return
node.zIndex = 10000;
x = node.x;
y = node.y;
dragNode = node;
DragEvent.setData({x, y, dragNode})
})
监听 drop
事件,并进行移动行为的校验,只允许用户从上下左右四个方向相邻的图片进行交换,通过 evt.data
可以读取到通过 DragEvent.setData
设置进去的值。
因为我们在之前在节点内部记录了了 data.current
当前位置的值,所以当用户交换图片位置时同时将两个图片的 data.current
进行交换,交换之后,再进行 checkSort
检查是否完成拼图。
image.on(DropEvent.DROP, (evt) => {
const node = evt.target
const {x, y, dragNode} = evt.data || {}
if (!node || !dragNode) return
// 校验是否斜角移动
if (node.x !== dragNode.x && node.y !== dragNode.y) return
// 校验 x 移动格数
if (node.x >= dragNode.x + (dragNode.width * 2) || node.x < dragNode.x - dragNode.width) {
return
}
// 校验 y 移动格数
if (node.y >= dragNode.y + (dragNode.height * 2) || node.y < dragNode.y - dragNode.height) {
return
}
// 交换 current 位置
const targetIdx = node.data.current;
const dragNodeIdx = dragNode.data.current;
dragNode.data.current = targetIdx;
node.data.current = dragNodeIdx;
// 交换节点位置
dragNode.set({x: node.x, y: node.y});
node.set({x, y});
// 检查是否成功
if (isCompleted()) {
message.success('恭喜你,完成拼图')
// 完成拼图后解绑事件,避免游戏结束还能拖拽
images.forEach((item) => {
item.draggable = false
item.off()
})
}
})
监听鼠标的 dragend 事件,用于恢复拖拽图片的位置。
image.on(DragEvent.END, () => {
if (!dragNode) return
dragNode.set({zIndex: 1, x, y})
dragNode = null;
})
检查排序
检查是否通过排序就很简单了,因为我们在节点内记录了正常顺序序号sortId
, 以及当前位置的序号current
, 只要遍历所有图片检查这两个值是否相等即可。
/**
* 检查排序
*/
function isCompleted() {
return this.images.every((item) => item.data!.current === item.data!.sortId);
}
结语
通过以上步骤,我们梳理并实现了一个基本的拼图游戏。整个实现下来实际非常简单,主要难点在于思考如何对图片的切割,其次就是如何检查是否完成拼图。
相关链接
在线体验: https://alexpang.cn/leafer-games/ 游戏源码: https://github.com/Alessandro-Pang/leafer-games