18910140161

javascript - 利用思否猫素材实现一个连连看小游戏_个人文章 - SegmentFault 思否

顺晟科技

2022-10-19 13:56:16

137

vue3实现一个思否猫连连看小游戏

本文参与了1024程序员节,欢迎正在阅读的你也加入。

通过本文,你将学到:

vue3的核心语法vue3的状态管理工具pinia的用法sass的用法基本算法canvas实现一个下雪的效果,一些canvas的基本用法rem布局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更新雪花的方法。所以在这个方法但这个方法当中,我们主要做的事情就是

我们已经准备好了,你呢?
2024我们与您携手共赢,为您的企业形象保驾护航