项目背景
前段时间 羊了个羊 这款小游戏火爆朋友圈,可以说是圈粉无数。国庆期间临时起意,打算自己也实现一个类似的小游戏,于是有了 果了个果 在线体验
完成了项目还不过瘾,打算将开发项目过程中的思路和细节进行梳理,让广大朋友都能学会,实现自己的专属“羊了个羊”!(项目源码地址在文章末)
觉得文章不错、或对自己开发有所帮助,欢迎点赞收藏!❤❤❤
项目准备
工欲善其事必先利其器,首先得为自己的小游戏找到合适的素材
1、iconfont
2、花瓣网
3、羊了个羊本地文件
准备好了项目素材和音效文件,正式开始我们的项目开发。
项目思路
技术栈选型
- 项目使用 Vue3+TS+Vite(主要原因是笔者对Vue更加熟悉),而且Vue相对来说更容易上手。
- 卡片层级关系使用 z-index 来实现
z-index 属性设置元素的堆叠顺序。拥有更高堆叠顺序的元素总是会处于堆叠顺序较低的元素的前面。
- 卡片位置关系使用 absolute 来实现
布局思路
可以将布局拆分为3部分:
- HeaderSection:写日期显示、背景音乐、设置等功能
- CardSection:展示卡片布局、小草背景
- FooterSection:存放被点击的卡片、功能按钮
数据存储
可以定义3个数组用来存储卡片数据:
- CardList:存放默认生成的卡片
- RemoveList:存放被移出的卡片
- StoreList:存放被点击但是没有被消除的卡片
卡片组件
可以定义3种卡片组件
- Card:默认生成的卡片组件(动画时间较长)
- RemoveCard:被移出的卡片组件(动画时间较短)
- StoreCard:被点击但是没有被消除的卡片组件(动画时间较短+消除动画)
项目拆解
卡片类型定义
- 定义 left 和 top 属性标识card的所在位置
- 定义id属性用作card的唯一标识,
- 定义zIndex属性来表示card的堆叠关系,层级较高的会显示、层级较低的则被遮挡
- 定义index属性标识card所在的相同层级下的索引
- 定义parents数组属性,从层级较低向层级较高逐级遍历存放和card有交集(遮挡)的card并放入数组中
- 定义row属性用来辅助计算card的left方向距离
- 定义column属性用来辅助计算card的top方向的距离
- 定义state属性用来标识card的各种状态,比如不能点击、可以点击、已经被点击、被移出等
- 定义ref属性用作该card对应的dom的引用(动态改变left、top的值实现动画交互)
- 定义type属性用作card显示的图标类型
- 定义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引用
};
游戏事件类型定义
- 定义winCallback用作游戏胜利的事件回调
- 定义loseCallback用作游戏失败的事件回调
- 定义clickCallback用作card点击事件回调
- 定义removeCallback用作移出3个card事件回调
- 定义rollCallback用作回退1个card事件回调
- 定义dropCallback用作3个同类型的card消除事件回调
于是我们可以得到以下GameEvents类型:
interface GameEvents {
clickCallback?: (card: CardNode) => void;
removeCallback?: () => void;
rollCallback?: (card: CardNode) => void;
dropCallback?: () => void;
winCallback?: () => void;
loseCallback?: () => void;
}
游戏设置类型定义
- 定义container用作展示card列表的父容器的dom引用(便于计算card的初始位置)
- 定义cardNum用作表示card显示的图标类型(比如香蕉、梨子、苹果等)
- 定义layerNum用作表示card堆叠的层数(控制游戏难度以及card数量)
- 定义events用作表示游戏各种事件
于是我们可以得到以下GameConfig类型:
interface GameConfig {
container?: Ref<HTMLElement | undefined>; // cardNode容器
cardNum: number; // card类型数量
layerNum: number; // card层数
events?: GameEvents; // 游戏事件
}
游戏类型定义
- 定义cardList数组用作存放生成的所有card
- 定义selectedList数组用作存放被点击的card
- 定义removeList数组用作存放被移出的card
- 定义removeFlag用作表示该局游戏有没有使用过移出功能
- 定义backFlag用作表示该局游戏有没有使用过回退功能
- 定义shuffleFlag用作表示该局游戏有没有使用过打乱功能
- 定义selectCardHandler用作card的点击事件方法
- 定义selectRemoveCardHandler用作被移出的card的点击事件方法
- 定义shuffleCardListHandler用作打乱card列表事件方法
- 定义rollbackOneCardHandler用作回退1个card的事件方法
- 定义removeThreeCardHandler用作移出3个card的事件方法
- 定义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}
类型的对象
对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个点的left和top
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中的top和left即可。
同理提前计算出移出card的3个坐标位置,按照上述步骤在移出card的点击事件回调中动态改变left和top实现动画效果。
如何让点击事件顺序执行
快速点击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