项目布景

前段时刻 羊了个羊 这款小游戏火爆朋友圈,能够说是圈粉无数。国庆期间暂时起意,计划自己也完成一个相似的小游戏,所以有了 果了个果 在线体验

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

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

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

项目预备

工欲善其事必先利其器,首要得为自己的小游戏找到适宜的资料

1、iconfont

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

2、花瓣网

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

3、羊了个羊本地文件

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

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

预备好了项目资料和音效文件,正式开始咱们的项目开发。

项目思路

技能栈选型

  1. 项目运用 Vue3+TS+Vite(首要原因是笔者对Vue更加了解),而且Vue相对来说更简略上手。
  2. 卡片层级联系运用 z-index 来完成

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

  1. 卡片方位联系运用 absolute 来完成

布局思路

手把手带你完成自己的专属“羊了个羊”,想通关就通关!
能够将布局拆分为3部分:

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

数据存储

手把手带你完成自己的专属“羊了个羊”,想通关就通关!
能够界说3个数组用来存储卡片数据:

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

卡片组件

手把手带你完成自己的专属“羊了个羊”,想通关就通关!
能够界说3种卡片组件

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

项目拆解

卡片类型界说

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

  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 efficientgloblibrary forNode.js。
这是一个依据 node.js 且十分高效的全局库。

咱们打印一下 moduleFiles ,能够看到它是一个{文件途径:Module}类型的目标

手把手带你完成自己的专属“羊了个羊”,想通关就通关!
对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[图片称号]即可

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

怎么完成动画音效

直接运用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

手把手带你完成自己的专属“羊了个羊”,想通关就通关!
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

手把手带你完成自己的专属“羊了个羊”,想通关就通关!
它表明的是层级以及该层级下存在的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; 即可,动画时长能够依据自己需求来设定

手把手带你完成自己的专属“羊了个羊”,想通关就通关!
为了精准操控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即可。

手把手带你完成自己的专属“羊了个羊”,想通关就通关!
同理提早计算出移出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