社区精选 | 利用思否猫素材实现一个连连看小游戏

业界 作者:SegmentFault 2023-02-02 17:23:29

今天小编为大家带来的是社区作者 夕水 的文章,让我们一起来学习 vue3 实现一个思否猫连连看小游戏。


前言


通过本文,你将学到:

  • vue3 的核心语法
  • vue3 的状态管理工具 pinia 的用法
  • sass 的用法
  • 基本算法
  • canvas 实现一个下雪的效果,一些 canvas 的基本用法
  • rem 布局
  • typescript 知识点

开始之前



在开始之前,我们先来看一下最终的成品是怎么样的,如下图所示:

首页如下:


游戏页如下:


如上图所示,我们本游戏包含了两部分,第一部分就是首页,第二部分则是游戏页面。然后首页我们又可以分成两个部分,第一部分则是下雪花的效果,第二部分就是一个背景图和按钮。游戏页面同理也是分成两个部分,第一个部分就是列表,第二个部分则是倒计时效果。

当然其实还有隐藏的第三部分,其实也就是一个弹框组件,因为游戏结束或者游戏赢了,我们要给予一个反馈,而这个反馈就是弹框组件。

所有页面分析完成,接下来让我们初始化一个 vite 工程项目。

初始化工程



首先在电脑上任意一个目录按住 shift + 鼠标右键,选择打开 powershell ,也就是终端。然后输入如下命令:

npm create vite <项目名> --template vue-ts


然后一路回车,初始化完成工程,初始化完成之后,输入 npm instal l,下载依赖,下载完依赖,由于我们使用到了 sass ,所以需要额外输入 npm install sass --save-dev 来安装 sass 依赖。当然由于我们可能会写 tsx ,所以我们也安装 @vitejs/plugin-vue-jsx ,还有就是我们设置导入路径的别名,需要用到 node 的 path 模块,所以也需要额外安装 @types/node 依赖。

笔记:初始化工程都是照着官网文档来的。

修改配置与调整目录



所有依赖安装完成之后,我们修改一下 vite.config.ts 的配置,如下:

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue(), vueJsx()],
  base: "./", //打包路径配置
  esbuild: {
    jsxFactory: "h",
    jsxFragment: "Fragment",
  }, //tsx相关配置
  server: {
    port: 30001,
  },//修改端口
  resolve: {
    alias: [
      {
        find: "@",
        replacement: path.resolve(__dirname, "src"),
      },
      {
        find: "~",
        replacement: path.resolve(__dirname, "src/assets/"),
      },
    ],
  }, //配置@和~导入别名
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@import "@/style/variable.scss";`, //顾名思义,这里是一个定义变量scss文件,变量应该是作用于全局的,所以在这里全局导入
      },
    },
  } //新增的导入全局scss文件的配置
})


以上代码注释所解释的都是新增的配置,vite 默认的配置就只有一个 plugins :[vue()]。

修改完成配置之后,接下来我们来修改目录(主要是修改 src 目录)以及文件,修改后的目录应该如下所示:

// assets: 存储静态资源的目录
// components: 公共组件目录
// core: 游戏核心逻辑目录
// directives: 指令目录
// store: 状态管理目录
// style: 样式目录
// utils: 工具函数目录
// views: 页面视图目录


思考一下,我们这里需要用到 vue-router 吗?最开始我也是在思考,但是后面想了一下,这个页面很简单,暂时可以不需要,可是当我们后面进行扩展就需要了,比如自定义关卡和难度配置页面。

ok ,调整好了,让我们继续下一步。

定义接口



由于本游戏我们会将游戏参数抽离出来,并且用到了 typescript ,所以我们可以额外的创建一个 type.d.ts 文件,用于定义全局的接口类型。并且 vite 工程已经帮我们做好了默认导入全局接口类型,所以我们不需要做额外的配置,在src  目录下,新建 type.d.ts 文件,然后写上如下接口:

enum Status {
    START,
    RUNNING,
    ENDING
}

declare namespace GlobalModule {
    export type LevelType = number | string;
    export type ElementType = HTMLElement | Document | Window | null | Element;
    export interface SnowOptionType {
        snowNumber?: number;
        snowShape?: number;
        speed?: number;
    }
    export interface GameConfigType {
        materialList:Record<string,string> [],
        time: number,
        gameStatus: Status
    }
    export interface MaterialData {
        active: boolean
        src: string
        title?: string
        id: string
        isMatch: boolean
    }
    export type DocumentHandler = <T extends MouseEvent|Event>(mouseup:T,mousedown:T) => void;
    export type FlushList = Map<HTMLElement,{ DocumentHandler:DocumentHandler,bindingFn:(...args:unknown[]) => unknown }>
}

以上代码我们定义了一个全局命名空间 GlobalModule , 定义了一个枚举 Status 代表游戏的状态。然后我们来看命名空间里面所有的接口类型代表什么。

  • LevelType : 数值或者字符串类型,这里是用作 h1 ~ h6 标签名的组成的类型,也就是说我们在后面将会封装一个 Head 组件,代表标题组件,组件会用到动态的标签名,也就是这里的 1  ~  6 属性,它可以是字符串或者数值,所以定义在这里。
  • ElementType : 顾名思义,就是定义元素的类型,这在实现下雪花以及获取 Dom 元素当中用到。
  • SnowOptionType: 下雪花效果配置对象的类型,包含三个参数值,雪花数量,雪花形状以及雪花速度,都是数值类型。
  • GameConfigType: 游戏配置类型,materialList 代表素材列表类型,是一个对象数组,因此定义成 Record<string,string> [],time 代表倒计时时间, gameStatus 代表游戏状态。
  • MaterialData : 素材列表对象类型。
  • DocumentHandler : 文档对象回调函数类型,是一个函数,这在实现自定义指令中会用到。
  • FlushList : 用 map 数据结构存储元素节点的事件回调函数类型,也是用在实现自定义指令当中。

创建 store



在 store 目录下新建 store.ts ,写下如下代码: 

import { defineStore } from 'pinia'
import { defaultConfig } from '../core/gameConfig'


export const useConfigStore = defineStore('config',{
    state:() => ({
        gameConfig:{ ...defaultConfig }
    }),
    actions:{
        setGameConfig(config:GlobalModule.GameConfigType) {
            this.gameConfig = config;
        },
        reset(){
            this.$reset();
        }
    }
})


代码逻辑很简单,就是定义一个游戏配置的状态,以及修改游戏配置状态的 action 函数,这里有点意思的就是 reset 函数,this.$reset 是哪里来的?可能会有人有疑问。

答案当然是 pinia ,因为 pinia 内部封装了一个重置状态的函数,我们可以直接拿来用就是啦。 

随后,我们在 main.ts 文件里面,注入 pinia 。修改代码如下:

import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
//新增的样式初始化文件
import "./style/reset.scss"

//新增的代码,调用createPinia函数
const pinia = createPinia()
//修改的代码
createApp(App).use(pinia).mount('#app')


游戏配置



还有一个 defaultConfig ,也就是游戏默认配置,也非常简单,在 core 目录下,新建一个 gameConfig.ts 文件,添加如下代码:

// 素材列表是可以随意更换的
export const BASE_IMAGE_URL = "https://www.eveningwater.com/my-web-projects/js/26/img/";
export const materialList: Record<string,string> [] = new Array(12).fill(null).map((item,index) => ({ title:`图片-${index + 1}`,src:`${BASE_IMAGE_URL + (index + 1)}.jpg`}));
export const defaultConfig: GlobalModule.GameConfigType = {
    materialList,
    time: 120,
    gameStatus: 0
}


这里面其实就做了两件事,第一件事当然是导出素材列表,第二件事就是导出游戏默认配置啦。

初始化样式



让我们继续,接下来,先初始化一些 scss 样式变量和初始化样式,在 style 目录下新建 reset.scss 和 variable.scss 文件。
 
  • varaible.scss 代码如下:

$prefix: bm-;
$white#fff;
$black#000;

@mixin setProperty($prop,$value){
    #{$prop}:$value; 
}

.flex-center {
    @include setProperty(display,flex);
    @include setProperty(justify-content,center);
    @include setProperty(align-items,center);
}


这个文件干了什么?

定义了一个 class 命名前缀 bm- ,用 $prefix 变量代表,接着定义了白色和黑色的变量。随后又定义了一个 mixin setProperty。

纵观 css 无非就是属性名和属性值,所以我定义一个 mixin 传入两个参数,就是分别代表动态设置属性名和属性值。

PS: 这里纯属添加了个人的爱好在里面,因为我喜欢这么写 scss。

至于用法,我想在 flex-center 里面已经体现出来了。就是 @include setProperty (属性名,属性值)。

  • reset.scss

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}
body,html {
    width: percentage(1);
    height: percentage(1);
    overflow: hidden;
    background: url("~/header_bg.jpg") no-repeat center / cover;
}
.#{$prefix}clearfix::after {
    @include setProperty(content,'');
    @include setProperty(display,table);
    @include setProperty(clear,both);
}
ul,li {
    @include setProperty(list-style,none);
}
.app {
    @include setProperty(position,absolute);
    @include setProperty(width,percentage(1));
    @include setProperty(height,percentage(1));
}


初始化样式的代码也很好理解,首先是通配选择器*,将所有的外间距和内间距初始化为 0 ,并且设置 body 和 html 的宽高,截断溢出内容,并设置背景。加了一个. bm-clearfix 用于清除浮动的样式,因为后面会涉及到这个类名的使用,接着是重置 ul,li 的列表富豪,以及设置类名为 app 元素的样式。

基本样式初始化完成,接下来,我们就来实现一下页面当中会用到的工具函数。

实现一些会用到的工具函数


在 utils 目录下新建一个 util.ts ,首先在指令当中会用到的就是一个 isServer ,用来判断是否是服务端环境,也比较好理解,直接判断 window 对象是否存在即可。代码如下:

export const isServer = typeof window === "undefined";


接下来,简单封装一个 on 方法,用来给元素添加事件,on 方法接受 4 个参数,第一个参数为添加事件的元素,类型就是 ElementType,第二个参数为事件类型,是一个字符串,比如‘click’,第三个参数是事件回调函数,类型为 EventListenerOrEventListenerObject,这个类型是 DOM 内置定义好的事件回调函数类型,第四个参数也就是一个配置,是一个布尔值,代表事件是冒泡还是捕获阶段。

这个代码,其实我们就是利用 addEventListener 方法来简单的封装一下,所以最终代码如下:

export function on(
  element: GlobalModule.ElementType,
  type: string,
  handler: EventListenerOrEventListenerObject,
  useCapture: boolean = false
) {
  if (element && type && handler) {
    element.addEventListener(type, handler, useCapture);
  }
}


相应的,我们也有 off 方法,其实就是将 addEventListener 缓存  removeEventListener 方法即可,但在本项目当中似乎并没有用到,所以不必封装。

接下来是第三个工具方法,叫做 isDom ,顾名思义,就是判断一个元素是否是一个 DOM 元素。思考一下,我们如何判断一个元素是否是 DOM 元素呢?

或者我们可以这么想,DOM 元素都有哪些特点?

首先第一点,当 HTMLElement 对象存在时,那么 DOM 对象节点一定是该对象的一个子实例,因此我们有:

if(typeof HTMLElement === 'object'){
    return el instanceof HTMLElement;
}


其次,如果 HTMLElement 不是一个对象,那我们可以判断el instanceof HTMLCollection。

最后一种判断方法,那就是判断 el 是否是一个对象,并且存在 nodeType 和 nodeName 属性,其中 nodeType = 1 代表是一个 DOM 元素节点,具体可以查看文档知晓这个属性的值分别代表什么。

综上所述,isDom 方法就呼之欲出了,如下:

export function isDom(el: any): boolean {
  return typeof HTMLElement === 'object' ?
    el instanceof HTMLElement :
    el && typeof el === 'object' && el.nodeType === 1 && typeof el.nodeName === 'string'
    || el instanceof HTMLCollection;
}


接下来的这个工具方法不需细讲,就是一个创建 uuid 的工具函数,代码如下:

export const createUUID = (): string => (Math.random() * 10000000).toString(16).substr(0, 4) + '-' + (new Date()).getTime() + '-' + Math.random().toString().substr(2, 5);


接下来的一个工具方法可是重中之重,也就是倒计时工具函数,让我们来思考一下,我们主要要返回一个状态出去,也就是倒计时的值,即一个数值,倒计时会有一个起始值,也会有一个结束值,并且还有一个步长,以及执行时间。

如何实现一个倒计时?这里很显然就要用到定时器啦,不过我这里采用的是另一种方式,也就是延迟函数+递归来实现。一共有 5 个参数,所以我们的函数结构如下:

export const CountDown = (start:number,end:number,step:number = 1,duration:number = 2000,callback:(args: { status:string,value:number,clear:() => void } ) => any) => {
    //核心逻辑
}


这个函数的参数比较长,一共有 5 个参数,主要在第5个参数上,它是一个函数,参数是 3 个 { status:'running',value:1,clear:() => {}} ,其中 status 代表当前是什么状态,value就是倒计时的数值,clear是一个函数,用来清空定时器,并阻止递归。

接下来第一步,定义 3 个变量,分别代表定时器,当前倒计时数值以及步长,如下:

let timer: ReturnType<typeof setTimeout>,
    current = start + 1,
    step = (end - start) * step < 0 ? -step : step;


紧接着定义一个需要执行递归的函数,并调用它,然后返回一个clear方法,如下:

const handler = () => {
    //核心代码
}
handler();
return {
    clear:() => clearTimeout(timer);
}


在递归函数 handler 中,我们通过 current 与步长相加得到了倒计时值,随后我们回调状态以及值出去,最后判断当满足了递归条件,就阻止递归并清除定时器,然后将结束状态以及倒计时值回调出去,否则就是延迟递归执行 handler 函数。如下:

current += _step;
callback({
    status:"running",
    value: current,
    clear:() => {
        //这里需要注意,必须要修改current为最终状态值,才能清除定时器并停止递归
        if(end - start > 0){
            current = end - 1;
        }else{
            current = end + 1;
        }
        clearTimeout(timer);
    }
});
//这里就是递归终止条件
const isOver = end - start > 0 ? current >= end - 1 : current <= end + 1;
if(isOver){
    clearTimeout(timer);
    callback({
        status:"running",
        value: current,
        clear:() => {
            //这里需要注意,必须要修改current为最终状态值,才能清除定时器并停止递归
            if(end - start > 0){
               current = end - 1;
            }else{
                current = end + 1;
            }
            clearTimeout(timer);
        }
    });
}else{
    timer = setTimeout(handler,Math.abs(duration));
}


合并以上代码就成了我们最终的倒计时函数,如下:

export const CountDown = (start: number,
  end: number,
  step: number = 1,
  duration: number = 2000,
  callback: (args: { status: string, value: number, clear: () => void }) => any): { clear: () => void } => {
  let timer: ReturnType<typeof setTimeout>,
    current = start + 1,
    _step = (end - start) * step < 0 ? -step : step;
  const handler = () => {
    current += _step;
    callback({
      status: "running",
      value: current,
      clear: () => {
        // 需要修改值
        if (end - start > 0) {
          current = end - 1;
        } else {
          current = end + 1;
        }
        clearTimeout(timer);
      }
    });    
    const isOver = end - start > 0 ? current >= end - 1 : current <= end + 1;
    if (isOver) {
      clearTimeout(timer);
      callback({
        status: "end",
        value: current,
        clear: () => {
          // 需要修改值
          if (end - start > 0) {
            current = end - 1;
          } else {
            current = end + 1;
          }
          clearTimeout(timer);
        }
      })
    } else {
      timer = setTimeout(handler, Math.abs(duration));
    }
  }
  handler();
  return {
    clear: () => clearTimeout(timer)
  }
}


实现下雪花效果



在 utils 下新建 snow.ts ,然后我们思考一下,如何实现下雪花的效果?
我们可以知道下雪花分成两部分下雪花和雪花,在这里,我们需要用到canvas相关语法,我们把下雪花叫做 SnowMove ,雪花叫做 Snow ,如此一来,我们就可以定义好两个类,代码如下:


class Snow {
    //雪花类核心代码
}
class SnowMove {
    //下雪花类核心代码
}


实现 Snow 类



现在,我们先来实现雪花类,首先我们要知道要实现雪花,就需要添加一个 canvas 标签,在这里我们选择的是动态添加 canvas标签,所以雪花类构造函数中应当有 2 个参数,第一个就是 canvas 元素添加的容器元素,另一个就是雪花配置对象。因此,我们继续添加如下代码:

class Snow {
    constructor(element:GlobalModule.ElementType,option?:GlobalModule.SnowOptionType){
        //初始化代码
    }
}


注意 2 个参数的类型,还有第 2 个参数是可选的,这样我们就可以定义一个默认配置对象,如果没有传 option ,就采用默认配置对象,接下来我们要在构造函数里面做什么?那当然是要初始化一些属性,定义一些公共属性来存储容器元素和配置对象。

class Snow {
    public el: GlobalModule.ElementType;
    public snowOption: GlobalModule.SnowOptionType;
    public defaultSnowOption: Required<GlobalModule.SnowOptionType> = {
        snowNumber: 200,
        snowShape: 5,
        speed: 1
    };
    public snowCan: HTMLCanvasElement | null;
    public snowCtx: CanvasRenderingContext2D | null;
    public snowArr: SnowMove [];
    constructor(element:GlobalModule.ElementType,option?:GlobalModule.SnowOptionType){
        this.el = element;
        this.snowOption = option || this.defaultSnowOption;
        this.snowCan = null;
        this.snowCtx = null;
        this.snowArr = [];
    }
}


以上代码虽然稍微有点长,但事实上很好理解,我们就是在类的this对象上绑定了一些属性,比如容器元素,还有初始化 canvas 元素和元素上下文对象,可能不好理解的是这里有一个 snowArr 属性,它代表存储的每一个雪花移动的类的数组。

初始化属性完成,接下来创建一个 init 方法,用来初始化雪花的一些方法,在init 方法中,我们调用了 3 个方法。

  • createCanvas: 顾名思义,就是创建canvas元素的方法。
  • createSnowShape: 这是一个创建雪花形状的方法。
  • drawSnow: 画雪花的方法。

代码如下:


`class Snow {
    //省略了部分代码
    init(){
        this.createCanvas();
        this.createSnowShape();
        this.drawSnow();
    }
}


让我们先来看第一个方法,createCanvas 方法的实现,我们知道动态创建一个元素,其实也就是使用 document.createElement 方法,创建 canvas 元素之后,我们需要额外设置一点样式让 canvas 填充满整个容器元素,为了方便获取 canvas 元素,我们给它添加一个id,随后我们需要设置 canvas 元素的宽度和高度,最后我们将 canvas 元素添加到容器元素中去。

但是我们需要知道,在这里屏幕可能会发生变动,发生了变动之后,我们的 canvas 元素应该也会变动,所以我们还需要监听 resize 事件,用来修改元素的宽高。

让我们来看一下实现的代码吧:

import { isDom,on } from './util'
class Snow {
    //省略了代码
    createCanvas(){
        //创建一个canvas元素
        this.snowCan = document.createElement('canvas');
        // 设置上下文
        this.snowCtx = this.snowCan.getContext('2d');
        // 设置id属性
        this.snowCan.id = "snowCanvas";
        // canvas元素设置样式
        this.snowCan.style.cssText += "position:absolute;left:0;top:0;z-index:1;";
        //设置canvas元素宽度和高度
        this.snowCan.width = isDom(this.el) ? (this.el as HTMLElement).offsetWidth : document.documentElement.clientWidth;
        this.snowCan.height = isDom(this.el) ? (this.el as HTMLElement).offsetHeight : document.documentElement.clientHeight;
        // 监听resize事件
        on(window,'resize',() => {
            (this.snowCan as HTMLElement).width = document.documentElement.clientWidth;
            (this.snowCan as HTMLElement).height = document.documentElement.clientHeight;
        });
        //最后一步,将canvas元素添加到页面中去
        if(isDom(this.el)){
            (this.el as HTMLElement).appendChild(this.snowCan);
        }else{
            document.body.appendChild(this.snowCan);
        }
    }
    //省略了代码
}


createCanvas 到此为止了,接下来我们来看下一个方法,也就是 createSnowShape 方法。这个方法其实也很简单,主要是根据参数创建一个雪花移动的数组并存储起来。如下:

class Snow {
    //省略了代码
    createSnowShape(){
        const maxNumber = this.snowOption.snowNumber || this.defaultSnowption.snowNumber,
              shape = this.snowOption.snowShape || this.defaultSnowption.snowShape,
              { width,height } = this.snowCan as HTMLCanvasElement,
              snowArr: SnowMove [] = this.snowArr = [];
        for(let i = 0;i < maxNumber;i++){
            snowArr.push(
                new SnowMove(width,height,shape,{ ...this.defaultSnowOption,...this.snowOption })
            )
        }
    }
    //省略了代码
}


显然这个方法就是把每一个雪花移动当作一个实例存储到数组中,这个雪花移动的类我们后面会说到,这里先不说。让我们来看下一个方法 drawSnow。

其实通过这个方法我们也可以看到真正画雪花是在 SnowMove 类当中,这个类当中我们实现了 render 也就是渲染雪花的方法,以及 update 更新雪花的方法。

所以在这个方法但这个方法当中,我们主要做的事情就是存储的雪花数组snowMove,然后调用每一个 snowMove 实例的 render 方法和 update 方法,然后再使用 requestAnimationFrame 重复调用 drawSnow 方法。

当然在遍历之前,我们要先调用 clearRect 方法清除画布。

class Snow {
    //省略了代码
    drawSnow(){
        //清除画布
        this.snowCtx?.clearRect(0,0,this.snowCan?.width as number,this.snowCan?.height as number);
        //遍历snowArr
        const snowNumber = this.snowOption.snowNumber || this.defaultSnowption.snowNumber;
        for(let i = 0;i < snowNumber;i++){
            this.snowArr[i].render(this.snowCtx as CanvasRenderingContext2D);
            this.snowArr[i].update(this.snowCan as HTMLCanvasElement);
        }
        // 重复调用
        requestAnimationFrame(() => this.drawSnow());
    }
    //省略了代码
}


接下来我们来看 SnowMove 类的实现。

实现SnowMove类



通过前面的代码,我想我们对这个类的实现已经有了一定的了解了,比如 render 和update方法,顾名思义,一个就是渲染方法,另一个就是更新方法。接下来我们要思考一下,雪花移动改变的是什么?

雪花移动主要就是改变坐标,也就是 x 和 y 坐标的值,它会有一个步长,然后根据步长结合数学函数计算出垂直下落的 x 和 y 坐标的一个速度,我们称之为 verX 和 verY ,在下落的时候,可能也会飞出边界,所以我们就需要在飞出边界的时候,我们就应该做一个重置操作,所以也就额外增加了一个reset方法。

根据以上分析,我们得出 SnowMove 类,我们应该初始化的属性有 x,y,shape,fallspeed,verX,verY,step,stepNum 等属性,分别代表x坐标以及 y 坐标,雪花形状,下落速度,垂直方向上的x坐标和y坐标,步长,以及步数。

当然为了方便获取在 Snow 类里面定义的配置属性,我们将 Snow 定义的配置属性对象当作参数也要传给 SnowMove 类。

代码如下:

class SnowMove {
    public x:number;
    public y:number;
    public shape:number;
    public fallspeed:number;
    public verx:number;
    public verY:number;
    public step:number;
    public stepNum: number;
    public context: Required<GlobalModule.SnowOptionType>;
    // 注意构造函数的参数
    constructor(w:number,h:number,s:number,context:Required<GlobalModule.SnowOptionType>){
        // 初始化x和y坐标,取随机数,由于我们的x和y坐标是在canvas元素内部变动,因此我们取canvas元素的宽度和高度去乘以随机数得到初始化的随机x和y坐标
        this.x = Math.floor(w * Math.random());
        // 这也是为什么要将canvas的宽度和高度当作SnowMove的参数原因
        this.y = Math.floor(h * Math.random());
        // 初始化形状
        this.shape = Math.random() * s;
        // 初始化下落速度
        this.fallspeed = Math.random() + context.speed;
        // 初始化x和y方向下落的速度
        this.verY = context.speed;
        this.verX = 0;
        // 初始化context
        this.context = context;
    }
}


如此一来我们的初始化工作就完成了,但事实上我们第二个方法 reset 方法本质上也是重新初始化一次,因此我们可以将初始化的逻辑抽取出来,创建一个 init 方法,然后直接调用这个方法来初始化。

修改代码如下:

class SnowMove {
    //省略了代码
    constructor(w:number,h:number,s:number,context:Required<GlobalModule.SnowOptionType>){
        this.context = context;
        this.init(w,h,s,context.speed);
    }
    init(w:number,h:number,s:number,speed: number){
        this.x = Math.floor(w * Math.random());
        this.y = Math.floor(h * Math.random());
        this.shape = Math.random() * s;
        this.fallspeed = Math.random() + speed;
        this.verY = speed;
        this.verX = 0;
    }
}


如此一来,我们的 reset 方法也就完成了,如下:

class SnowMove {
    //省略了代码
    reset(can: HTMLCanvasElement){
        this.init(can.width,can.height,this.context.speed);
    }
    //省略了代码
}


接下来,我们来完成 update 方法,update 方法传入 canvas 作为参数,因为我们要使用到 canvas 元素的宽度和高度,接下来思考一下,我们要在 update 方法里面做什么?

我们是不是要更新下落坐标?也可以称之为更新下落速度,这样我们也就相当于更改 verX 和 verY 的值,那么如何更改?

verX 的计算公式为:

this.verX = this.verX 一个随机移动的数(这里是0.95)+ Math.cos(this.step += (一个数,这里取的是0.4)) this.stepNum;

verY 的计算公式为:

this.verY = Math.max(this.fallspeed,this.verY);

然后我们再将两者自增,这样雪花就达到了从最上方落到最下方的效果,当然这个计算公式不是唯一的,根据实际效果而定。

更新了坐标完成之后,我们需要做一个边界处理,边界的判断条件是什么?

很简单不能小于(可以等于可以不等于,这里取等于)0,其次不能大于 canvas 元素的宽度和高度。

综上所述,update 方法就呼之欲出啦,代码如下:

class SnowMove {
    //省略了代码
    update(can: HTMLCanvasElement){
        this.verX *= 0.95;
        if(this.verY <= this.fallspeed){
            this.verY = this.fallspeed;
        }
        this.verX += Math.cos(this.step += .4) * this.stepNum;
        this.verY += this.verY;
        this.verX += this.verX;
        // 边界判断
        if(this.verX <= 0 || this.verX > can.width || this.verY <= 0 || this.verY > can.height){
            this.reset(can);
        }
    }
    //省略了代码
}


update 方法完成后,render 方法才是最核心的构建雪花的方法,构建雪花我们采用渐变颜色填充,并且这里的雪花是圆形的,所以我们需要用到 arc 方法来画圆,画圆要用到半径,所以我我们将最开始配置对象的参数 shape 作为半径。

canvas 画一个图形的步骤有,

  • ctx.save 保存状态
  • ctx.fillStyle 填充颜色
  • ctx.beginPath 开始路径
  • ctx.arc 画圆
  • ctx.fill 填充路径
  • ctx.restore 弹出状态

想要知道 canvas 的这些具体代表什么,可以查看文档。https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/canvas

这里我们使用 createRadialGradient和addColorStop 方法来创建一个渐变颜色。

根据以上分析,render 方法,我们基本上就完成了。如下:

class SnowMove {
    //省略了代码
    render(ctx:CanvasRenderingContext2D){
        const snowStyle = ctx.createRadialGradient(this.x,this.y,0,this.x,this.y,this.shape);
        snowStyle.addColorStop(0.8, 'rgba(255,255,255,1)');
        snowStyle.addColorStop(0.1, 'rgba(255,255,255,.4)');
        ctx.save();
        ctx.fillStyle = snowStyle;
        ctx.beginPath();
        ctx.arc(this.x,this.y,this.shape,0,Math.PI * 2);
        ctx.fill();
        ctx.beginPath();
    }
    //省略了代码
}

将以上的分析代码合并,我们的一个 Snow 下雪花效果就写好了
接下来我们来看是如何使用的

const s = new Snow(document.querySelect('.test'));
s.init();



一些公共组件的实现



我们来尝试分析一下页面,我们可以将哪些组件做成公共组件,首先是首页,我们可以将按钮组件,还有就是 ready go 也分别做成公共组件,其次我们还需要一个 Modal 组件,公共组件基本就这些了。

Button组件的实现



button 组件的实现很简单,就是一个 button ,然后写点样式(样式是可以自己随便写的),然后通过 defineEmits 方法将点击事件传给父组件即可。代码如下:

<script lang="ts" setup>
    const emit = defineEmits(['click']);
</script>
<template>
    <button type="button" class="bm-play-btn" @click="emit('click')">开始游戏</button>
</template>
<style lang="scss" scoped>
$color#753200;
.#{$prefix}play-btn {
    @include setProperty(position, absolute);
    @include setProperty(width, 2rem);
    @include setProperty(height, .6rem);
    @include setProperty(left, percentage(0.5));
    @include setProperty(top, percentage(0.5));
    @include setProperty(background, linear-gradient(135deg,#fefefe 10%,#fff 90%));
    @include setProperty(transform, translate(-50%, -50%));
    @include setProperty(font, bold .34rem/.6rem '微软雅黑');
    @include setProperty(text-align, center);
    @include setProperty(color, $color);
    @include setProperty(border-radius,.4rem);
    @include setProperty(letter-spacing,2px);
    @include setProperty(cursor,pointer);
    @include setProperty(outline,none);
    @include setProperty(border,none);
    &:hover {
        @include setProperty(background, linear-gradient(135deg,#e8e8e8 10%,#fff 90%));
    }
}
</style>


在这里,我通过写 scss 的 mixin 来写样式,满屏的 setProperty 可能会让人有些迷惑,你只需要知道它就是 mixin 即可,也许这不是一个好的方式,这纯属个人的爱好,不一定非要跟着我这样写。

PS: 这里为了兼容移动端,我们也用到了 rem 布局,这个我们放到最后来讲。

go和ready组件的实现



要实现这两个组件,我们首先需要先简单包装一下标题组件,创建一个 Head.vue ,代码如下:

<script lang="ts" setup>
import { PropType, toRefs } from 'vue';
const props = defineProps({
    level: {
        type: [Number, String] as PropType<GlobalModule.LevelType>,
        default: '1',
        validator: (v: GlobalModule.LevelType) => {
            return [1, 2, 3, 4, 5, 6].includes(Number(v));
        }
    },
    content: String as PropType<string>
})
const { level, content } = toRefs(props);
const ComponentName = `h${level.value}`;
</script>
<template>
    <Component :is="ComponentName">
        <slot>{{ content }}</slot>
    </Component>
</template>



这个组件的代码也是很好理解的,利用 vue 的动态组件 component,来实现从 h1 ~ h6 根据 props 来决定是使用哪个标签元素渲染。

这里使用了对象解构,为了不让 props 在对象解构当中失去响应式特性,我们使用 toRefs 方法来包裹了 props。

props 有两个参数,第一个为 level,代表标题标签使用哪种,有 6 个数值,即 1 ~ 6,其次 content 可以作为标签的内容,当然如果写了插槽内容,默认还是以插槽内容为主。

接下来 Go 和 Ready 组件就是基于 Head 组件来实现的,两者有些共同之处,主要不同的地方在于动画效果的不同,一个是渐隐效果,一个是渐隐 + 缩放效果。

到了这里,我想很多人已经分析出来了,就是使用 animation 动画来实现。
首先,我们将这两个组件的公共样式提取出来,放到 style 目录下,新增一个 Head.scss,然后写上样式代码。

我认为样式还是比较简单好理解的,应该不需要细讲,直接附上源码即可。

@mixin head {
    color:$white;
    width: percentage(1);
    text: {
        align: center;
    }
    line: {
        height: 400px;
    }
    position: absolute;
    display: block;
}



这里值得一提的就是 scss 的属性语法,我们还可以将属性拆分,比如本示例中的 text-align 被拆分成了 text 和 align,同理 line-height 也是,这样我们也可以举一反三,比如 border,background 等也都可以这么写,当然这种写法与 scss 的版本也有关系,需要注意你使用的 scss 版本是否支持。

然后我们来看 Go 和 Ready 组件的源码,两者应该是类似的,基本上写了一个,另一个就好写了,无非是动画的效果不同罢了。

<script setup lang="ts">
import { PropType } from 'vue';
import Head from './Head.vue';
const props = defineProps({
    modelValue: Boolean as PropType<boolean>
});
const emit = defineEmits(['update:modelValue']);
emit('update:modelValue');
</script>
<template>
    <Head class="bm-go" :class="{ 'active':props.modelValue }">Go</Head>
</template>
<style scoped lang="scss">
@import "../style/head.scss";
.#{$prefix}go {
    @include head();
    opacity: 0;
    transform: scale(0);
    &.active {
        animation: goSlide 1.5s .5s;
    }
    @keyframes goSlide {
        from {
            opacity: 0;
            transform: scale(0);
        }
        to {
            transform: scale(1.7);
            opacity: 1;
        }
    }
}
</style>


  • Ready.vue

<script setup lang="ts">
import { PropType } from 'vue';
import Head from './Head.vue';
const props = defineProps({
    modelValue: Boolean as PropType<boolean>
});
const emit = defineEmits(['update:modelValue']);
emit('update:modelValue');
</script>
<template>
    <Head class="bm-ready" :class="{ 'active':props.modelValue }">Ready</Head>
</template>
<style scoped lang="scss">
    @import "../style/head.scss";
    .#{$prefix}ready {
        @include head();
        transform: translateY(-150%);
        &.active {
            animation: readySlide 1.5s;
        }
        // 不同的是动画效果
        @keyframes readySlide {
            from {
                opacity: 1;
                transform: translateY(-150%);
            }
            to {
                transform: translateY(0);
                opacity: 0;
            }
        }
    }
</style>




最后一个公共组件就是 Modal.vue 呢,也就是一个弹框组件的实现,让我们一起来看一下吧。

clickoutside 指令的实现



在开始这个组件之前,我们还需要额外使用到一个指令,即 clickOutside 指令,顾名思义,就是点击元素区域之外所执行的逻辑。试想一下,我们通常在实现弹框组件的时候,点击弹框内容里面是不用关闭弹框的,但是点击遮罩层就需要关闭弹框了,所以这个指令在此也就有了用武之地。

像一些下拉框组件 Select,Popover 组件(悬浮框)组件等,都可能会用到这个指令。

那么如何实现这个指令呢?

我们思考一下,要实现点击区域之外,也就是说我们需要一个事件的全局代理,即我们点击整个屏幕,然后通过点击屏幕的事件对象中的点击触发节点来判定是否在弹框内容组件节点中。

有两种方式实现这种效果,一种是通过节点的方式,另一种则是通过判断坐标的方式,这在我的实现颜色选择器的文章和课程当中有详细讲解。

当然以上是题外话,让我们继续,我们在这里很明显需要有一个数据结构,将绑定该指令的所有节点都存储起来,然后通过监听 document 或者是 window 对象的 mousedown 事件,比较节点是否在存储的数据结构中能够找到,如果能够找到,就不执行后续逻辑,否则就执行指令绑定的对应方法。

整体思路就是这么一回事,接下来,我们来看具体的实现,在 directives 目录下新建一个 clickoutside.ts 文件。

import { ComponentPublicInstance, DirectiveBinding, ObjectDirective } from 'vue';
import { isServer,on } from '../utils/util';

const nodeList:GlobalModule.FlushList = new Map();
let startClick:MouseEvent | Event;
if(!isServer){
    on(document,'mousedown',(e:MouseEvent | Event) => startClick = e);
    on(document,'mouseup',(e:MouseEvent | Event) => {
        for(const { DocumentHandler } of nodeList.values()){
            DocumentHandler(e,startClick);
        }
    });
}
const createDocumentHandler = (el:HTMLElement,binding:DirectiveBinding):GlobalModule.DocumentHandler => {
    // the excluding elements
    let excludes:HTMLElement[] = [];
    if(binding.arg){
        if(Array.isArray(binding.arg)){
            excludes = binding.arg;
        }else{
            excludes.push(binding.arg as unknown as HTMLElement);
        }
    }
    return (mouseup,mousedown) => {
        // Maybe we can not considered the tooltip component,which is the popperRef type
        const popperRef = (binding.instance as ComponentPublicInstance<{ popperRef:NonNullable<HTMLElement> }>).popperRef;
        const mouseUpTarget = mouseup.target as Node;
        const mouseDownTarget = mousedown.target as Node;
        const isBinding = !binding || !binding.instance;
        const isExistTargets = !mouseUpTarget || !mouseDownTarget;
        const isContainerEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget);
        const isSelf = el === mouseUpTarget;
        const isContainByPopper = popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget))
        const isTargetExcluded = excludes.length && (excludes.some(item => item.contains && item?.contains(mouseUpTarget)) || excludes.indexOf(mouseDownTarget as HTMLElement) > -1);
        if(isBinding || isExistTargets || isContainerEl || isSelf || isTargetExcluded || isContainByPopper)return;
        // the directive should binding a method or function
        binding.value();
    }
}
const setNodeList = (el:HTMLElement,binding:DirectiveBinding) => {
    nodeList.set(el,{
        DocumentHandler:createDocumentHandler(el,binding),
        bindingFn:binding.value
    })
}
const clickOutside:ObjectDirective = {
    beforeMount(el,binding){
        setNodeList(el,binding);
    },
    updated(el,binding){
        setNodeList(el,binding);
    },
    unmounted(el){
        nodeList.delete(el);
    }
}
export default clickOutside;



通过以上源码,我们需要知道哪些点,首先我们是通过 map 数据结构来存储整个节点,每个节点对应一个对象,对象里面对应一个文档节点的回调方法,和指令值所执行的方法。

我们知道,在 vue 的指令当中也有对应的生命周期钩子函数,在这里我们用到了 beforeMount,updated,以及 unmounted 钩子函数,在元素挂载和数据更新的钩子函数中,我们存储调用的逻辑对象,在组件卸载的钩子函数中,我们删除以元素作为存储的对应节点的逻辑对象。

在 mousedown 事件中,我们用了一个变量来存储事件对象,然后在 mouseup 事件中,我们就调用对应的文档节点存储的回调方法。

这里的判断元素节点是否是在弹框内容之外的核心逻辑,其实就在 createDocumentHandler 这个函数中。

在这个函数当中,我们首先用一个数组来存储指令的 arg 参数,这个参数如果传了,并且是一个 dom 元素,我们就保存起来。

然后我们返回一个函数,函数有 2 个参数,分别是鼠标按下的事件对象和鼠标释放的事件对象,在这个函数里面,我们主要对每一种情况都做了分析。

归根结底就是判断元素是否存在,并且元素不应该是 popover 组件,并且在我们存储的数组当中存在该元素,都直接 return,代表我们点击的是元素区域内。

如果不满足这些条件,我们才调用指令的值,它是一个方法。

这个指令理解了,接下来我们的弹框组件就好理解多了。

弹框组件的实现



弹框组件整体逻辑并不算复杂,主要需要考虑样式的编写,以及配置属性,可以尝试思考一下,一个弹框组件应该会有哪些基本属性,如下。

  • title: 弹框的标题
  • content: 弹框的内容

其余的属性都是额外延伸出来的,例如 hasFooter 属性,顾名思义,就是是否显示弹框底部内容,其他额外的属性如下所示:

  • showCancel: 是否显示取消按钮
  • isRenderContentHTML: 弹框内容是否渲染 html 元素
  • maskCloseable: 是否允许点击遮罩层关闭弹框
  • canceText: 取消按钮文本
  • okText: 确认按钮文本
  • align: 弹框底部的布局方式
  • container: 渲染弹框的容器元素


当然一个复杂的弹框还会有更多属性,用来应对各种各样的场景,但是这些属性在这个示例当中已经足够了。

除此之外,为了实现自定义组件的 v-model 指令,我们在这里也定义了一个 modelValue 属性,属性方面分析完成,接下来就是分析事件的注册,主要有三个事件,第一就是 update:modelvalue,还有两个就是点击确认和取消事件。
在这里,我们也知道了 clickoutside 指令的使用方式,首先就是导入指令,然后用一个变量(为了添加独特的标志,代表是 Vue 框架的指令),我们定义成 VClickOutside,然后在模板代码中,我们就可以直接 v-click-outside 这样来使用了。

其实分析到这里,一个弹框组件基本也就完成了,接下来就是添加样式,去美化弹框组件了,当然这里还使用了一个 teleport 组件,这个组件是 Vue3 独特添加的一个组件,用来将组件插入到某个容器元素的,现在我们就来看完整的代码吧:

<script setup lang="ts">
import { PropType, toRefs } from 'vue';
import clickOutside from "../directives/clickOutside";
const props = defineProps({
    modelValue: Boolean as PropType<boolean>,
    title: String as PropType<string>,
    content: String as PropType<string>,
    hasFooter: {
        type: Boolean as PropType<boolean>,
        default: true
    },
    showCancel: {
        type: Boolean as PropType<boolean>,
        default: true
    },
    isRenderContentHTML: {
        type: Boolean as PropType<boolean>,
        default: false
    },
    maskCloseable: {
        type: Boolean as PropType<boolean>,
        default: true
    },
    cancelText: {
        type: String as PropType<string>,
        default: "取消"
    },
    okText: {
        type: String as PropType<string>,
        default: "确认"
    },
    align: {
        type: String as PropType<string>,
        default: 'right',
        validator: (v: string) => {
            return ['left''center''right'].includes(v);
        }
    },
    container: {
        type: String as PropType<string>,
        default: 'body'
    }
});
const emit = defineEmits(['update:modelValue''on-ok''on-cancel']);
emit('update:modelValue');
const { modelValue, title, content, hasFooter, cancelText, okText, align, container, maskCloseable, isRenderContentHTML } = toRefs(props);
const onClickOutsideHandler = () => {
    if (maskCloseable.value) {
        emit('update:modelValue'false);
    }
}
const VClickOutside = clickOutside;
const onCancelHandler = () => {
    emit('update:modelValue'false);
    emit('on-cancel');
}
const onOkHandler = () => {
    emit('on-ok');
}
</script>
<template>
    <teleport :to="container">
        <Transition name="modal">
            <div v-if="modelValue" class="bm-modal-mask">
                <div class="bm-modal-wrapper">
                    <div class="bm-modal-container" v-click-outside="onClickOutsideHandler">
                        <div class="bm-modal-header" v-if="title">
                            <slot name="header">{{ title }}</slot>
                        </div>
                        <div class="bm-modal-body" v-if="content">
                            <slot name="body">
                                <p v-if="isRenderContentHTML" v-html="content"></p>
                                <template v-else>{{ content }}</template>
                            </slot>
                        </div>
                        <div class="bm-modal-footer" v-if="hasFooter" :class="{ ['text-' + align]: true }">
                            <slot name="footer">
                                <button class="bm-modal-footer-btn" @click="onCancelHandler" v-if="showCancel">{{
                                cancelText
                                }}</button>
                                <button class="bm-modal-footer-btn primary" @click="onOkHandler">{{ okText
                                }}</button>
                            </slot>
                        </div>
                    </div>
                </div>
            </div>
        </Transition>
    </teleport>
</template>
    
<style lang="scss" scoped>
$btnBorderColor#c4c4c4;
$primaryBgColor: linear-gradient(135deg, #77b9f3 10%, #106ad8 90%);
$primaryHoverBgColor: linear-gradient(135deg, #4d95ec 10%, #0754cf 90%);
$btnHoverColor#3a6be7;
$btnHoverBorderColor#2c92eb;
.#{$prefix}modal-mask {
    @include setProperty(background-color, fade-out($black, .5));
    @include setProperty(position, fixed);
    @include setProperty(z-index, 2000);
    @include setProperty(top, 0);
    @include setProperty(left, 0);
    @include setProperty(bottom, 0);
    @include setProperty(right, 0);
    @include setProperty(transition, all .2s ease-in-out);
    @include setProperty(font-size,.2rem);
    .#{$prefix}modal-wrapper {
        @extend .flex-center;
        @include setProperty(height, percentage(1));
        .#{$prefix}modal-container {
            @include setProperty(min-width, 300px);
            @include setProperty(margin, 0 auto);
            @include setProperty(background-color, $white);
            @include setProperty(border-radius, 4px);
            @include setProperty(transition, all .2s ease-in-out);
            @include setProperty(box-shadow, 0 1px 6px fade-out($black, .67));
            .#{$prefix}modal-header {
                @include setProperty(padding, 20px 30px);
                @include setProperty(border-bottom, 1px solid fade-out($white, .65));
                @include setProperty(color, fade-out($black, .15));
            }
            .#{$prefix}modal-body {
                @include setProperty(padding, 20px 30px);
            }
            .#{$prefix}modal-footer {
                @include setProperty(padding, 20px 30px);
                &.text-left {
                    @include setProperty(text-align, left);
                }
                &.text-center {
                    @include setProperty(text-align, center);
                }
                &.text-right {
                    @include setProperty(text-align, right);
                }
                &-btn {
                    @include setProperty(outline, none);
                    @include setProperty(display, inline-block);
                    @include setProperty(background, transparent);
                    @include setProperty(border, 1px solid $btnBorderColor);
                    @include setProperty(border-radius, 8px);
                    @include setProperty(padding, 8px 12px);
                    @include setProperty(color, fade-out($black, .15));
                    @include setProperty(letter-spacing, 2px);
                    @include setProperty(font-size, 14px);
                    @include setProperty(font-weight, 450);
                    @include setProperty(cursor, pointer);
                    @include setProperty(transition, background .3s cubic-bezier(.123, .453, .56, .89));
                    &:first-child {
                        @include setProperty(margin-right, 15px);
                    }
                    &:hover {
                        @include setProperty(color, $btnHoverColor);
                        @include setProperty(border-color, $btnHoverBorderColor);
                    }
                    &.primary {
                        @include setProperty(background, $primaryBgColor);
                        @include setProperty(color, $white);
                        &:hover {
                            @include setProperty(background, $primaryHoverBgColor);
                        }
                    }
                }
            }
        }
    }
}
.baseModalStyle {
    @include setProperty(transform, scale(1));
}
.modal-enter-from {
    @include setProperty(opacity, 0);
    .#{$prefix}modal-container {
        @extend .baseModalStyle;
    }
}
.modal-leave-to {
    @include setProperty(opacity, 0);
    .#{$prefix}modal-container {
        @extend .baseModalStyle;
    }
}
</style>



弹框组件实现完成,我们本示例所用到的公共组件也就完成了,接下来,我们来完善游戏的核心逻辑,在 core 目录下新建 game.ts 文件。

游戏核心逻辑



由于我们每一个素材需要一个唯一的 uuid 标志,所以 createUUID 方法需要在这里导入进来,另外我们需要随机打乱顺序,虽然可以自己写方法来实现,但是这里为了方便,我们使用 lodash.js,然后我们还要将游戏配置的状态管理 store 给导入进来。

其实这个文件我们主要导出一个函数组件,所以我们先写一个基本结构,代码如下:

import { createUUID } from './../utils/util';
import { useConfigStore } from './../store/store';
import _ from 'lodash';
import { onMounted, ref } from 'vue';

const useGame = () => {
   //游戏核心逻辑
}

export default useGame;



游戏的核心逻辑其实也不难,主要是打乱素材列表然后导出的逻辑,然后还有一个逻辑,那就是如果用户点击的是 2 个相同的素材,那么我们需要执行相应的逻辑,比如更改素材列表。

我们一步步来看,首先是第一步,拿到游戏的配置状态,代码如下:

const { gameConfig } = useConfigStore();


接着,我们用一个数组来存储数组列表,并且用另外一个数组用来存储用户点击的素材列表,素材列表的对象有如下几个属性:

  • active 表示当前素材是否被选中,用来确定是否添加一个选中样式
  • src 表示素材的路径,也就是图片路径
  • title? 表示描述素材的标题
  • id 唯一标志,uuid
  • isMatch 表示是否匹配

 这里可能有人疑惑为什么不能用 active 来同时表示选中和是否匹配,其实增加一个字段来表示是否匹配,我们会更方便写逻辑,因为只有在满足 2 项选中素材的情况下,我们才需要考虑判断是否匹配。

所以定义好两种数据结构,代码如下:

const materialDataList = ref<GlobalModule.MaterialData[]>([]);
const activeDataList = ref<GlobalModule.MaterialData[]>([]);


下一步,我们还用了两个变量来存储错误和正确的 audio 对象,用来添加音    效,当然其实音效逻辑不应该放在这游戏核心逻辑中。

const rightAudio = ref<HTMLAudioElement>();
const wrongAudio = ref<HTMLAudioElement>();


最后,我们还需要定义一个匹配数用来判断用户是否匹配完成所有的素材,以及一个用来确定游戏状态的值,如下:

const totalMatch = ref(0);
const gameStatus = ref(gameConfig.gameStatus);


接下来的逻辑也就比较简单了,其实就是重复复制素材列表,然后随机打乱顺序,并修改。如下:

const onStartGame = () => {
    materialDataList.value = _.shuffle(_.flatten(_.times(2, _.constant(gameConfig.materialList.map(item => ({
        src: item.src,
        title: item.title,
        active: false,
        isMatch: false
    })))))).map(item => ({
        id: createUUID(),
        ...item
    }));
}


这里使用了 lodash 的 shuffle 方法来实现打乱顺序,用了 flatten,times,constant 方法来实现重复复制,这一段逻辑还确实有点点复杂,主要需要了解 lodash 的 4 个方法的使用。

接下来就是游戏的点击逻辑,点击逻辑,我们思考一下,可以先将点击的素材对象添加到数组中去,然后判断点击的素材数组中是否有重复的项。

这里难点就来了,如何判断是否重复?

这里我们用到了一个哈希表的算法,详细算法思路可以参考剑指 offer-查找重复的数字https://eveningwater.github.io/to-offer/#/codes/1/findRepeatNumber,我这里就是依据这里来进行稍微的改造,从而实现了算法。代码如下:

const findRepeatItem = function (arr: GlobalModule.MaterialData[]) {
    const unique = new Set();
    for (const item of arr) {
        if (unique.has(item.src)) {
            return true;
        }
        unique.add(item.src);
    }
    return false;
};


点击事件的核心逻辑,其实细细分下来,就主要是 2 点,添加选中样式,然后判断是否重复,分别执行对应的逻辑。说到这里,相信没有人会看不懂如下代码了:

const onClickHandler = (block: GlobalModule.MaterialData) => {
    block.active = true;
    // 这里判断如果用户点击的是同一张素材,则下面的逻辑就不执行
    if (activeDataList.value.findIndex(item => item.id === block.id) > -1) {
        return;
    }
    // 添加到选中素材数组中
    activeDataList.value.push(block);
    // 获取正确和错误音效 audio 元素,并存储到数据中
    if(!rightAudio.value){
        rightAudio.value = document.getElementById('rightAudio') as HTMLAudioElement;
    }
    if(!wrongAudio.value){
        wrongAudio.value = document.getElementById('wrongAudio') as HTMLAudioElement;
    }
    // 判断是否存在重复项
    if (findRepeatItem(activeDataList.value)) {
        // 存在就更改 isMatch 值,并从选中素材数组中删除对应的值
        materialDataList.value = materialDataList.value.map(item => {
            const index = activeDataList.value.findIndex(active => active.id === item.id);
            if (index > -1) {
                item.isMatch = true;
                activeDataList.value.splice(index, 1);
            }
            return item;
        });
        // 统计匹配的数量,这里加 2 主要是方便,后面该值等于 materialDataList.value.length === 2 就代表全部消除完了,游戏胜利
        totalMatch.value += 2;
        // 播放音效
        rightAudio.value?.play();
        wrongAudio.value?.pause();
    } else {
        // 素材列表长度不等于 2,就代表用户只点击了一张,无法进行匹配,所以后续逻辑不执行
        if (activeDataList.value.length !== 2) {
            return;
        }
        // 重置选中素材列表以及素材列表的喧哗走过呢状态
        activeDataList.value = [];
        materialDataList.value = materialDataList.value.map(item => ({
            ...item,
            active: false
        }));
        // 播放音效
        rightAudio.value?.pause();
        wrongAudio.value?.play();
    }
}


下一步,我们就在 mounted 挂载钩子函数中调用游戏开始函数,如下:

onMounted(() => {
    onStartGame();
})


最后,我们导出需要用到的东西,如下:

return {
    materialDataList,
    gameConfig,
    gameStatus,
    totalMatch,
    onClickHandler,
    onStartGame
}


合并以上代码,我们的游戏核心逻辑就完成了,到了这里,其实我们本游戏就已经基本完成一半了,让我们继续。

更改根元素字体的函数



继续下一个素材列表页面组件的实现之前,我们先来看如何让页面根据浏览器设备自动更改字体大小的函数。

由于这里采用的是 javascript 写法,所以我直接写在了 index.html 文件里面,当然这并不是一个好的方式。

首先定义了一个自调用函数,在 javascript 中,我们通常是这样些自调用函数的:

(function(){
    //  函数核心代码
})()


事实上自调用函数不止可以使用括号包裹,还可以使用感叹号,加号等操作符,这里使用的就是感叹号!。

然后在这个自调用函数当中,传入了 2 个参数,第一个是 window 对象,第二个则是配置对象,如下:

!function(win,option){
    //核心代码
}(window,{ designWidth: 750 })


然后这个自调用函数可以拆分 3 部分,第一部分就是初始化变量,第二部分则是更改 fontsize 的函数,第三部分就是监听事件。我们先来看第一部分的变量初始化:

通过变量的初始化,我们可以看到 option 配置对象的参数有 4 个。如下:

var count = 0,
    designWidth = option.designWidth,
    designHeight = option.designHeight || 0,
    designFontSize = option.designFontSize || 100,
    callback = option.callback || null,
    root = document.documentElement,
    body = document.body,
    rootWidth, newSize, t, self;


下一个函数,设置字体大小的函数 _getNewFontSize,这个函数主要是对字体大小做一个计算,取比例 scale 与设计图字体的大小相乘,比例可以通过宽度除以设计图宽度或者是高度除以设计图高度即可得到,而设计图宽度和高度就是 option 配置对象传入的值。代码如下:

function _getNewFontSize() {
    const iw = win.innerWidth > 750 ? 750 : win.innerWidth;
    const scale = designHeight !== 0 ? Math.min(iw / designWidth, win.innerHeight / designHeight) : iw / designWidth;
    return parseInt(scale * 10000 * designFontSize) / 10000;


下一步也是一个自调用函数,函数里面,我们做了判断,从而来确定设置字体的大小,代码如下:

!function () {
    rootWidth = root.getBoundingClientRect().width;
    self = self ? self : arguments.callee;
    if (rootWidth !== win.innerWidth && count < 20) {
      win.setTimeout(function () {
        count++;
        self();
      }, 0);
    } else {
      newSize = _getNewFontSize();
      if (newSize + 'px' !== getComputedStyle(root)['font-size']) {
        // 核心代码就这一行
        root.style.fontSize = newSize + "px";
        return callback && callback(newSize);
      };
    };
}();


最后监听屏幕旋转事件 orientationchange 和改变窗口大小事件 resize,延迟调用设置字体大小函数即可。代码如下:

win.addEventListener("onorientationchange" in window ? "orientationchange" : "resize"function () {
    clearTimeout(t);
    t = setTimeout(function () {
      self();
    }, 200);
}, false);


到此为止,这个函数就分析完成了,让我们继续下一步。

素材列表页面组件



素材列表页面组件主要包含 3 个部分,如下:

  • 倒计时
  • 素材列表
  • 弹框逻辑

本页面采用了浮动和 rem 布局。根据以上分析,我们的 html 代码就很简单了,如下:

<div class="bm-container bm-clearfix" :class="{ active:props.active }">
<!-- 倒计时部分 -->
    <div class="bm-start-time">{{ count }}</div>
    <!-- 素材列表部分 -->
    <ul class="bm-game-list bm-clearfix">
        <li class="bm-game-list-item" v-for="item inmaterialDataList" :key="item.id"
            :class="{ active: item.active }" @click="() =>onClickHandler(item)"
            :style="{ opacity: item.isMatch ? 0 : 1 }">
            <img :src="item.src" :alt="item.title" class="bm-game-list-item-image" />
        </li>
    </ul>
    <slot></slot>
    <!-- 弹框组件 -->
    <Modal v-model="showModal" :title="modalTitle" :content="modalContent" :okText="modalOkText"
            @on-ok="onOkHandler" :maskCloseable="false" :show-cancel="false" />
</div>


我们用来自父组件的 active 属性用来确定这个组件是否显示,样式部分其实也没什么好说的,分成了两部分,第一部分是 PC 端的样式,第二部分则是移动端的样式。代码如下:

$boxShadowColor#eee;
$activeBorderColor#2f3394;
$bgColor#1f3092;
.#{$prefix}container {
    @include setProperty(position, relative);
    @include setProperty(padding, 0 .1rem .18rem .1rem);
    @include setProperty(left, percentage(.5));
    @include setProperty(top, percentage(.5));
    @include setProperty(width, 10.9rem);
    @include setProperty(height, auto);
    @include setProperty(border-radius, .2rem);
    @include setProperty(transform, translate(-50%, -50%));
    @include setProperty(text-align, center);
    @include setProperty(user-select, none);
    @include setProperty(z-index, 99);
    @include setProperty(background, $bgColor);
    &.active {
        @include setProperty(animation, bounceIn 1s);
        @include setProperty(box-shadow, 0 0 .1rem .1rem $boxShadowColor);
        @keyframes bounceIn {
            from {
                @include setProperty(opacity, 0);
            }
            to {
                @include setProperty(opacity, 1);
            }
        }
    }
    .#{$prefix}start-time {
        @include setProperty(position, absolute);
        @include setProperty(top, -.4rem);
        @include setProperty(color, $white);
        @include setProperty(right, -.5rem);
        @include setProperty(font-size, .28rem);
    }
    .#{$prefix}game-list {
        @include setProperty(width, percentage(1));
        @include setProperty(height, percentage(1));
        @include setProperty(float, left);
        @include setProperty(display, block);
        &-item {
            @include setProperty(float, left);
            @include setProperty(margin, .18rem 0 0 .1rem);
            @include setProperty(width, 1.67rem);
            @include setProperty(height, .9rem);
            @include setProperty(cursor, pointer);
            @include setProperty(border, .03rem solid $white);
            &:hover {
                @include setProperty(box-shadow, 0 0 .2rem $white);
            }
            &.active {
                @include setProperty(border-color, $activeBorderColor);
            }
            &-image {
                @include setProperty(width, percentage(1));
                @include setProperty(height, percentage(1));
                @include setProperty(display, inline-block);
                @include setProperty(vertical-align, top);
            }
        }
    }
}
@media screen and (max-width: 640px) {
    .#{$prefix}container {
        @include setProperty(width, 6rem);
        @include setProperty(padding-bottom, .3rem);
        .#{$prefix}game-list {
            &-item {
                @include setProperty(width, percentage(.3));
                @include setProperty(margin-left, .15rem);
                @include setProperty(margin-top, .3rem);
            }
        }
    }
}


都是一些常规的样式布局,我们主要来看一下核心的逻辑,其实核心的逻辑在 game.ts 里面基本实现了,我们只需要拿出来用即可。

首先是用一个变量存储倒计时的值,其次用一个变量控制弹框组件的显隐,还有 3 个变量分别代表弹框组件的标题,内容和确定按钮的内容,为什么要用变量代表弹框组件的标题,内容和确定按钮的内容呢?

这里我们的游戏分为两种状态,第一种就是游戏胜利,第二种则是游戏失败,两种状态的反馈提示是不一样的,所以才需要变量来代替。

所以以下代码就比较好理解了。

import { PropType, ref, watch } from 'vue';
import useGame from '../core/game';
import { CountDown } from '../utils/util';
import Modal from '../components/Modal.vue';
const count = ref<number>();
const showModal = ref(false);
const modalTitle = ref<string>('温馨提示');
const modalContent = ref<string>();
const modalOkText = ref<string>();


接下来,我们获取游戏核心逻辑函数中导出的方法和数据,如下:

const { materialDataList, onClickHandler, gameConfig, totalMatch,onStartGame,gameStatus } = useGame();


随后,我们定义一个 active 的属性,用来确定这个组件是否显示,动画效果已经在 scss 中实现了,就是渐隐效果,通过类名控制,如以上的模版代码中所写。

接着,我们定义好暴露给父组件的事件,分为 3 种,游戏结束,游戏胜利和点击弹框确认按钮事件。代码如下:

const props = defineProps({
    active: {
        type: Boolean as PropType<boolean>
    }
})
const emit = defineEmits(['on-game-over''on-win''on-ok']);


最后,我们监听 props.active,如果这个值是 true,就代表这个组件显示,也就代表游戏开始,然后我们执行倒计时函数,在倒计时回调函数中,我们通过返回的 status 是否等于 end 来判定倒计时时间是否已执行完成,随后我们如前面所说,根据 totalMatch 是否等于素材列表的长度代表用户是否消除掉所有图片素材,从而确定游戏是否胜利,游戏结束和游戏胜利,我们都要清空倒计时的定时器,并且修改弹框组件的内容和确定按钮的文本,然后暴露出事件传递给父组件,因为父组件可能会在游戏胜利和游戏结束中执行一些逻辑,比如添加音效之类的,所以我们暴露出去。根据这个分析,以下代码就比较好理解了。

watch(() => props.active, (val) => {
    if (val) {
        CountDown(gameConfig.time, 0, 1, 1000, (res) => {
            count.value = res.value;            
            const isWin = () => totalMatch.value === materialDataList.value.length;
            if (res.status === 'end') {
                if (!isWin()) {
                    showModal.value = true;
                    modalContent.value = `游戏已结束!`;
                    modalOkText.value = '重新开始';
                    res.clear?.();
                    emit('on-game-over');
                }
            } else {
                if (isWin()) {
                    showModal.value = true;
                    modalContent.value = `完成游戏共耗时:${gameConfig.time - count.value}s!`;
                    modalOkText.value = '再玩一次';
                    res.clear?.();
                    emit('on-win');
                }
            }
        });
    }
});


然后还有一个逻辑,就是点击确认按钮事件,这个没什么好说的,就是重置游戏的素材列表和一些状态。如下:

const onOkHandler = () => {
    showModal.value = false;
    onStartGame();
    totalMatch.value = 0;
    emit('on-ok');
}


到此为止,这个素材列表组件就完成了,最后一步就是根组件 App.vue 里面了,这里面主要做一些音效逻辑,我们来详细看一下吧。

根组件里的逻辑实现



根组件主要处理 6 种音效逻辑,并且用一种状态控制素材列表页面和首页的切换,然后还有一个逻辑,就是使用我们已经封装好的下雪花的逻辑。我们来看模板代码,如下:

<!-- 雪花效果容器元素 -->
<div ref="snow" class="bm-snow"></div>
<!-- 音效元素 -->
<audio :src="bgMusic" ref="bgAudio"></audio>
<audio :src="readyMusic" ref="readyAudio"></audio>
<audio :src="rightMusic" id="rightAudio"></audio>
<audio :src="wrongMusic" id="wrongAudio"></audio>
<audio :src="loseMusic" ref="loseAudio"></audio>
<audio :src="winMusic" ref="winAudio"></audio>
<!-- ready 和 go 组件以及按钮组件 -->
<Ready v-model="countShow" v-show="countShow"></Ready>
<Go v-model="countShow" v-show="countShow"/>
<Button @click="onStart" :style="{ display: countShow ? 'none' : 'block'}"></Button>
<!-- 素材列表组件 -->
<Container 
    v-show="gameStatus === 1"
    :active="gameStatus === 1" 
    @on-game-over="onGameOver" 
    @on-win="onWin" 
    @on-ok="onOkHandler"
></Container>


样式也没什么好说的,就是给雪花效果容器元素设置一下,让它撑满全屏即可,用绝对定位。

.#{$prefix}snow {
    @include setProperty(width,percentage(1));
    @include setProperty(height,percentage(1));
    @include setProperty(position,absolute);
    @include setProperty(z-index,0);
}


js 逻辑代码也很简单,都是一些资源导入以及变量的初始化,还有就是相关事件的逻辑。看下源码基本很好理解。

import { onMounted,ref } from 'vue';
import Snow from './utils/snow';
import Button from './components/Button.vue';
import Go from './components/Go.vue';
import Ready from './components/Ready.vue';
import bgMusic from '@/assets/audio/bgMusic.mp3';
import readyMusic from '@/assets/audio/go.mp3';
import rightMusic from '@/assets/audio/right.mp3';
import wrongMusic from '@/assets/audio/wrong.mp3';
import loseMusic from '@/assets/audio/lose.mp3';
import winMusic from '@/assets/audio/win.mp3';
import Container from './views/Container.vue';
import { useConfigStore } from './store/store';
import useGame from './core/game';
// 使用到的游戏配置和游戏状态
const { setGameConfig,gameConfig } = useConfigStore();
const { gameStatus } = useGame();
// 一些状态
const snow = ref<HTMLDivElement>();
const countShow = ref(false);
const bgAudio = ref<HTMLAudioElement>();
const readyAudio = ref<HTMLAudioElement>();
const loseAudio = ref<HTMLAudioElement>();
const winAudio = ref<HTMLAudioElement>();
// 游戏开始
const onStart = () => {
    countShow.value = true;
    readyAudio.value?.play();
    bgAudio.value?.play();
    bgAudio.value?.setAttribute('loop','loop');
    setTimeout(() => {
        setGameConfig({
            ...gameConfig,
            gameStatus: 1
        })
        gameStatus.value = 1;
    },1800);
}
// 关闭背景音效
const onStopMusic = () => {
    bgAudio.value?.pause();
}
// 游戏结束
const onGameOver = () => {
    onStopMusic();
    loseAudio.value?.play();
}
// 游戏胜利
const onWin = () => {
    onStopMusic();
    winAudio.value?.play();
}
// 确认按钮的逻辑
const onOkHandler = () => {
    countShow.value = false;
    gameStatus.value = 0;
}
onMounted(() => {
    // 初始化雪花效果
    if(snow.value){
        const s = new Snow(snow.value!);
        s.init();
    }
});


到此为止,我们的连连看小游戏就算是大功告成了,当然我只是完成了一个基础版,我们还可以扩展,比如游戏时间的设置,以及素材列表的设置,那就是再添加一个配置页面,或许到了后面我会扩展也说不一定。

本文源码点这里https://github.com/eveningwater/my-web-projects/tree/master/vue/81
在线示例点这里https://www.eveningwater.com/my-web-projects/vue/81/



点击左下角阅读原文,到 SegmentFault 思否社区 和文章作者展开更多互动和交流,“公众号后台回复“ 入群 ”即可加入我们的技术交流群,收获更多的技术文章~

- END -


延伸阅读

关注公众号:拾黑(shiheibook)了解更多

赞助链接:

关注数据与安全,洞悉企业级服务市场:https://www.ijiandao.com/
四季很好,只要有你,文娱排行榜:https://www.yaopaiming.com/
让资讯触达的更精准有趣:https://www.0xu.cn/

公众号 关注网络尖刀微信公众号
随时掌握互联网精彩
赞助链接