乐趣区

关于前端:利用思否猫实现一个连连看小游戏

vue3 实现一个连连看小游戏

本文参加了 1024 程序员节,欢送正在浏览的你也退出。

通过本文,你将学到:

  1. vue3 的外围语法
  2. vue3 的状态管理工具 pinia 的用法
  3. sass 的用法
  4. 根本算法
  5. canvas 实现一个下雪的成果,一些 canvas 的根本用法
  6. rem 布局
  7. typescript 知识点

开始之前

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

首页如下:

游戏页如下:

如上图所示,咱们本游戏蕴含了两局部,第一局部就是首页,第二局部则是游戏页面。而后首页咱们又能够分成两个局部,第一局部则是下雪花的成果,第二局部就是一个背景图和按钮。游戏页面同理也是分成两个局部,第一个局部就是列表,第二个局部则是倒计时成果。

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

所有页面剖析实现,接下来让咱们初始化一个 vite 工程项目。

初始化工程

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

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

而后一路回车,初始化实现工程,初始化实现之后,输出 npm install, 下载依赖,下载完依赖,因为咱们应用到了 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());
    }
    // 省略了代码
}

除此之外,Snow 类还额定封装了一个 remove 办法,用来移除 Snow 创立的 canvas 元素,尽管在本示例当中没有用到,然而也能够说一下。

class Snow {
    // 省略了代码
    remove(){if(isDom(this.el)){(this.el as HTMLElement).removeChild(this.snowCan);
        }else{document.body.removeChild(this.snowCan);
        }
    }
    // 省略了代码
}

接下来咱们来看 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 的这些具体代表什么,能够查看文档。

这里咱们应用 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 组件的源码,两者应该是相似的,基本上写了一个,另一个就好写了,无非是动画的成果不同罢了。

  • Go.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-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 指令,顾名思义,就是点击元素区域之外所执行的逻辑。试想一下,咱们通常在实现弹框组件的时候,点击弹框内容外面是不必敞开弹框的,然而点击遮罩层就须要敞开弹框了,所以这个指令在此也就有了用武之地。

像一些下拉框组件 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- 查找反复的数字,我这里就是根据这里来进行略微的革新,从而实现了算法。代码如下:

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();}
});

到此为止,咱们的连连看小游戏就算是功败垂成了,当然我只是实现了一个根底版,咱们还能够扩大,比方游戏工夫的设置,以及素材列表的设置,那就是再增加一个配置页面,或者到了前面我会扩大也说不肯定。

本文源码点这里。
在线示例点这里。

退出移动版