手把手带你实现自己的专属“羊了个羊”,想通关就通关!

2,195 阅读8分钟

项目背景

前段时间 羊了个羊 这款小游戏火爆朋友圈,可以说是圈粉无数。国庆期间临时起意,打算自己也实现一个类似的小游戏,于是有了 果了个果 在线体验

合成3.gif

完成了项目还不过瘾,打算将开发项目过程中的思路和细节进行梳理,让广大朋友都能学会,实现自己的专属“羊了个羊”!(项目源码地址在文章末

觉得文章不错、或对自己开发有所帮助,欢迎点赞收藏!❤❤❤

项目准备

工欲善其事必先利其器,首先得为自己的小游戏找到合适的素材

1、iconfont 微信图片_20221017222136.png

2、花瓣网 微信图片_20221017222352.png

3、羊了个羊本地文件 微信图片_20221017222938.png

微信图片_20221017223230.png

准备好了项目素材和音效文件,正式开始我们的项目开发。

项目思路

技术栈选型

  1. 项目使用 Vue3+TS+Vite(主要原因是笔者对Vue更加熟悉),而且Vue相对来说更容易上手。
  2. 卡片层级关系使用 z-index 来实现

z-index 属性设置元素的堆叠顺序。拥有更高堆叠顺序的元素总是会处于堆叠顺序较低的元素的前面。

  1. 卡片位置关系使用 absolute 来实现

布局思路

微信图片_20221017224233(1).png 可以将布局拆分为3部分:

  1. HeaderSection:写日期显示、背景音乐、设置等功能
  2. CardSection:展示卡片布局、小草背景
  3. FooterSection:存放被点击的卡片、功能按钮

数据存储

微信图片_20221017231221(1).png 可以定义3个数组用来存储卡片数据:

  1. CardList:存放默认生成的卡片
  2. RemoveList:存放被移出的卡片
  3. StoreList:存放被点击但是没有被消除的卡片

卡片组件

微信图片_20221017232111(1).png 可以定义3种卡片组件

  1. Card:默认生成的卡片组件(动画时间较长)
  2. RemoveCard:被移出的卡片组件(动画时间较短)
  3. StoreCard:被点击但是没有被消除的卡片组件(动画时间较短+消除动画)

项目拆解

卡片类型定义

微信图片_20221018232020.png

  1. 定义 lefttop 属性标识card的所在位置
  2. 定义id属性用作card的唯一标识,
  3. 定义zIndex属性来表示card的堆叠关系,层级较高的会显示、层级较低的则被遮挡
  4. 定义index属性标识card所在的相同层级下的索引
  5. 定义parents数组属性,从层级较低向层级较高逐级遍历存放和card有交集(遮挡)的card并放入数组中
  6. 定义row属性用来辅助计算card的left方向距离
  7. 定义column属性用来辅助计算card的top方向的距离
  8. 定义state属性用来标识card的各种状态,比如不能点击、可以点击、已经被点击、被移出等
  9. 定义ref属性用作该card对应的dom的引用(动态改变left、top的值实现动画交互)
  10. 定义type属性用作card显示的图标类型
  11. 定义imgUrl属性用作card的图片文件路径

于是我们可以得到以下CardNode类型:

// 卡片节点类型
type CardNode = {
    id: string; // 卡片唯一id
    type: string; // 卡片的图标类型
    imgUrl: string; // 卡片的图标路径
    zIndex: number; // 卡片所在的图层
    index: number; // 所在图层中的索引
    parents: CardNode[]; // 卡片的父类card数组
    row: number; // 卡片所在行
    column: number; // 卡片所在列
    top: number; // 卡片top距离
    left: number; // 卡片left距离
    state: number; // 卡片四种状态  0: 无状态  1:可点击 2:已选 3:已消除
    ref?:  undefined | HTMLElement // 卡片自身的dom引用
};

游戏事件类型定义

  1. 定义winCallback用作游戏胜利的事件回调
  2. 定义loseCallback用作游戏失败的事件回调
  3. 定义clickCallback用作card点击事件回调
  4. 定义removeCallback用作移出3个card事件回调
  5. 定义rollCallback用作回退1个card事件回调
  6. 定义dropCallback用作3个同类型的card消除事件回调

于是我们可以得到以下GameEvents类型:

interface GameEvents {
    clickCallback?: (card: CardNode) => void;
    removeCallback?: () => void;
    rollCallback?: (card: CardNode) => void;
    dropCallback?: () => void;
    winCallback?: () => void;
    loseCallback?: () => void;
}

游戏设置类型定义

  1. 定义container用作展示card列表的父容器的dom引用(便于计算card的初始位置)
  2. 定义cardNum用作表示card显示的图标类型(比如香蕉、梨子、苹果等)
  3. 定义layerNum用作表示card堆叠的层数(控制游戏难度以及card数量)
  4. 定义events用作表示游戏各种事件

于是我们可以得到以下GameConfig类型:

interface GameConfig {
    container?: Ref<HTMLElement | undefined>; // cardNode容器
    cardNum: number; // card类型数量
    layerNum: number; // card层数
    events?: GameEvents; //  游戏事件
}

游戏类型定义

  1. 定义cardList数组用作存放生成的所有card
  2. 定义selectedList数组用作存放被点击的card
  3. 定义removeList数组用作存放被移出的card
  4. 定义removeFlag用作表示该局游戏有没有使用过移出功能
  5. 定义backFlag用作表示该局游戏有没有使用过回退功能
  6. 定义shuffleFlag用作表示该局游戏有没有使用过打乱功能
  7. 定义selectCardHandler用作card的点击事件方法
  8. 定义selectRemoveCardHandler用作被移出的card的点击事件方法
  9. 定义shuffleCardListHandler用作打乱card列表事件方法
  10. 定义rollbackOneCardHandler用作回退1个card的事件方法
  11. 定义removeThreeCardHandler用作移出3个card的事件方法
  12. 定义initCardList用作游戏初始化方法

于是我们可以得到以下Game类型:

export interface Game {
    cardList: Ref<CardNode[]>;
    selectedList: Ref<CardNode[]>;
    removeList: Ref<CardNode[]>;
    removeFlag: Ref<boolean>;
    backFlag: Ref<boolean>;
    shuffleFlag: Ref<boolean>;
    selectCardHandler: (node: CardNode) => void;
    selectRemoveCardHandler: (node: CardNode) => void;
    shuffleCardListHandler: () => void;
    rollbackOneCardHandler: () => void;
    removeThreeCardHandler: () => void;
    initCardList: (config?: GameConfig) => void;
}

项目难点

如何批量导入图标文件

const moduleFiles = import.meta.globEager('../../assets/icons/*.png');

/**vite升级3.0以上版本采用以下写法**/
const modulesFiles: Record<string, any> = import.meta.glob('../../assets/icons/*.png', {
    eager: true
});

项目采用 import.meta.globEager(vite3.0以上版本import.meta.glob) 来导入图标文件,glob是基于插件fast-glob实现的,一个*用来匹配icons文件夹下所有以png为后缀的文件。

It's a very fast and efficient glob library for Node.js
这是一个基于 node.js 且非常高效的全局库。

我们打印一下 moduleFiles ,可以看到它是一个{文件路径:Module}类型的对象 微信图片_20221019165117.png 对moduleFiles稍作处理替换一下图片路径名称同时对Moudule进行解构

const moduleFiles = import.meta.globEager("../../assets/icons/*.png");

/**vite升级3.0以上版本采用以下写法**/
const modulesFiles: Record<string, any> = import.meta.glob('../../assets/icons/*.png', {
    eager: true
});

const imgMapObj = Object.keys(moduleFiles).reduce(
        (module: { [key: string]: any }, path: string) => {
                const moduleName = path
                        .replace("../../assets/icons/", "")
                        .replace(".png", "");
                module[moduleName] = moduleFiles[path].default;
                return module;
        },
        {} as Record<string, string>
);

我们将得到一个{图片名称:图片路径}的对象,此时引入图片路径就很简单了,直接imgMapObj[图片名称]即可 微信图片_20221019165723.png

如何实现动画音效

直接使用audio和source标签来引入音频文件,单独使用audio标签在Vite环境下会报错

<audio ref="clickAudioRef" style="display: none;" preload="auto" controls>
    <source src="@/assets/audios/click.mp3" />
</audio>

此时播放音频文件,直接调用以下代码即可

const clickAudioRef = ref();
clickAudioRef.value.play();

如何生成card数组

1、首先根据cardNum 和 layerNum 生成循环遍历得到一个itemList

// 生成节点池
let itemList = [];
let itemTypes = [];
for (let i = 0; i < cardNum; i++) itemTypes.push(i + 1);
for (let i = 0; i < 3 * layerNum; i++) itemList = [...itemList, ...itemTypes];

itemList是一个由图片类型组成的数组并且 itemList的个数为cardNum * layerNum * 3 微信图片_20221019180901.png 2、接下来按照层级关系由第一层逐级向上生成层级数组

// 打乱节点
itemList = shuffle(shuffle(itemList));
// 初始化各个层级节点
let floorList = [];
let len = 0;
let floorIndex = 1;
const itemLength = itemList.length;
while (len <= itemLength) {
        const maxFloorNum = floorIndex * floorIndex;
        const floorNum = ceil(random(maxFloorNum / 2, maxFloorNum));
        floorList.push(itemList.splice(0, floorNum));
        len += floorNum;
        floorIndex++;
}

此时我们打印一下floorList

微信图片_20221019202500.png 它表示的是层级以及该层级下存在的card的个数和类型

3、有了层级数组floorList,接下来生成中间部分cardList

let perFloorNodes: CardNode[] = [];
const containerWidth = container!.value!.clientWidth;
const containerHeight = container!.value!.clientHeight;
const width = containerWidth / 2;
const height = containerHeight / 2;
const cardList = ref<CardNode[]>([]);
const indexSet = new Set();

// 生成中间部分卡牌
floorList.forEach((o, index) => {
        indexSet.clear();
        let i = 0;
        const floorNodes: CardNode[] = [];
        o.forEach((k, index1) => {
                i = floor(random(0, (index + 1) ** 2));
                while (indexSet.has(i)) i = floor(random(0, (index + 1) ** 2));
                const row = floor(i / (index + 1));
                const column = index ? i % index : 0;
                const node: CardNode = {
                        id: `${index}-${i}`,
                        type: shuffleCardImgArr[k],
                        imgUrl: imgMapObj[shuffleCardImgArr[k]],
                        zIndex: index,
                        index: i,
                        row,
                        column,
                        top: height + (size * row - (size / 2) * index),
                        left: width + (size * column - (size / 2) * index),
                        parents: [],
                        state: 0,
                };
                const xy = [node.top, node.left];
                perFloorNodes.forEach((e) => {
                        if (
                             Math.abs(e.top - xy[0]) <= size &&
                             Math.abs(e.left - xy[1]) <= size
                        )
                        e.parents.push(node);
                });
                floorNodes.push(node);
                indexSet.add(i);
        });
        cardList.value = cardList.value.concat(floorNodes);
        perFloorNodes = floorNodes;
});

生成左右两边cardList

const leftTotal = Number((cardNum * 3) / 2);
const rightTotal = cardNum * 3 - leftTotal;
const topOffset = containerHeight - (layerNum > 5 ? size : 2 * size);
// 生成左右两边的卡牌池
for (let i = 0; i < 3; i++) itemList = [...itemList, ...itemTypes];
// 打乱节点
itemList = shuffle(shuffle(itemList));
// 生成左边部分卡牌
for (let j = 0; j < leftTotal; j++) {
        const node: CardNode = {
                id: `left-${j}`,
                type: shuffleCardImgArr[itemList[j]],
                imgUrl: imgMapObj[shuffleCardImgArr[itemList[j]]],
                zIndex: j,
                index: j,
                row: j,
                column: 1,
                top: topOffset,
                left: j * 7,
                parents: [],
                state: 0,
        };
        leftNodes.push(node);
}
for (let j = 0; j < leftTotal; j++) {
        for (let k = leftTotal - 1; k > j; k--) {
                leftNodes[j].parents.push(leftNodes[k]);
        }
}

// 生成右边部分卡牌
for (let k = 0; k < rightTotal; k++) {
        const node: CardNode = {
                id: `right-${k}`,
                type: shuffleCardImgArr[itemList[leftTotal + k]],
                imgUrl: imgMapObj[shuffleCardImgArr[itemList[leftTotal + k]]],
                zIndex: k,
                index: k,
                row: k,
                column: 1,
                top: topOffset,
                left: containerWidth - k * 7 - size,
                parents: [],
                state: 0,
        };
        rightNodes.push(node);
}
for (let j = 0; j < rightTotal; j++) {
        for (let k = rightTotal - 1; k > j; k--) {
                rightNodes[j].parents.push(rightNodes[k]);
        }
}
cardList.value = cardList.value.concat(leftNodes).concat(rightNodes);

4、改变cardList中card的state状态

cardList.value.forEach((o) => {
        o.state = o.parents.every((p) => p.state > 0) ? 1 : 0;
});

every() 方法使用指定函数检测数组中的所有元素,如果数组中检测到有一个元素不满足,则整个表达式返回 false ,且剩余的元素不会再进行检测。如果所有元素都满足条件,则返回 true。

对于最上层的card,它的parents为空数组,此时状态会被设置为1,其他则会被设置为0

如何实现动画效果

实现动画效果很简单,只需要给card组件增加 transition: all .4s ease-in-out; 即可,动画时长可以根据自己需求来设定 微信图片_20221019173706.png 为了精准控制card的移动位置,我们需要提前计算出上述7个点的lefttop

let positionList = [
    {top: 200,left: 0}, 
    {top: 200,left: 50}, 
    {top: 200,left: 100}, 
    {top: 200,left: 150}, 
    {top: 200,left: 200},
    {top: 200,left: 250},
    {top: 200,left: 300},
    {top: 200,left: 350}
]

const confirmCardPosition = (card) => {
    const top = positionList[selectedList.length].top;
    const left = positionList[selectedList.length].left;
    card.ref?.setAttribute('style', `position: absolute; z-index: ${card.zIndex}; top: ${top}px; left: ${left}px;`);
}

那么我们只需要在点击card的回调事件中动态改变style中的topleft即可。

微信图片_20221019201650.png 同理提前计算出移出card的3个坐标位置,按照上述步骤在移出card的点击事件回调中动态改变lefttop实现动画效果。

如何让点击事件顺序执行

快速点击card,调用card的点击事件时,数组计算和视图渲染会出现问题,导致游戏不会产生胜利结果

let historyList = [];
let count = 1;

const clickHandler = (card) => {
    historyList.push(card);
    if (count !== historyList.length) {
        historyList.pop();
        return;
    } else {
        setTimeout(() => {
            fn(); // 执行卡片处理逻辑
            count += 1;
        }, 500);
    }
}

项目采用上述代码,通过比对count和historyList数组长度,确保点击事件是按照顺序执行。

如何适配移动端

由于移动端的机型、系统还有浏览器环境千差万别,最好将项目拆分为PC端和移动端两个版本,本项目采用User-Agent配合Nginx服务器来进行项目适配

User-Agent是Http协议中的一部分,属于头域的组成部分,User Agent也简称UA。简单来说,是一种向访问网站提供你所使用的浏览器类型、操作系统及版本、CPU 类型、浏览器渲染引擎、浏览器语言、浏览器插件等信息的标识。UA字符串在每次浏览器 HTTP 请求时发送到服务器!

只需要修改一下Nginx配置文件即可实现

server 
{
    listen 80;
    listen 443 ssl;
    server_name 你的项目域名;
    
    index index.php index.html index.htm default.php default.htm default.html;
    root 你的PC端项目路径;
    
    location / {
        if ($http_user_agent ~ "(MIDP)|(WAP)|(UP.Browser)|(Smartphone)|(Obigo)|(Mobile)|(AU.Browser)|(wxd.Mms)|(WxdB.Browser)|(CLDC)|(UP.Link)|(KM.Browser)|(UCWEB)|(SEMC-Browser)|(Mini)|(Symbian)|(Palm)|(Nokia)|(Panasonic)|(MOT-)|(SonyEricsson)|(NEC-)|(Alcatel)|(Ericsson)|(BENQ)|(BenQ)|(Amoisonic)|(Amoi-)|(Capitel)|(PHILIPS)|(SAMSUNG)|(Lenovo)|(Mitsu)|(Motorola)|(SHARP)|(WAPPER)|(LG-)|(LG/)|(EG900)|(CECT)|(Compal)|(kejian)|(Bird)|(BIRD)|(G900/V1.0)|(Arima)|(CTL)|(TDG)|(Daxian)|(DAXIAN)|(DBTEL)|(Eastcom)|(EASTCOM)|(PANTECH)|(Dopod)|(Haier)|(HAIER)|(KONKA)|(KEJIAN)|(LENOVO)|(Soutec)|(SOUTEC)|(SAGEM)|(SEC-)|(SED-)|(EMOL-)|(INNO55)|(ZTE)|(iPhone)|(Android)|(Windows CE)|(Wget)|(Java)|(curl)|(Opera)") {
        	root 你的移动端项目路径;
        }
        try_files $uri $uri/ /index.html;
        index index.html index.htm;
    }
}

如何处理打乱card排序事件

与生成cardList的方法类似,我们只需要遍历cardList数组重置state为0和1的card的属性即可,在这里要注意改变card的id属性,否则视图可能不会渲染

当在进行列表渲染的时候,vue会直接对已有的标签进行复用

card.id = card.id + "shuffle";

写在最后

至此整个项目讲解已经全部结束,你学会了吗?

果了个果 已经开源

gitee地址: 源码地址

github地址: 源码地址

觉得文章不错、或对自己开发有所帮助,欢迎点赞收藏!❤❤❤

同时推荐几个作者参与的开源项目,如果项目有帮助到你,欢迎star!

一个简单的基于Vue3、TS、Vite、qiankun技术栈的后台管理项目www.xkxk.tech

一个基于Vue3、Vite的仿element UI的组件库项目ui.xkxk.tech

一个基于Vue3、Vite的炫酷大屏项目screen.xkxk.tech