今天小编为大家带来的是社区作者 夕水 的文章,让我们一起来学习 vue3 实现一个思否猫连连看小游戏。
vue3 的核心语法 vue3 的状态管理工具 pinia 的用法 sass 的用法 基本算法 canvas 实现一个下雪的效果,一些 canvas 的基本用法 rem 布局 typescript 知识点
开始之前
初始化工程
npm create vite <项目名> --template vue-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文件的配置
})
// assets: 存储静态资源的目录
// components: 公共组件目录
// core: 游戏核心逻辑目录
// directives: 指令目录
// store: 状态管理目录
// style: 样式目录
// utils: 工具函数目录
// views: 页面视图目录
定义接口
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 }>
}
LevelType : 数值或者字符串类型,这里是用作 h1 ~ h6 标签名的组成的类型,也就是说我们在后面将会封装一个 Head 组件,代表标题组件,组件会用到动态的标签名,也就是这里的 1 ~ 6 属性,它可以是字符串或者数值,所以定义在这里。 ElementType : 顾名思义,就是定义元素的类型,这在实现下雪花以及获取 Dom 元素当中用到。 SnowOptionType: 下雪花效果配置对象的类型,包含三个参数值,雪花数量,雪花形状以及雪花速度,都是数值类型。 GameConfigType: 游戏配置类型,materialList 代表素材列表类型,是一个对象数组,因此定义成 Record<string,string> [],time 代表倒计时时间, gameStatus 代表游戏状态。 MaterialData : 素材列表对象类型。 DocumentHandler : 文档对象回调函数类型,是一个函数,这在实现自定义指令中会用到。 FlushList : 用 map 数据结构存储元素节点的事件回调函数类型,也是用在实现自定义指令当中。
创建 store
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();
}
}
})
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')
游戏配置
// 素材列表是可以随意更换的
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
}
初始化样式
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);
}
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));
}
实现一些会用到的工具函数
export const isServer = typeof window === "undefined";
export function on(
element: GlobalModule.ElementType,
type: string,
handler: EventListenerOrEventListenerObject,
useCapture: boolean = false
) {
if (element && type && handler) {
element.addEventListener(type, handler, useCapture);
}
}
if(typeof HTMLElement === 'object'){
return el instanceof HTMLElement;
}
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;
}
export const createUUID = (): string => (Math.random() * 10000000).toString(16).substr(0, 4) + '-' + (new Date()).getTime() + '-' + Math.random().toString().substr(2, 5);
export const CountDown = (start:number,end:number,step:number = 1,duration:number = 2000,callback:(args: { status:string,value:number,clear:() => void } ) => any) => {
//核心逻辑
}
let timer: ReturnType<typeof setTimeout>,
current = start + 1,
step = (end - start) * step < 0 ? -step : step;
const handler = () => {
//核心代码
}
handler();
return {
clear:() => clearTimeout(timer);
}
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)
}
}
实现下雪花效果
class Snow {
//雪花类核心代码
}
class SnowMove {
//下雪花类核心代码
}
实现 Snow 类
class Snow {
constructor(element:GlobalModule.ElementType,option?:GlobalModule.SnowOptionType){
//初始化代码
}
}
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 = [];
}
}
createCanvas: 顾名思义,就是创建canvas元素的方法。 createSnowShape: 这是一个创建雪花形状的方法。 drawSnow: 画雪花的方法。
`class Snow {
//省略了部分代码
init(){
this.createCanvas();
this.createSnowShape();
this.drawSnow();
}
}
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);
}
}
//省略了代码
}
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 })
)
}
}
//省略了代码
}
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类
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;
}
}
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;
}
}
class SnowMove {
//省略了代码
reset(can: HTMLCanvasElement){
this.init(can.width,can.height,this.context.speed);
}
//省略了代码
}
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);
}
}
//省略了代码
}
ctx.save 保存状态 ctx.fillStyle 填充颜色 ctx.beginPath 开始路径 ctx.arc 画圆 ctx.fill 填充路径 ctx.restore 弹出状态
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();
一些公共组件的实现
Button组件的实现
<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>
go和ready组件的实现
<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>
@mixin head {
color:$white;
width: percentage(1);
text: {
align: center;
}
line: {
height: 400px;
}
position: absolute;
display: block;
}
<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>
clickoutside 指令的实现
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;
弹框组件的实现
title: 弹框的标题 content: 弹框的内容
showCancel: 是否显示取消按钮 isRenderContentHTML: 弹框内容是否渲染 html 元素 maskCloseable: 是否允许点击遮罩层关闭弹框 canceText: 取消按钮文本 okText: 确认按钮文本 align: 弹框底部的布局方式 container: 渲染弹框的容器元素
<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>
游戏核心逻辑
import { createUUID } from './../utils/util';
import { useConfigStore } from './../store/store';
import _ from 'lodash';
import { onMounted, ref } from 'vue';
const useGame = () => {
//游戏核心逻辑
}
export default useGame;
const { gameConfig } = useConfigStore();
active 表示当前素材是否被选中,用来确定是否添加一个选中样式 src 表示素材的路径,也就是图片路径 title? 表示描述素材的标题 id 唯一标志,uuid isMatch 表示是否匹配
const materialDataList = ref<GlobalModule.MaterialData[]>([]);
const activeDataList = ref<GlobalModule.MaterialData[]>([]);
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
}));
}
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;
};
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();
}
}
onMounted(() => {
onStartGame();
})
return {
materialDataList,
gameConfig,
gameStatus,
totalMatch,
onClickHandler,
onStartGame
}
更改根元素字体的函数
(function(){
// 函数核心代码
})()
!function(win,option){
//核心代码
}(window,{ designWidth: 750 })
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;
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);
};
};
}();
win.addEventListener("onorientationchange" in window ? "orientationchange" : "resize", function () {
clearTimeout(t);
t = setTimeout(function () {
self();
}, 200);
}, false);
素材列表页面组件
倒计时 素材列表 弹框逻辑
<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>
$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);
}
}
}
}
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();
const props = defineProps({
active: {
type: Boolean as PropType<boolean>
}
})
const emit = defineEmits(['on-game-over', 'on-win', 'on-ok']);
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');
}
根组件里的逻辑实现
<!-- 雪花效果容器元素 -->
<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);
}
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();
}
});