< 纯前端实现「羊了个羊」小游戏 >

在这里插入图片描述


👉 背景

最近简单的「羊了个羊」小游戏火到出圈,据说狂赚几百几千万。这么弱智的玩意,即便是前端,我上我也行!

最终成果
在这里插入图片描述效果演示: 点击跳转

👉 游戏本体

<template>
    <div v-if="step === 0" class="intro">
        <div>
            横向卡片最大平铺排数
            <input
                v-model="option.x" min="2"
                max="10" type="range"
            > {{ option.x }}
        </div>
        <div>
            纵向卡片最大平铺排数
            <input
                v-model="option.y" min="2"
                max="10" type="range"
            > {{ option.y }}
        </div>
        <div>
            卡片最大堆叠层数
            <input
                v-model="option.z" min="2"
                max="10" type="range"
            > {{ option.z }}
        </div>
        <div>
            卡片密度
            <input
                v-model="option.cardRandom" min="0"
                max="1" step="0.1"
                type="range"
            >
            {{ option.cardRandom }}
        </div>
        <div>
            最大卡片种类
            <input
                v-model="option.maxCardType" min="3"
                max="14" step="1"
                type="range"
            >
            {{ option.maxCardType }}
        </div>
        <br>
        <button @click="startGame">开始游戏</button>
    </div>
    <div v-else-if="step === 2" class="intro">
        <h1>{{ result ? "You Win!🎉" : "You Lose!😢" }}</h1>
        <button @click="rePlay">再来一轮</button>
        <button @click="setGame">难度调节</button>
    </div>
    <div v-else class="box">
        <div class="card-wrap" :style="cardWrapStyle">
            <div
                v-for="item in cardItemList"
                :key="item.key"
                :class="{'item-cover': item.cover}"
                class="card-item"
                :style="item.style"
                @click="clickCard(item)"
            >
                {{ item.content }}
            </div>
            <div
                v-for="item in penddingList"
                :key="item.key"
                class="card-item"
                :style="item.style"
            >
                {{ item.content }}
            </div>
            <div
                v-for="item in clearList"
                :key="item.key"
                class="card-item clear-item"
                :style="item.style"
            >
                {{ item.content }}
            </div>
            <div
                v-for="item in saveList"
                :key="item.key"
                class="card-item"
                :style="item.style"
                @click="clickSaveCard(item)"
            >
                {{ item.content }}
            </div>
            <p class="card-tips">
                剩余空位:{{ 7 - penddingList.length }}/7;已消除:{{ clearList.length }}/{{
                    cardItemList.length + penddingList.length + saveList.length + clearList.length
                }}
            </p>
        </div>
        <div class="tools">
            道具:
            <button :disabled="!tools.save" @click="saveCard">取出3个卡片</button>
            <button :disabled="!tools.rand" @click="randCard">随机</button>
            <button @click="rePlay">再来一轮</button>
        </div>
    </div>
</template>

<script>
import Vue from 'vue';

class CardItem {
    static x = 20;
    static y = 21;
    static colorType = {
        1: {background: '#FFB7DD'},
        2: {background: '#FFCCCC'},
        3: {background: '#FFC8B4'},
        4: {background: '#FFDDAA'},
        5: {background: '#FFEE99'},
        6: {background: '#FFFFBB'},
        7: {background: '#EEFFBB'},
        8: {background: '#CCFF99'},
        9: {background: '#99FF99'},
        10: {background: '#BBFFEE'},
        11: {background: '#AAFFEE'},
        12: {background: '#99FFFF'},
        13: {background: '#CCEEFF'},
        14: {background: '#CCDDFF'}
    };
    static contentType = {
        1: '🥕',
        2: '✂️',
        3: '🥦',
        4: '🥛',
        5: '🌊',
        6: '🧤',
        7: '🧵',
        8: '🌱',
        9: '🔨',
        10: '🌽',
        11: '🌾',
        12: '🐑',
        13: '🪵',
        14: '🔥'
    };
    constructor({x, y, z, key}) {
        this.x = x;
        this.y = y;
        this.z = z;
        this.key = key;
        const offset = z * 0;
        this.val = key;
        this.style = {
            top: y * CardItem.y + offset + 'px',
            left: x * CardItem.x + offset + 'px',
            width: CardItem.x * 2 - 2 + 'px',
            height: CardItem.y * 2 - 8 + 'px'
        };
    }

    setValue(val) {
        this.val = val;
        this.content = CardItem.contentType[val];
        Object.assign(this.style, CardItem.colorType[val]);
    }
}

export default {
    data() {
        return {
            option: {
                x: 6,
                y: 4,
                z: 8,
                cardRandom: 0.2,
                maxCardType: 11
            },
            step: 0,
            win: false,
            cardMap: [],
            cardItemList: [],
            penddingList: [],
            clearList: [],
            saveList: [],
            calcValueList: [],
            maxWidth: 0,
            maxHeight: 0,
            tools: {
                save: true,
                rand: true
            },
            timer: 0
        };
    },
    computed: {
        cardWrapStyle() {
            return {
                width: (this.maxWidth + 2) * CardItem.x + 'px',
                height: (this.maxHeight + 1) * CardItem.y + 'px'
            };
        },
        leftOffset() {
            const wrapWidth = (this.maxWidth + 2) * CardItem.x;
            return (wrapWidth - 7 * CardItem.x * 2) / 2;
        }
    },
    methods: {
        randCard() {
            if (!this.tools.rand) {
                return;
            }
            this.tools.rand = false;
            const length = this.cardItemList.length;
            this.cardItemList.forEach(item => {
                const randNum = Math.floor(length * Math.random());
                const newItem = this.cardItemList[randNum];
                let temp;
                temp = item.style.left;
                item.style.left = newItem.style.left;
                newItem.style.left = temp;
                temp = item.style.top;
                item.style.top = newItem.style.top;
                newItem.style.top = temp;
                temp = item.x;
                item.x = newItem.x;
                newItem.x = temp;
                temp = item.y;
                item.y = newItem.y;
                newItem.y = temp;
                temp = item.z;
                item.z = newItem.z;
                newItem.z = temp;
            });

            this.cardItemList.sort((a, b) => a.z - b.z);
            this.calcCover();
        },
        saveCard() {
            if (!this.tools.save) {
                return false;
            }
            this.tools.save = false;
            this.saveList = this.penddingList.slice(0, 3);
            setTimeout(() => {
                this.saveList.forEach((item, index) => {
                    item.style.top = '110%';
                    item.style.left = this.leftOffset + index * CardItem.x * 2 + 'px';
                    this.calcValueList[item.val]--;
                });
            }, 0);
            this.penddingList = this.penddingList.slice(3);
            this.penddingList.forEach((item, index) => {
                item.style.top = '160%';
                item.style.left = this.leftOffset + index * CardItem.x * 2 + 'px';
            });
        },
        initGame() {
            this.step = 1;
            this.getMap(this.option);
            this.penddingList = [];
            this.clearList = [];
            this.saveList = [];
            this.tools.save = true;
            this.tools.rand = true;
            this.setCardValue({maxCardType: Number(this.option.maxCardType)});
            this.calcCover();
        },
        // 表示地图最大为 x * y 张牌,最多有 z 层
        getMap({x, y, z, cardRandom} = {}) {
            this.maxWidth = (x - 1) * 2;
            this.maxHeight = (y - 1) * 2 + 1;
            const cardMap = new Array(z);
            const cardItemList = [];
            let key = 0;
            // 地图初始化
            for (let k = 0; k < z; k++) {
                cardMap[k] = new Array(this.maxHeight);
                for (let i = 0; i <= this.maxHeight; i++) {
                    cardMap[k][i] = new Array(this.maxWidth).fill(0);
                }
            }
            for (let k = 0; k < z; k++) {
                const shrink = Math.floor((z - k) / 3);
                // 行
                for (let i = shrink; i < this.maxHeight - shrink; i++) {
                    // 列,对称设置
                    const mid = Math.ceil((this.maxWidth - shrink) / 2);
                    for (let j = shrink; j <= mid; j++) {
                        let canSetCard = true;
                        if (j > 0 && cardMap[k][i][j - 1]) {
                            // 左边不能有牌
                            canSetCard = false;
                        }
                        else if (i > 0 && cardMap[k][i - 1][j]) {
                            // 上边不能有牌
                            canSetCard = false;
                        }
                        else if (i > 0 && j > 0 && cardMap[k][i - 1][j - 1]) {
                            // 左上不能有牌
                            canSetCard = false;
                        }
                        else if (i > 0 && cardMap[k][i - 1][j + 1]) {
                            // 右上不能有牌
                            canSetCard = false;
                        }
                        else if (k > 0 && cardMap[k - 1][i][j]) {
                            // 正底不能有牌
                            canSetCard = false;
                        }
                        else if (Math.random() >= cardRandom) {
                            canSetCard = false;
                        }
                        if (canSetCard) {
                            key++;
                            const cardItem = new CardItem({x: j, y: i, z: k, key});
                            cardMap[k][i][j] = cardItem;
                            cardItemList.push(cardItem);
                            // 对称放置
                            if (j < mid) {
                                key++;
                                const cardItem = new CardItem({
                                    x: this.maxWidth - j,
                                    y: i,
                                    z: k,
                                    key
                                });
                                cardMap[k][i][j] = cardItem;
                                cardItemList.push(cardItem);
                            }
                        }
                    }
                }
            }
            cardItemList.reverse();
            for (let i = 1; i <= key % 3; i++) {
                const clearItem = cardItemList.pop();
                cardMap[clearItem.z][clearItem.y][clearItem.x] = 0;
            }
            cardItemList.reverse();
            this.cardMap = cardMap;
            this.cardItemList = cardItemList;
        },
        setCardValue({maxCardType} = {}) {
            // 卡片种类
            const valStack = new Array(maxCardType);
            this.calcValueList = new Array(maxCardType + 1).fill(0);
            // 给卡片设置值
            this.cardItemList.forEach(item => {
                const value = Math.ceil(Math.random() * maxCardType);
                if (valStack[value]) {
                    valStack[value].push(item);
                    if (valStack[value].length === 3) {
                        valStack[value].forEach(item => {
                            item.setValue(value);
                        });
                        valStack[value] = null;
                    }
                }
                else {
                    valStack[value] = [item];
                }
            });

            let count = 2;
            // console.log(valStack)
            valStack.forEach(list => {
                list
                    && list.forEach(item => {
                        count++;
                        item.setValue(Math.floor(count / 3));
                    });
            });
        },
        // 计算遮挡关系
        calcCover() {
            // 构建一个遮挡 map
            const coverMap = new Array(this.maxHeight);
            for (let i = 0; i <= this.maxHeight; i++) {
                coverMap[i] = new Array(this.maxWidth).fill(false);
            }

            // 从后往前,后面的层数高
            for (let i = this.cardItemList.length - 1; i >= 0; i--) {
                const item = this.cardItemList[i];
                const {x, y, z} = item;
                if (coverMap[y][x]) {
                    item.cover = true;
                }
                else if (coverMap[y][x + 1]) {
                    item.cover = true;
                }
                else if (coverMap[y + 1][x]) {
                    item.cover = true;
                }
                else if (coverMap[y + 1][x + 1]) {
                    item.cover = true;
                }
                else {
                    item.cover = false;
                }
                coverMap[y][x] = true;
                coverMap[y + 1][x] = true;
                coverMap[y][x + 1] = true;
                coverMap[y + 1][x + 1] = true;
            }
        },
        clickSaveCard(item) {
            this.cardItemList.push(item);
            const index = this.saveList.indexOf(item);
            this.saveList = this.saveList
                .slice(0, index)
                .concat(this.saveList.slice(index + 1));
            this.clickCard(item);
        },
        removeThree() {
            this.penddingList.some(item => {
                if (this.calcValueList[item.val] === 3) {
                    this.penddingList.forEach(newItem => {
                        if (newItem.val === item.val) {
                            this.clearList.push(newItem);
                        }
                    });
                    setTimeout(() => {
                        this.clearList.forEach((item, index) => {
                            item.style.left = this.leftOffset - 60 + 'px';
                        });
                    }, 300);

                    this.penddingList = this.penddingList.filter(newItem => {
                        return newItem.val !== item.val;
                    });
                    this.penddingList.forEach((item, index) => {
                        item.style.top = '160%';
                        item.style.left = this.leftOffset + index * CardItem.x * 2 + 'px';
                    });
                    this.calcValueList[item.val] = 0;
                    if (this.cardItemList.length === 0) {
                        this.step = 2;
                        this.result = true;
                    }
                }
            });

            if (this.penddingList.length >= 7) {
                this.step = 2;
                this.result = false;
            }
        },
        // 点击卡片
        clickCard(item) {
            clearTimeout(this.timer);
            this.removeThree();
            this.penddingList.push(item);
            const index = this.cardItemList.indexOf(item);
            this.cardItemList = this.cardItemList
                .slice(0, index)
                .concat(this.cardItemList.slice(index + 1));
            this.calcCover();
            this.calcValueList[item.val]++;
            setTimeout(() => {
                item.style.top = '160%';
                item.style.left
                    = this.leftOffset + (this.penddingList.length - 1) * CardItem.x * 2 + 'px';
            }, 0);

            this.timer = setTimeout(() => {
                this.removeThree();
            }, 500);
        },
        // 开始
        startGame() {
            this.initGame();
        },
        // 设置
        setGame() {
            this.step = 0;
        },
        // 重来
        rePlay() {
            this.initGame();
        }
    }
};
</script>

<style>
.box {
  position: relative;
}
.intro {
  margin: 10% auto 0 auto;
  text-align: center;
}

.card-wrap {
  position: relative;
  margin: 10% auto 0 auto;
}

.card-item {
  font-size: 28px;
  text-align: center;
  position: absolute;
  border-radius: 2px;
  box-sizing: border-box;
  background: #ddd;
  opacity: 1;
  cursor: pointer;
  transition: all 0.3s;
  box-shadow: 0px 3px 0 0 #fff, 0 8px 0 0 #ddd, 0 8px 0 2px #333, 0 0 0 2px #333;
}

.card-item:hover {
  transform: scale3d(1.1, 1.1, 1.1);
  z-index: 1;
}

.item-cover {
  pointer-events: none;
  box-shadow: 0px 3px 0 0 #999, 0 8px 0 0 #666, 0 8px 0 2px #000, 0 0 0 2px #000;
}

.item-cover:after {
  border-radius: 2px;
  content: "";
  position: absolute;
  width: 100%;
  height: 100%;
  left: 0;
  top: 0;
  background: #000;
  opacity: 0.55;
}

.card-tips {
  white-space: nowrap;
  position: absolute;
  left: 50%;
  top: 130%;
  transform: translate(-50%, 0);
  pointer-events: none;
}

.tools {
  position: absolute;
  top: 200%;
  width: 100%;
  left: 0;
  text-align: center;
}
.clear-item {
  pointer-events: none;
}
</style>

👉 原理讲解

> 地图模拟

游戏本体长这样
在这里插入图片描述

可以很明显的观察到,卡片是以 1/4 为单位排列的

  1. 单层
    在这里插入图片描述

假设有这种布局,模拟成二维数组应该如下表示,每个格子就是一个数字元素

// 通过0/1代表是否占用格子
[
    [0, 0, 1, 0, 0, 0],
    [1, 0, 0, 0, 1, 0],
    [0, 0, 0, 0, 0, 0],
]
  1. 多层
    多层
[
    // 第1层
    [
        [1, 0, 0, 0, 1, 0],
        [0, 0, 0, 0, 0, 0],
	  	[1, 0, 0, 0, 1, 0],
	  	[0, 0, 0, 0, 0, 0],
    ],
    // 第2层
    [
  		[0, 0, 0, 0, 0, 0],
   		[0, 1, 0, 1, 0, 0],
	  	[0, 0, 0, 0, 0, 0],
	  	[0, 0, 0, 0, 0, 0],
    ],
]

> 地图生成

  1. 基础生成:

最基础的地图只关乎当前层,假设当前需要判定是否放置卡片的坐标为 [i, j],那么下面四个位置就不能存在卡片,否则就会出现同层卡片重叠
在这里插入图片描述

// 一个图片占据四格,通过下标去索引上层数组有无未消除的内容。
	[i-1,j] != 1
	[i,j-1] != 1
	[i-1, j-1] ! = 1
	[i-1, j+1] ! = 1

同时我们加入一个随机系数,保证每次生成的地图不同

Math.random() < 0.3 === true 的时候该位置才放置卡片

  1. 优化地图:

以一层为例,按上面的逻辑只能生成最简单的地图,实际我们观察游戏,会发现卡片的放置是有一定规律的:
在这里插入图片描述

  • 左右对称
  • 从顶层到底层越来越往中心聚集,卡片越来越少
  • 上一层不会完全覆盖下一层

加上这两点优化之后,地图应该如下展示:

顶层
在这里插入图片描述
底层
在这里插入图片描述

  1. 卡片渲染

每次位置和随机数判定合格,我们应该实际放置一张卡片,一个实际的 dom。然后根据卡片的 x、y、z、宽高 值设置实际位置

const style = {
  position: "absolute",
  top: (y * CardItem.height) / 2 + offset + "px",
  left: (x * CardItem.width) / 2 + offset + "px",
  width: CardItem.width + "px",
  height: CardItem.height + "px",
}

> 覆盖关系

在这里插入图片描述

我们可以先按一层的大小初始化一个处理遮挡用的二维数组 coverMap,然后在之前生成的游戏地图上,从最后一层往第一层遍历。

[
    // 第1层
    [
		[1, 0, 0, 0, 1, 0],
		[0, 0, 0, 0, 0, 0],
		[1, 0, 0, 0, 1, 0],
		[0, 0, 0, 0, 0, 0],
    ],
    // 第2层
    [
		[0, 0, 0, 0, 0, 0],
		[0, 1, 0, 1, 0, 0],
		[0, 0, 0, 0, 0, 0],
		[0, 0, 0, 0, 0, 0],
    ],
]

先遍历第二层,发现 [1,1] 位置有卡片(由于是最上层可以先不考虑本身被遮挡的情况)所以我们把 coverMap 的对应 4 个坐标置为 1,第 [1,4] 位置的卡片也一样处理。
处理完顶层之后的 coverMap 结果如下

const coverMap = [
    [0, 0, 0, 0, 0, 0],
    [0, 1, 1, 1, 1, 0],
    [0, 1, 1, 1, 1, 0],
    [0, 0, 0, 0, 0, 0],
]

第二次(底层)遍历到 [0,0] 位置发现有卡片,并且实际会占据:

[0,0]
[0,1]
[1,0]
[1,1]

而其中 [1,1] 位置,是被遮挡的,所以这张卡片也应该被判定成遮挡状态。依次处理完这一层所有卡片,同时遮挡数组更新

const coverMap = [
    [1, 1, 0, 0, 1, 1],
    [1, 1, 1, 1, 1, 1],
    [1, 1, 1, 1, 1, 1],
    [1, 1, 0, 0, 1, 1],
]

> 填充数据

整改游戏的核心逻辑是 pending 区域存在 3 个同样图案的卡片时会消除。所以我们有两个关键点要注意

  1. 保证卡片是 3 的倍数(三张一消)

之前都是用 0、1 代指卡片,实际之前设置卡片的时候,我们可以新建 CardItem 类的实例,每个卡片实例会记录自己的位置、样式、是否被覆盖等状况。并且我们可以用一个 cardList 数组保存下这些实例
并且在地图生成完之后,根据数组数量除 3 的余数,从前开始删除对应数量的卡片

可以想想为什么不从后面删~

  1. 填充卡片类型

我们需要随机的把指定种类的卡片类型,以 3 的倍数填充到现有卡片中去
随机
创建一个新数组,并且随机交换顺序即可

const tempList = [...this.cardList];
const listLength = tempList.length;
for (let i = 0; i < listLength; i++) {
  const j = Math.ceil(Math.random() * listLength);
  const tempItem = tempList[i];
  tempList[i] = tempList[j];
  tempList[j] = tempItem;
}

填充
假设有 cardType 种类型的卡片,那么按 3 张重复填充即可

for (let i = 0; i < listLength; i++) {
  const item = tempList[i];
  item.setVal(Math.floor(i / 3) % this.cardType);
}

> 点击交互

  1. 是否可以点击
    只有顶层可以被点击,我们之前已经判定过卡片是否被覆盖的逻辑,做对应处理即可。一个简单的方法是给被覆盖的卡片设置一个特殊 style(禁止点击)
.covered-item {
  pointer-event: none;
}

这样对应卡片上的任何事件都不会生效

  1. 点击卡片

点击到最上层的卡片之后,我们按如下步骤处理:

  • 把点击到的卡片实例 push 到暂存数组 pendingList 中

  • 把卡片实例从 cardMap、cardList 中去除

  • pendingList 遍历

  • 如果 pendingList 中存在 3 张相同的卡片,则消除这 3 张卡片

  • 如果不存在,且pendingList 中卡片数为 7,游戏结束 。本局失败

  • 如果 cardList 中的卡片数量为 0,游戏结束。本局成功

总结

到这一步,整个游戏的基础框架就已经搭建好了。剩下的难点还有

道具的实现

  • 暂存道具
  • 随机道具
  • 撤销道具

动效的实现

  • 从排堆进入 pendding 区域
  • 从 pendding 区域进入暂存区
  • 使用随机道具时候的动画
  • 集齐 3 个卡片时候的消除动画

样式美化
这些基本属于锦上添花了,感兴趣的同学可以自行探索一下。我提供的实时代码里基本已经实现了大部分

作者:QCY
链接:https://juejin.cn/post/7143897892531486727

往期内容 💨

🔥 < 今日份知识点: 浅述对函数式编程的理解 及其 优缺点 >

🔥 < 每日知识点:关于Javascript 精进小妙招 ( Js技巧 ) >

🔥 < 今日份知识点:谈谈内存泄漏 及 在 Javascript 中 针对内存泄漏的垃圾回收机制 >

🔥 < 今日份知识点:浅述对 “ Vue 插槽 (slot) ” 的理解 以及 插槽的应用场景 >

  • 7
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值