查看原文
其他

Leafer 开发小游戏-拼图游戏

子洋 子洋的摘星阁
2024-10-25

前言

本篇文章,我将带领你使用 Leafer 创建一个简单的拼图游戏。通过实现思路、步骤讲解以及代码演示,可以带你轻松上手使用 Leafer 编写游戏,同时可以让没有使用过 Leafer 的开发也能轻松理解并动手尝试。

最终效果图

思路

拼图游戏实现思路简单,功能也复杂,借助 Leafer 我们可以快速实现一个拼图游戏项目。Leafer 提供了高度封装的 Canvas 操作API,让我们能专注于游戏逻辑,而不需过多关注底层实现,整体开发体验非常良好。

拼图游戏的核心逻辑包括以下几步:

  1. 创建拼图容器:用于存储和显示所有的拼图块。
  2. 拆分图像:将一张完整的图片切割成若干小块,形成拼图。
  3. 标记并打乱图像块:为每一块拼图标记正确的位置,然后将它们打乱顺序。
  4. 拖拽与交互:允许玩家拖动拼图块,并在合适的地方释放。
  5. 检查排序:实时检查拼图块的当前位置是否符合其最初的位置,确定游戏是否完成。

实现

下面将详细讲解如何使用 Leafer 进行实现,只实现相关核心步骤,不会贴出完整代码,相关完整代码已经开源到 github,链接放在文章末尾,感兴趣自行 clone 查阅。

创建拼图容器

使用 Leafer 的 App 结构来初始化我们的游戏环境。这不仅便于我们管理游戏中的元素,也为后续可能的扩展提供了便利。

function createGameApp(view) {  
  const app = new App({  
   view,  
   fill'transparent',  
   move: {  
    disabledtrue  
   },  
   zoom: {  
    disabledtrue  
   }
  })  
  app.tree = app.addLeafer();
  return app;
}

我们使用 Box 创建了一个 500x500 的拼图容器。使用 Box 的好处在于它可以自动处理边界检测,让后续的图片拖拽逻辑更加简洁,不在需要我们自己实现容器范围内的边界检测。。

function createWrapper() {  
  return new Box({  
    width500,  
    height500,  
    x0,  
    y0,  
    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({  
  width500,  
  height500,  
  x0 ,  
  y0,  
  fill: {  
   type'image',  
   url: url,  
  },  
});

接下来,我们可以通过设置 fill.mode 为 'clip' 和 fill.offset 来指定图片的裁剪区域。例如,如果我们要从一张 500x500 的图片中裁剪出 100x100 的区域,效果如下:

我们将 rect 填充模式设置为 clip 后,切割位置设置为 {x: 100, y: 100}

 new LeaferRect({  
  width500,  
  height500,  
  x0 ,  
  y0,  
  fill: {  
   type'image',  
   url: url,  
   // clip 模式
   mode'clip',  
   // 切割的位置
   offset: {x100y100}  
  },  
});

这样,我们就可以看到图片的红色部分被裁剪出来了,而灰色的背景实际效果是透明。

如果 offset 的坐标为负数,则图片会向左/向上平移,从而裁剪出不同的区域。

offset: {x-100y-100}  

效果如下:

我们现在已经知道了图片平移的方式,在 clip 模式下,图片不会自适应宽高,为了控制裁剪后图片的大小,我们还可以设置 width 和 height 属性。例如,我们想要显示一个 100x100 的小方块:

 new LeaferRect({  
  width100,  
  height100,  
  x0 ,  
  y0,  
  fill: {  
   type'image',  
   url: url,  
   // clip 模式
   mode'clip',  
   // 切割的位置
   offset: {x0y0}  
  },  
});

对于拼图游戏来说,我们可以根据 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 提供用户存储数据的,我们可以在里面存储自定义数据,通过设置 draggabledragBounds 我们可以限制图片在 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},  
    // 使图片可拖拽
    draggabletrue,  
    // 设置 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] = [00];  
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({zIndex1, 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


继续滑动看下一个
子洋的摘星阁
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存