✨ 完成2048
添加分数和最高方块数字记分板 现在方块移动动画可以打断 修复部分Promise无法fulfil的问题
This commit is contained in:
parent
7b934108f5
commit
9190604d05
2
components.d.ts
vendored
2
components.d.ts
vendored
@ -27,6 +27,8 @@ declare module 'vue' {
|
||||
ElTabPane: (typeof import('element-plus/es'))['ElTabPane'];
|
||||
ElTabs: (typeof import('element-plus/es'))['ElTabs'];
|
||||
Game2048: (typeof import('./src/components/Game2048.vue'))['default'];
|
||||
Game2048Button: (typeof import('./src/components/Game2048Button.vue'))['default'];
|
||||
Game2048Score: (typeof import('./src/components/Game2048Score.vue'))['default'];
|
||||
IconCsLock: (typeof import('~icons/cs/lock'))['default'];
|
||||
IconCsUser: (typeof import('~icons/cs/user'))['default'];
|
||||
IconCsValidate: (typeof import('~icons/cs/validate'))['default'];
|
||||
|
36
src/App.vue
36
src/App.vue
@ -29,8 +29,8 @@
|
||||
</template>
|
||||
<el-avatar
|
||||
v-else
|
||||
style="color: black; user-select: none; cursor: pointer"
|
||||
:size="40"
|
||||
style="color: black; user-select: none; cursor: pointer"
|
||||
@click="showLoginRegisterDialog = true"
|
||||
>
|
||||
登录
|
||||
@ -42,10 +42,10 @@
|
||||
<router-view />
|
||||
</el-container>
|
||||
<el-dialog
|
||||
class="login-dialog"
|
||||
width="400"
|
||||
v-model="showLoginRegisterDialog"
|
||||
:align-center="true"
|
||||
class="login-dialog"
|
||||
width="400"
|
||||
>
|
||||
<el-tabs v-model="loginRegisterDialogActiveName">
|
||||
<el-tab-pane label="登录" name="login">
|
||||
@ -53,8 +53,8 @@
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginFormData.username"
|
||||
placeholder="请输入用户名"
|
||||
:disabled="logining"
|
||||
placeholder="请输入用户名"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-icon>
|
||||
@ -66,9 +66,9 @@
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginFormData.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:disabled="logining"
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-icon>
|
||||
@ -82,11 +82,11 @@
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-bottom: 0">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="logining"
|
||||
native-type="submit"
|
||||
style="width: 100%"
|
||||
type="primary"
|
||||
@click="submitLoginForm"
|
||||
:loading="logining"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
@ -103,8 +103,8 @@
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="registerFormData.username"
|
||||
placeholder="请输入注册用户名"
|
||||
:disabled="registering"
|
||||
placeholder="请输入注册用户名"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-icon>
|
||||
@ -116,9 +116,9 @@
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="registerFormData.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:disabled="registering"
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-icon>
|
||||
@ -130,9 +130,9 @@
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="registerFormData.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请确认密码"
|
||||
:disabled="registering"
|
||||
placeholder="请确认密码"
|
||||
type="password"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-icon>
|
||||
@ -146,11 +146,11 @@
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-bottom: 0">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="registering"
|
||||
native-type="submit"
|
||||
style="width: 100%"
|
||||
type="primary"
|
||||
@click="submitRegisterForm"
|
||||
:loading="registering"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
@ -160,7 +160,7 @@
|
||||
</el-tabs>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.el-header {
|
||||
--el-header-padding: var(--page-content-padding);
|
||||
position: relative;
|
||||
@ -174,7 +174,7 @@
|
||||
}
|
||||
|
||||
.el-container {
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
@ -218,7 +218,7 @@
|
||||
margin-right: 10px;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import axiosInstance from '@/api';
|
||||
import { type VerifyImagePath } from '@/components/VerifyInput.vue';
|
||||
import { loginResponseSchema, registerResponseSchema } from '@/schemas';
|
||||
|
@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { useUserStore } from '@/stores';
|
||||
import axios from 'axios';
|
||||
|
||||
const baseURL = 'http://wzpmc.cn:18080/';
|
||||
const axiosInstance = axios.create({
|
||||
|
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition @after-enter="onAfterEnter" @after-leave="onAfterLeave" name="bg">
|
||||
<div class="bg" v-show="show" :style="{ backgroundImage }"></div>
|
||||
<transition name="bg" @after-enter="onAfterEnter" @after-leave="onAfterLeave">
|
||||
<div v-show="show" :style="{ backgroundImage }" class="bg"></div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@ -36,7 +36,7 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
type BackgroundTask,
|
||||
type BackgroundURL,
|
||||
|
@ -1,49 +1,53 @@
|
||||
<template>
|
||||
<transition name="game-container" @after-enter="onContainerTransitionEnd">
|
||||
<div v-show="showContainer" class="game-container" :style="containerSizeStyle">
|
||||
<transition name="failed-mask">
|
||||
<div v-show="gameStatus !== 'playing'" class="failed-mask">
|
||||
<transition appear name="game-container" @after-enter="onContainerEnterComplete">
|
||||
<div :style="containerSizeStyle" class="game-container">
|
||||
<transition name="mask">
|
||||
<div v-show="gameStatus !== 'playing' && !hideMask" class="mask" @click="onMaskClick">
|
||||
<div v-if="gameStatus === 'succeed'">
|
||||
你赢了!
|
||||
<br />
|
||||
🎉🥳🎊
|
||||
<b>你赢了!</b>
|
||||
<div>🎉🥳🎊</div>
|
||||
<div style="font-size: 25px">点击关闭</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
你输了
|
||||
<br />
|
||||
🤣👉🤡
|
||||
<b>你输了</b>
|
||||
<div>🤣👉🤡</div>
|
||||
<div style="font-size: 25px">点击关闭</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<template v-for="y in game2048Store.height">
|
||||
<!-- eslint-disable-next-line vue/require-v-for-key -->
|
||||
<div
|
||||
v-for="x in game2048Store.width"
|
||||
class="background-tile"
|
||||
:style="getTilePosition(y - 1, x - 1)"
|
||||
></div>
|
||||
</template>
|
||||
<transition-group appear name="background-tile" @after-enter="onBackgroundTileEnterComplete">
|
||||
<template v-for="y in height">
|
||||
<div
|
||||
v-for="x in width"
|
||||
:key="`${y}-${x}`"
|
||||
:style="[
|
||||
getTilePosition(y - 1, x - 1),
|
||||
{
|
||||
animationDelay: `${0.1 * (x + y - 2)}s`
|
||||
}
|
||||
]"
|
||||
class="background-tile"
|
||||
></div>
|
||||
</template>
|
||||
</transition-group>
|
||||
<div v-show="showTiles">
|
||||
<transition-group name="tile" type="animation" @after-enter="onTileAddCompleted">
|
||||
<transition-group name="tile" type="animation" @after-enter="onTileEnterComplete">
|
||||
<template v-for="(row, y) in tileGrid">
|
||||
<template v-for="(tiles, x) in row">
|
||||
<template v-for="tile in tiles" :key="tile.id">
|
||||
<div
|
||||
ref="tileDiv"
|
||||
ref="tileDivs"
|
||||
:class="[
|
||||
'tile',
|
||||
{
|
||||
'tile-appear-from-others': tile.fromOthers
|
||||
'tile-appear-from-others': tile.fromOthers,
|
||||
'tile-transition-cancelled': transitionCancelled
|
||||
}
|
||||
]"
|
||||
:style="[
|
||||
getTilePosition(y, x),
|
||||
getTileStyle(tile),
|
||||
{ transition: transitionCancelled ? 'none' : undefined }
|
||||
]"
|
||||
:data-tile-id="tile.id"
|
||||
@transitioncancel="onTileTransitionCompletedOrCancelled"
|
||||
@transitionend="onTileTransitionCompletedOrCancelled"
|
||||
:style="[getTilePosition(y, x), getNumberTileStyle(tile)]"
|
||||
@transitioncancel="onTileTransitionComplete"
|
||||
@transitionend="onTileTransitionComplete"
|
||||
>
|
||||
{{ tile.number }}
|
||||
</div>
|
||||
@ -55,7 +59,10 @@
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
@keyframes nil {
|
||||
}
|
||||
|
||||
@keyframes container-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
@ -69,21 +76,25 @@
|
||||
|
||||
@keyframes tile-common-appear {
|
||||
from {
|
||||
opacity: 0.5;
|
||||
transform: scale(0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tile-bounce-appear {
|
||||
from {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@ -98,72 +109,91 @@
|
||||
}
|
||||
|
||||
.game-container-enter-active {
|
||||
animation: container-appear 0.5s ease;
|
||||
animation: container-appear 0.5s ease both;
|
||||
}
|
||||
|
||||
.failed-mask {
|
||||
.mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 50px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
background-color: rgb(white, 0.3);
|
||||
background-color: rgb(white, 0.5);
|
||||
color: rgb(119, 110, 101);
|
||||
z-index: 20;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.failed-mask-enter-active {
|
||||
.mask-enter-active {
|
||||
transition: opacity 1s ease;
|
||||
}
|
||||
|
||||
.failed-mask-enter-from {
|
||||
.mask-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.failed-mask-enter-to {
|
||||
.mask-enter-to {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mask-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.mask-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mask-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tile,
|
||||
.background-tile {
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
width: v-bind(tileWidthStyle);
|
||||
height: v-bind(tileWidthStyle);
|
||||
border-radius: 3px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tile {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
line-height: 100px;
|
||||
transition: all 0.1s ease-in;
|
||||
}
|
||||
|
||||
.tile-enter-active.tile-appear-from-others {
|
||||
animation: tile-bounce-appear 0.15s ease;
|
||||
.tile-enter-active.tile-appear-from-others:not(.tile-transition-cancelled) {
|
||||
animation: tile-bounce-appear 0.15s ease both;
|
||||
}
|
||||
|
||||
.tile-enter-active:not(.tile-appear-from-others) {
|
||||
animation: tile-common-appear 0.15s ease;
|
||||
.tile-enter-active:not(.tile-appear-from-others):not(.tile-transition-cancelled) {
|
||||
animation: tile-common-appear 0.15s ease both;
|
||||
}
|
||||
|
||||
.tile-enter-active.tile-transition-cancelled {
|
||||
animation: nil 0s;
|
||||
}
|
||||
|
||||
.background-tile {
|
||||
background-color: rgb(205, 193, 180);
|
||||
}
|
||||
|
||||
.background-tile-enter-active {
|
||||
animation: tile-common-appear 0.2s ease both;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { type Tile, useGame2048Store } from '@/stores';
|
||||
import { get2DArrayItem, IdDispenser } from '@/utils';
|
||||
import { pick, sample, shuffle } from 'lodash-es';
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
<script lang="ts" setup>
|
||||
import { type GameState, type Tile, useGame2048Store } from '@/stores';
|
||||
import { chainIterables, type Future, get2DArrayItem } from '@/utils';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { ElNotification } from 'element-plus';
|
||||
import { add, pick, sample, shuffle } from 'lodash-es';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
const game2048Store = useGame2048Store();
|
||||
|
||||
@ -182,7 +212,7 @@ type OverlappedTileLine = Tile[][];
|
||||
/**
|
||||
* 存储合并操作所有被合并的块.
|
||||
*/
|
||||
type OriginalTilesMap = Map<Tile, Tile[]>;
|
||||
type TileRelationMap = Map<Tile, Tile[]>;
|
||||
|
||||
/**
|
||||
* 行合并结果.
|
||||
@ -192,13 +222,9 @@ interface LineMergeResult {
|
||||
originalLine: SingleTileLine;
|
||||
transitionLine: OverlappedTileLine;
|
||||
resultLine: OverlappedTileLine;
|
||||
tileRelationMap: TileRelationMap;
|
||||
}
|
||||
|
||||
type Future<T = void> = PromiseWithResolvers<T>;
|
||||
/**
|
||||
* 界面过渡是否完成?
|
||||
*/
|
||||
type ContainerTransitionFuture = Future;
|
||||
/**
|
||||
* 数块是否添加完成?
|
||||
*/
|
||||
@ -211,62 +237,85 @@ type TileTransitionFuture = Future;
|
||||
* 合并结果网格.
|
||||
*/
|
||||
type MergedGrid = (LineMergeResult | undefined)[];
|
||||
const tileWidth = 100;
|
||||
|
||||
class FutureMap<T = void> extends Map<number, Future<T>> {
|
||||
add(tileId: number) {
|
||||
const future = Promise.withResolvers<T>();
|
||||
this.set(tileId, future);
|
||||
return future.promise;
|
||||
}
|
||||
|
||||
resolve(tileId: number, value: T) {
|
||||
this.get(tileId)?.resolve(value);
|
||||
this.delete(tileId);
|
||||
}
|
||||
|
||||
reject(tileId: number, reason: unknown) {
|
||||
this.get(tileId)?.reject(reason);
|
||||
this.delete(tileId);
|
||||
}
|
||||
}
|
||||
|
||||
const tileWidth = 105;
|
||||
const tileWidthStyle = computed(() => `${tileWidth}px`);
|
||||
const borderWidth = 15;
|
||||
/**
|
||||
* 成功目标数字.
|
||||
*/
|
||||
const successNumber = 2048;
|
||||
const containerSizeStyle = computed(
|
||||
() => (
|
||||
console.log(game2048Store.width, typeof game2048Store.width),
|
||||
{
|
||||
minWidth: `${game2048Store.width * tileWidth + (game2048Store.width + 1) * borderWidth}px`,
|
||||
minHeight: `${game2048Store.height * tileWidth + (game2048Store.height + 1) * borderWidth}px`
|
||||
}
|
||||
)
|
||||
);
|
||||
const { width, height } = storeToRefs(game2048Store);
|
||||
const containerSizeStyle = computed(() => ({
|
||||
minWidth: `${width.value * tileWidth + (width.value + 1) * borderWidth}px`,
|
||||
minHeight: `${height.value * tileWidth + (height.value + 1) * borderWidth}px`
|
||||
}));
|
||||
/**
|
||||
* 是否显示容器.
|
||||
*/
|
||||
const showContainer = ref(false);
|
||||
const showTiles = ref(false);
|
||||
const tileDiv = ref<HTMLDivElement[]>([]);
|
||||
const tileDivs = ref<HTMLDivElement[]>([]);
|
||||
/**
|
||||
* 游戏状态.
|
||||
*/
|
||||
const gameStatus = ref<'playing' | 'succeed' | 'failed'>('playing');
|
||||
watch(gameStatus, (value) => {
|
||||
if (value === 'succeed') {
|
||||
ElNotification({
|
||||
title: '你赢了!',
|
||||
message: `单个块分数达到${successNumber}`,
|
||||
type: 'success',
|
||||
duration: 3500
|
||||
});
|
||||
} else if (value === 'failed') {
|
||||
ElNotification({
|
||||
title: '你输了',
|
||||
message: '你无路可走',
|
||||
type: 'error',
|
||||
duration: 3500
|
||||
});
|
||||
const gameStatus = ref<GameState>('playing');
|
||||
watch(gameStatus, (status) => {
|
||||
if (status == game2048Store.gameStatus) {
|
||||
return;
|
||||
}
|
||||
game2048Store.gameStatus = status;
|
||||
switch (status) {
|
||||
case 'succeed':
|
||||
ElNotification({
|
||||
title: '你赢了!',
|
||||
message: `单个块分数达到${game2048Store.successNumber}`,
|
||||
type: 'success',
|
||||
duration: 3500,
|
||||
offset: 50
|
||||
});
|
||||
break;
|
||||
case 'failed':
|
||||
ElNotification({
|
||||
title: '你输了',
|
||||
message: '你无路可走',
|
||||
type: 'error',
|
||||
duration: 3500,
|
||||
offset: 50
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
let isUnmounted = false;
|
||||
const locked = ref(true);
|
||||
const hideMask = ref(false);
|
||||
const isTileTransitioning = ref(false);
|
||||
const transitionCancelled = ref(false);
|
||||
const tileTransitionStyle = computed(() => ({
|
||||
transition: !transitionCancelled.value ? 'all 1s ease-in' : 'none'
|
||||
}));
|
||||
let containerTransitionFuture: ContainerTransitionFuture | undefined;
|
||||
const gameReadyFuture: Future = Promise.withResolvers();
|
||||
let enteredBackgroundTileCount = 0;
|
||||
/**
|
||||
* 数块id分配器, 便于重用id.
|
||||
*/
|
||||
const tileIdDispenser = new IdDispenser();
|
||||
let maxId = 0;
|
||||
const tileGrid = reactive<OverlappedTileLine[]>([]);
|
||||
const tileAddFutureMap = new Map<number, TileAddFuture>();
|
||||
const tileTransitionFutureMap = new Map<number, TileTransitionFuture>();
|
||||
const tileAddFutureMap = new FutureMap();
|
||||
const tileTransitionFutureMap = new FutureMap();
|
||||
const tileBackgroundColorMapping: Record<number, string | undefined> = {
|
||||
2: 'rgb(238, 228, 218)',
|
||||
4: 'rgb(237, 224, 200)',
|
||||
@ -290,12 +339,9 @@ function storeTileGrid() {
|
||||
);
|
||||
}
|
||||
|
||||
function addFuture<T = void>(tileId: number, futureMap: Map<number, Future<T>>): Promise<T> {
|
||||
const future = Promise.withResolvers<T>();
|
||||
futureMap.set(tileId, future);
|
||||
return future.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取块的位置.
|
||||
*/
|
||||
function getTilePosition(y: number, x: number) {
|
||||
return {
|
||||
left: `${borderWidth + x * (tileWidth + borderWidth)}px`,
|
||||
@ -304,9 +350,9 @@ function getTilePosition(y: number, x: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数块的样式.
|
||||
* 获取数块除位置以外的样式.
|
||||
*/
|
||||
function getTileStyle(tile: Tile) {
|
||||
function getNumberTileStyle(tile: Tile) {
|
||||
const { number } = tile;
|
||||
return {
|
||||
fontSize: `${number < 128 ? 55 : number < 1024 ? 45 : 35}px`,
|
||||
@ -320,7 +366,7 @@ function getTileStyle(tile: Tile) {
|
||||
* @param randomCount 随机数量.
|
||||
* @param randomNumbers 随机的数字列表, 默认为2或4.
|
||||
*/
|
||||
async function addRandomTiles(randomCount: number, randomNumbers: number[] = [2, 4]) {
|
||||
function addRandomTiles(randomCount: number, randomNumbers: number[] = [2, 4]) {
|
||||
let emptyPositions: [y: number, x: number][] = [];
|
||||
for (const y of tileGrid.keys()) {
|
||||
for (const [x, tiles] of tileGrid[y].entries()) {
|
||||
@ -332,20 +378,24 @@ async function addRandomTiles(randomCount: number, randomNumbers: number[] = [2,
|
||||
}
|
||||
emptyPositions = shuffle(emptyPositions);
|
||||
const count = Math.min(emptyPositions.length, randomCount);
|
||||
|
||||
const tiles: Tile[] = [];
|
||||
const tileAddPromises: TileAddFuture['promise'][] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const [y, x] = emptyPositions[i];
|
||||
const tile: Tile = {
|
||||
id: tileIdDispenser.getId(),
|
||||
id: maxId++,
|
||||
number: sample(randomNumbers) ?? 2,
|
||||
removed: false,
|
||||
fromOthers: false
|
||||
};
|
||||
tiles.push(tile);
|
||||
tileGrid[y][x].push(tile);
|
||||
tileAddPromises.push(addFuture(tile.id, tileAddFutureMap));
|
||||
tileAddPromises.push(tileAddFutureMap.add(tile.id));
|
||||
}
|
||||
await Promise.all(tileAddPromises);
|
||||
return {
|
||||
tiles,
|
||||
promise: Promise.all(tileAddPromises).then<void>(() => undefined)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -360,7 +410,7 @@ function getElementTileId(element: HTMLDivElement) {
|
||||
* 合并算法.
|
||||
* @param once 相邻块仅合并一次?
|
||||
*/
|
||||
function mergeLineImpl(originalTilesMap: OriginalTilesMap, line: SingleTileLine, once: boolean) {
|
||||
function mergeLineImpl(originalTilesMap: TileRelationMap, line: SingleTileLine, once: boolean) {
|
||||
let changed = false;
|
||||
let finished;
|
||||
do {
|
||||
@ -380,7 +430,7 @@ function mergeLineImpl(originalTilesMap: OriginalTilesMap, line: SingleTileLine,
|
||||
prevTile.removed = true;
|
||||
tile.removed = true;
|
||||
const newTile: Tile = {
|
||||
id: tileIdDispenser.getId(),
|
||||
id: maxId++,
|
||||
number: tile.number * 2,
|
||||
removed: false,
|
||||
fromOthers: true
|
||||
@ -407,11 +457,11 @@ function mergeLineImpl(originalTilesMap: OriginalTilesMap, line: SingleTileLine,
|
||||
* 合并一行.
|
||||
* @return 若该行未更改返回合并结果, 否则返回undefined.
|
||||
*/
|
||||
function mergeLine(line: SingleTileLine, test: boolean): LineMergeResult | undefined {
|
||||
function mergeLine(line: SingleTileLine): [lineMergeREsult: LineMergeResult, changed: boolean] {
|
||||
const originalLine = Array.from(line);
|
||||
const transitionLine: OverlappedTileLine = line.map(() => []);
|
||||
const originalTilesMap: OriginalTilesMap = new Map();
|
||||
let changed = mergeLineImpl(originalTilesMap, line, true);
|
||||
const tileRelationMap: TileRelationMap = new Map();
|
||||
let changed = mergeLineImpl(tileRelationMap, line, true);
|
||||
// 将所有块移到一侧.
|
||||
let dumpIndex = line.length - 1;
|
||||
let maxNumber = 0;
|
||||
@ -425,43 +475,37 @@ function mergeLine(line: SingleTileLine, test: boolean): LineMergeResult | undef
|
||||
if (tile.number > maxNumber) {
|
||||
maxNumber = tile.number;
|
||||
}
|
||||
if (!originalTilesMap.has(tile)) {
|
||||
if (!tileRelationMap.has(tile)) {
|
||||
transitionLine[dumpIndex].push(tile);
|
||||
}
|
||||
line[i] = undefined;
|
||||
line[dumpIndex--] = tile;
|
||||
}
|
||||
// 回收所有临时块的id.
|
||||
tileIdDispenser.removeId(
|
||||
...Array.from(originalTilesMap.keys())
|
||||
.filter((tile) => test || !line.includes(tile)) // 没有出现在结果行的块为临时块.
|
||||
.map((tile) => tile.id)
|
||||
);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
for (const [i, tile] of line.entries()) {
|
||||
if (tile === undefined) {
|
||||
continue;
|
||||
}
|
||||
transitionLine[i].push(...(originalTilesMap.get(tile) ?? []));
|
||||
transitionLine[i].push(...(tileRelationMap.get(tile) ?? []));
|
||||
}
|
||||
return {
|
||||
maxNumber,
|
||||
originalLine,
|
||||
transitionLine,
|
||||
resultLine: line.map((tile) => (tile !== undefined ? [tile] : []))
|
||||
};
|
||||
return [
|
||||
{
|
||||
maxNumber,
|
||||
originalLine,
|
||||
transitionLine,
|
||||
resultLine: line.map((tile) => (tile !== undefined ? [tile] : [])),
|
||||
tileRelationMap
|
||||
},
|
||||
changed
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并所有块.
|
||||
* @param direction 合并方向
|
||||
* @return 是否有活动空间
|
||||
* @return {} 是否有活动空间
|
||||
*/
|
||||
async function mergeTiles(direction: Directions) {
|
||||
function getMergedGrid(
|
||||
test: boolean,
|
||||
d: Directions = direction
|
||||
): [MergedGrid, changed: boolean, maxNumber: number] {
|
||||
let changed = false;
|
||||
@ -471,32 +515,54 @@ async function mergeTiles(direction: Directions) {
|
||||
const line = Array.from(secondIndices(d), (second) =>
|
||||
get2DArrayItem(tileGrid, first, second, isColFirst(d)).at(0)
|
||||
);
|
||||
const lineMergeResult = mergeLine(line, test);
|
||||
if (lineMergeResult === undefined) {
|
||||
const [lineMergeResult, lineChanged] = mergeLine(line);
|
||||
const { maxNumber: lineMaxNumber } = lineMergeResult;
|
||||
if (lineMaxNumber > maxNumber) {
|
||||
maxNumber = lineMaxNumber;
|
||||
}
|
||||
if (!lineChanged) {
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
if (lineMergeResult.maxNumber > maxNumber) {
|
||||
maxNumber = lineMergeResult.maxNumber;
|
||||
}
|
||||
mergedGrid[first] = lineMergeResult;
|
||||
}
|
||||
return [mergedGrid, changed, maxNumber];
|
||||
}
|
||||
|
||||
function forEachMergedGrid(
|
||||
callback: (data: { tiles: Tile[]; lineMergeResult: LineMergeResult; lineIndex: number }) => void
|
||||
) {
|
||||
function forEachMergedLine(callback: (lineMergeResult: LineMergeResult, first: number) => void) {
|
||||
for (const first of firstIndices()) {
|
||||
const mergeResult = mergedGrid[first];
|
||||
if (mergeResult === undefined) {
|
||||
continue;
|
||||
}
|
||||
callback(mergeResult, first);
|
||||
}
|
||||
}
|
||||
|
||||
function forEachMergedTiles(
|
||||
callback: (
|
||||
data: LineMergeResult & {
|
||||
tiles: Tile[];
|
||||
lineIndex: number;
|
||||
},
|
||||
first: number,
|
||||
second: number
|
||||
) => void
|
||||
) {
|
||||
forEachMergedLine((lineMergeResult, first) => {
|
||||
for (const [lineIndex, second] of secondIndices().entries()) {
|
||||
const tiles = get2DArrayItem(tileGrid, first, second, isColFirst());
|
||||
callback({ tiles, lineMergeResult: mergeResult, lineIndex });
|
||||
callback(
|
||||
{
|
||||
tiles,
|
||||
lineIndex,
|
||||
...lineMergeResult
|
||||
},
|
||||
first,
|
||||
second
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function firstIndices(d: Directions = direction) {
|
||||
@ -505,13 +571,13 @@ async function mergeTiles(direction: Directions) {
|
||||
switch (d) {
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
for (let x = 0; x < game2048Store.width; x++) {
|
||||
for (let x = 0; x < width.value; x++) {
|
||||
yield x;
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
for (let y = 0; y < game2048Store.height; y++) {
|
||||
for (let y = 0; y < height.value; y++) {
|
||||
yield y;
|
||||
}
|
||||
break;
|
||||
@ -525,23 +591,23 @@ async function mergeTiles(direction: Directions) {
|
||||
(function* () {
|
||||
switch (d) {
|
||||
case 'ArrowUp':
|
||||
for (let y = game2048Store.height - 1; y >= 0; y--) {
|
||||
for (let y = height.value - 1; y >= 0; y--) {
|
||||
yield y;
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
for (let y = 0; y < game2048Store.height; y++) {
|
||||
for (let y = 0; y < height.value; y++) {
|
||||
yield y;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowLeft':
|
||||
for (let x = game2048Store.width - 1; x >= 0; x--) {
|
||||
for (let x = width.value - 1; x >= 0; x--) {
|
||||
yield x;
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
for (let x = 0; x < game2048Store.width; x++) {
|
||||
for (let x = 0; x < width.value; x++) {
|
||||
yield x;
|
||||
}
|
||||
break;
|
||||
@ -554,75 +620,78 @@ async function mergeTiles(direction: Directions) {
|
||||
return d == Directions.LEFT || d == Directions.RIGHT;
|
||||
}
|
||||
|
||||
const [mergedGrid, changed, maxNumber] = getMergedGrid(false);
|
||||
const transitionTilesAndPromises: TileTransitionFuture['promise'][] = [];
|
||||
const tilesToRemoveId: Tile[] = [];
|
||||
forEachMergedGrid(({ tiles, lineMergeResult: { originalLine, transitionLine }, lineIndex }) => {
|
||||
const [mergedGrid, changed, maxNumber] = getMergedGrid(direction);
|
||||
if (maxNumber >= game2048Store.successNumber) {
|
||||
gameStatus.value = 'succeed';
|
||||
}
|
||||
const tileTransitionPromises: TileTransitionFuture['promise'][] = [];
|
||||
forEachMergedTiles(({ tiles, originalLine, transitionLine, lineIndex }) => {
|
||||
const transitionTiles = transitionLine[lineIndex];
|
||||
if (!transitionCancelled.value) {
|
||||
transitionTilesAndPromises.push(
|
||||
tileTransitionPromises.push(
|
||||
...transitionTiles
|
||||
.filter((tile) => tile !== originalLine[lineIndex]) // 过滤没有移动位置的过渡块.
|
||||
.map((tile) => addFuture(tile.id, tileTransitionFutureMap))
|
||||
.map((tile) => tileTransitionFutureMap.add(tile.id))
|
||||
);
|
||||
}
|
||||
tiles.splice(0, tiles.length, ...transitionTiles);
|
||||
});
|
||||
await Promise.all(transitionTilesAndPromises);
|
||||
await Promise.all(tileTransitionPromises);
|
||||
const tileAddPromises: TileAddFuture['promise'][] = [];
|
||||
forEachMergedGrid(
|
||||
({ tiles, lineMergeResult: { originalLine, transitionLine, resultLine }, lineIndex }) => {
|
||||
const transitionTiles = transitionLine[lineIndex];
|
||||
const resultTiles = resultLine[lineIndex];
|
||||
tiles.splice(0, tiles.length, ...resultTiles);
|
||||
if (!transitionCancelled.value) {
|
||||
tileAddPromises.push(
|
||||
...resultTiles
|
||||
.filter((tile) => !originalLine.includes(tile)) // 过滤仅移动位置的结果块.
|
||||
.map((tile) => addFuture(tile.id, tileAddFutureMap))
|
||||
);
|
||||
}
|
||||
tilesToRemoveId.push(...transitionTiles.filter((tile) => !resultTiles.includes(tile)));
|
||||
}
|
||||
);
|
||||
await Promise.all(tileAddPromises.concat(addRandomTiles(changed ? 1 : 0)));
|
||||
console.log(2);
|
||||
tileIdDispenser.removeId(...tilesToRemoveId.map((tile) => tile.id)); // 回收所有已删除块的id.
|
||||
if (maxNumber === successNumber) {
|
||||
gameStatus.value = 'succeed';
|
||||
game2048Store.$reset();
|
||||
forEachMergedTiles(({ tiles, originalLine, resultLine, lineIndex }) => {
|
||||
const resultTiles = resultLine[lineIndex];
|
||||
tileAddPromises.push(
|
||||
...resultTiles
|
||||
.filter((tile) => !originalLine.includes(tile)) // 过滤仅移动位置的结果块.
|
||||
.map((tile) => tileAddFutureMap.add(tile.id))
|
||||
);
|
||||
tiles.splice(0, tiles.length, ...resultTiles);
|
||||
});
|
||||
if (!isUnmounted) {
|
||||
game2048Store.maxNumber = maxNumber;
|
||||
forEachMergedLine(({ tileRelationMap }) => {
|
||||
game2048Store.score += Array.from(tileRelationMap.keys())
|
||||
.map((tile) => tile.number)
|
||||
.reduce(add, 0);
|
||||
});
|
||||
}
|
||||
return Object.values(Directions).some((d) => getMergedGrid(true, d)[1]);
|
||||
await Promise.all(tileAddPromises.concat(addRandomTiles(changed ? 1 : 0).promise));
|
||||
return Object.values(Directions).some((d) => getMergedGrid(d)[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 界面过渡完成.
|
||||
*/
|
||||
function onContainerTransitionEnd() {
|
||||
containerTransitionFuture?.resolve();
|
||||
function onMaskClick() {
|
||||
hideMask.value = true;
|
||||
}
|
||||
|
||||
function onBackgroundTileEnterComplete() {
|
||||
if (++enteredBackgroundTileCount === width.value * height.value + 1) {
|
||||
gameReadyFuture?.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function onContainerEnterComplete() {
|
||||
onBackgroundTileEnterComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 数块添加完成.
|
||||
* @param element 过渡完成的数块.
|
||||
*/
|
||||
function onTileAddCompleted(element: Element) {
|
||||
function onTileEnterComplete(element: Element) {
|
||||
const tileId = getElementTileId(element as HTMLDivElement);
|
||||
tileAddFutureMap.get(tileId)?.resolve();
|
||||
tileAddFutureMap.delete(tileId);
|
||||
tileAddFutureMap.resolve(tileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 数块过渡完成.
|
||||
*/
|
||||
function onTileTransitionCompletedOrCancelled(event: TransitionEvent) {
|
||||
function onTileTransitionComplete(event: TransitionEvent) {
|
||||
const tileId = getElementTileId(event.currentTarget as HTMLDivElement);
|
||||
tileTransitionFutureMap.get(tileId)?.resolve();
|
||||
tileTransitionFutureMap.delete(tileId);
|
||||
tileTransitionFutureMap.resolve(tileId);
|
||||
}
|
||||
|
||||
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
|
||||
if (locked.value) {
|
||||
if (locked.value || gameStatus.value !== 'playing') {
|
||||
return;
|
||||
}
|
||||
switch (e.key) {
|
||||
@ -633,25 +702,31 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
|
||||
if (isTileTransitioning.value) {
|
||||
locked.value = true;
|
||||
transitionCancelled.value = true;
|
||||
for (const tileDiv of tileDivs.value) {
|
||||
for (const animation of tileDiv.getAnimations()) {
|
||||
animation.finish();
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => watch(isTileTransitioning, resolve, { once: true }));
|
||||
transitionCancelled.value = false;
|
||||
locked.value = false;
|
||||
}
|
||||
isTileTransitioning.value = true;
|
||||
if ((await mergeTiles(e.key)) && gameStatus.value === 'playing') {
|
||||
isTileTransitioning.value = false;
|
||||
storeTileGrid();
|
||||
} else if (gameStatus.value === 'playing') {
|
||||
if (!(await mergeTiles(e.key)) && gameStatus.value === 'playing') {
|
||||
gameStatus.value = 'failed';
|
||||
// game2048Store.$reset();
|
||||
}
|
||||
if (gameStatus.value !== 'playing') {
|
||||
hideMask.value = false;
|
||||
}
|
||||
if (!isUnmounted) {
|
||||
storeTileGrid();
|
||||
}
|
||||
isTileTransitioning.value = false;
|
||||
break;
|
||||
}
|
||||
});
|
||||
onMounted(async () => {
|
||||
containerTransitionFuture = Promise.withResolvers();
|
||||
showContainer.value = true;
|
||||
await containerTransitionFuture.promise;
|
||||
await gameReadyFuture.promise;
|
||||
const tileAddPromises: TileAddFuture['promise'][] = [];
|
||||
tileGrid.splice(
|
||||
0,
|
||||
@ -659,8 +734,8 @@ onMounted(async () => {
|
||||
...game2048Store.rawTileGrid.map((row) =>
|
||||
row.map((rawTiles) =>
|
||||
rawTiles.map((rawTile) => {
|
||||
const id = tileIdDispenser.getId();
|
||||
tileAddPromises.push(addFuture(id, tileAddFutureMap));
|
||||
const id = maxId++;
|
||||
tileAddPromises.push(tileAddFutureMap.add(id));
|
||||
return {
|
||||
...rawTile,
|
||||
id,
|
||||
@ -671,14 +746,29 @@ onMounted(async () => {
|
||||
)
|
||||
);
|
||||
if (game2048Store.isInitial) {
|
||||
tileAddPromises.push(addRandomTiles(2));
|
||||
const { tiles, promise: newTilePromise } = addRandomTiles(2);
|
||||
tileAddPromises.push(newTilePromise);
|
||||
game2048Store.maxNumber = Math.max(0, ...tiles.map((tile) => tile.number));
|
||||
}
|
||||
showTiles.value = true;
|
||||
await Promise.all(tileAddPromises);
|
||||
storeTileGrid();
|
||||
gameStatus.value = game2048Store.gameStatus;
|
||||
if (gameStatus.value === 'playing') {
|
||||
hideMask.value = true;
|
||||
}
|
||||
if (!isUnmounted) {
|
||||
storeTileGrid();
|
||||
}
|
||||
locked.value = false;
|
||||
});
|
||||
onUnmounted(() => {
|
||||
locked.value = true;
|
||||
isUnmounted = true;
|
||||
for (const future of chainIterables([
|
||||
tileAddFutureMap.values(),
|
||||
tileTransitionFutureMap.values()
|
||||
])) {
|
||||
future.resolve();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
19
src/components/Game2048Button.vue
Normal file
19
src/components/Game2048Button.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<button class="game-2048-button">
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.game-2048-button {
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
font-size: var(--normal-font-size);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
background-color: rgb(143, 122, 102);
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts" setup></script>
|
89
src/components/Game2048Score.vue
Normal file
89
src/components/Game2048Score.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="score-container">
|
||||
<div class="score-description">
|
||||
<slot>最高数字</slot>
|
||||
</div>
|
||||
<div class="score-text-outer">
|
||||
<div ref="scoreDiv" class="score-text">{{ displayedScore }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.score-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 120px;
|
||||
height: 55px;
|
||||
font-weight: bold;
|
||||
align-items: center;
|
||||
padding: 0 25px;
|
||||
margin-left: 10px;
|
||||
border-radius: 4px;
|
||||
background-color: rgb(187, 173, 160);
|
||||
}
|
||||
|
||||
.score-description {
|
||||
font-size: 14px;
|
||||
color: rgb(238, 228, 218);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.score-text-outer {
|
||||
font-size: 25px;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts" setup>
|
||||
import { Queue, QueueCancelledError } from '@/utils';
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
score: number;
|
||||
}>();
|
||||
const displayedScore = ref(props.score);
|
||||
const scoreDiv = ref<HTMLDivElement>();
|
||||
const scoreQueue = new Queue<number>();
|
||||
let isUnmounted = false;
|
||||
watch(
|
||||
() => props.score,
|
||||
(score) => {
|
||||
scoreQueue.push(score);
|
||||
}
|
||||
);
|
||||
onMounted(async () => {
|
||||
let continuouslyRolling = false;
|
||||
while (!isUnmounted) {
|
||||
try {
|
||||
const score = await scoreQueue.shift();
|
||||
await scoreDiv.value?.animate([{ transform: 'none' }, { transform: 'translateY(-100%)' }], {
|
||||
easing: continuouslyRolling ? 'linear' : 'ease-in',
|
||||
fill: 'forwards',
|
||||
duration: 100 / (scoreQueue.size() + 1 + Number(continuouslyRolling))
|
||||
}).finished;
|
||||
displayedScore.value = score;
|
||||
await nextTick();
|
||||
continuouslyRolling = scoreQueue.size() > 0;
|
||||
await scoreDiv.value?.animate([{ transform: 'translateY(100%)' }, { transform: 'none' }], {
|
||||
easing: continuouslyRolling ? 'linear' : 'ease-out',
|
||||
fill: 'forwards',
|
||||
duration: 100 / (scoreQueue.size() + 1)
|
||||
}).finished;
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof QueueCancelledError || //取消获取分数
|
||||
e instanceof DOMException //动画中途取消
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
isUnmounted = true;
|
||||
scoreQueue.cancelGetters();
|
||||
scoreDiv.value?.getAnimations().forEach((animation) => animation.cancel());
|
||||
});
|
||||
</script>
|
@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<el-icon :size="props.size" color="dimgrey" class="loading-icon is-loading">
|
||||
<el-icon :size="props.size" class="loading-icon is-loading" color="dimgrey">
|
||||
<icon-ep-loading />
|
||||
</el-icon>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
size?: number;
|
||||
}>();
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-input v-model="model.verifyCode" placeholder="请输入验证码" :disabled="props.disabled">
|
||||
<el-input v-model="model.verifyCode" :disabled="props.disabled" placeholder="请输入验证码">
|
||||
<template #prepend>
|
||||
<el-icon>
|
||||
<icon-cs-validate />
|
||||
@ -11,14 +11,14 @@
|
||||
<icon-ep-loading />
|
||||
</el-icon>
|
||||
<template v-else>
|
||||
<el-popover popper-style="text-align: center" :content="popOverMessage">
|
||||
<el-popover :content="popOverMessage" popper-style="text-align: center">
|
||||
<template #reference>
|
||||
<img
|
||||
:src="model.verifyImage.img"
|
||||
alt="验证码"
|
||||
class="verify-image"
|
||||
draggable="false"
|
||||
:src="model.verifyImage.img"
|
||||
@click="getVerifyImage"
|
||||
alt="验证码"
|
||||
/>
|
||||
</template>
|
||||
</el-popover>
|
||||
@ -26,13 +26,13 @@
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.verify-image {
|
||||
height: 30px;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import axiosInstance from '@/api';
|
||||
import { verifyResponseSchema } from '@/schemas';
|
||||
import { errorMessage, timeout } from '@/utils';
|
||||
|
@ -1,10 +1,10 @@
|
||||
import '@/assets/global.scss';
|
||||
import 'element-plus/theme-chalk/index.css';
|
||||
import 'element-plus/theme-chalk/display.css';
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from '@/App.vue';
|
||||
import router from '@/router';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp } from 'vue';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia()).use(router).mount('#app');
|
||||
|
@ -1,12 +1,12 @@
|
||||
import axiosInstance from '@/api';
|
||||
import { userInfoResponseSchema } from '@/schemas';
|
||||
import { useUserStore } from '@/stores';
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import axiosInstance from '@/api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { errorMessage } from '@/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { RoutePermission } from './permissions';
|
||||
import { routeHasPermission } from '@/utils/permissions';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import { RoutePermission } from './permissions';
|
||||
|
||||
export * from './permissions';
|
||||
|
||||
@ -37,7 +37,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: '/club',
|
||||
component: () => import('@/views/ClubPage.vue'),
|
||||
component: () => import('@/views/Game2048Page.vue'),
|
||||
meta: {
|
||||
permission: RoutePermission.CLUB_PAGE
|
||||
}
|
||||
@ -56,7 +56,6 @@ const router = createRouter({
|
||||
routes: routes
|
||||
});
|
||||
router.beforeEach(async (to) => {
|
||||
console.log(to);
|
||||
const userStore = useUserStore();
|
||||
console.log(userStore.isInitialized);
|
||||
if (!userStore.isInitialized) {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { create2DArray, useRefresh } from '@/utils';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { defineStore } from 'pinia';
|
||||
import { watch } from 'vue';
|
||||
import { readonly, watch } from 'vue';
|
||||
|
||||
export interface Tile {
|
||||
id: number;
|
||||
@ -10,6 +10,7 @@ export interface Tile {
|
||||
fromOthers: boolean;
|
||||
}
|
||||
|
||||
export type GameState = 'playing' | 'succeed' | 'failed';
|
||||
export const useGame2048Store = defineStore('2048', () => {
|
||||
const { tilesKey: gameKey, refresh: refreshGame } = useRefresh();
|
||||
|
||||
@ -18,6 +19,9 @@ export const useGame2048Store = defineStore('2048', () => {
|
||||
}
|
||||
|
||||
function $reset() {
|
||||
gameStatus.value = 'playing';
|
||||
score.value = 0;
|
||||
maxNumber.value = 0;
|
||||
rawTileGrid.value = create();
|
||||
isInitial.value = true;
|
||||
refreshGame();
|
||||
@ -25,6 +29,11 @@ export const useGame2048Store = defineStore('2048', () => {
|
||||
|
||||
const width = useLocalStorage('2048-width', 4);
|
||||
const height = useLocalStorage('2048-height', 4);
|
||||
const gameStatus = useLocalStorage<GameState>('2048-game-state', 'playing');
|
||||
const score = useLocalStorage('2048-score', 0);
|
||||
const maxNumber = useLocalStorage('2048-max-number', 0);
|
||||
const successNumber = useLocalStorage('2048-success-number', 2048);
|
||||
successNumber.value = 2048;
|
||||
watch([width, height], $reset, { flush: 'sync' });
|
||||
const rawTileGrid = useLocalStorage<Pick<Tile, 'number' | 'removed'>[][][]>(
|
||||
'2048-tile-grid',
|
||||
@ -38,5 +47,16 @@ export const useGame2048Store = defineStore('2048', () => {
|
||||
{ flush: 'sync' }
|
||||
);
|
||||
const isInitial = useLocalStorage('2048-is-initial', true);
|
||||
return { width, height, rawTileGrid, isInitial, gameKey, $reset };
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
gameStatus,
|
||||
score,
|
||||
maxNumber,
|
||||
successNumber: readonly(successNumber),
|
||||
rawTileGrid,
|
||||
isInitial,
|
||||
gameKey,
|
||||
$reset
|
||||
};
|
||||
});
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { IdDispenser } from '@/utils';
|
||||
import { defineStore } from 'pinia';
|
||||
import { reactive, readonly } from 'vue';
|
||||
import { reactive, readonly, ref } from 'vue';
|
||||
|
||||
export type BackgroundURL = string | undefined;
|
||||
export type BackgroundOptions = {
|
||||
@ -17,7 +16,7 @@ export class CancelledError extends Error {}
|
||||
export const useBackgroundStore = defineStore('background', () => {
|
||||
const taskQueue = reactive<BackgroundTask[]>([]);
|
||||
const getFuturesMap = new Map<number, PromiseWithResolvers<BackgroundTask>>();
|
||||
const compIdDispenser = new IdDispenser();
|
||||
const maxCompId = ref(0);
|
||||
|
||||
function getURL(id: number) {
|
||||
const future = Promise.withResolvers<BackgroundTask>();
|
||||
@ -52,7 +51,7 @@ export const useBackgroundStore = defineStore('background', () => {
|
||||
}
|
||||
|
||||
function newCompId() {
|
||||
return compIdDispenser.getId();
|
||||
return maxCompId.value++;
|
||||
}
|
||||
|
||||
function unregisterComp(id: number) {
|
||||
|
@ -4,57 +4,75 @@ export function timeout(delay: number = 0) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
export class IdDispenser {
|
||||
#maxId: number;
|
||||
#usedIdSet: Set<number>;
|
||||
#unUsedIdSet: Set<number>;
|
||||
|
||||
constructor() {
|
||||
this.#maxId = 0;
|
||||
this.#usedIdSet = new Set<number>();
|
||||
this.#unUsedIdSet = new Set<number>();
|
||||
}
|
||||
|
||||
getId(...identifiersToRemove: number[]) {
|
||||
let id: number;
|
||||
if (this.#unUsedIdSet.size) {
|
||||
[id] = this.#unUsedIdSet;
|
||||
this.#unUsedIdSet.delete(id);
|
||||
} else {
|
||||
id = this.#maxId++;
|
||||
}
|
||||
this.#usedIdSet.add(id);
|
||||
this.removeId(...identifiersToRemove);
|
||||
return id;
|
||||
}
|
||||
|
||||
removeId(...identifiers: number[]) {
|
||||
for (const id of identifiers) {
|
||||
if (!this.#usedIdSet.delete(id)) {
|
||||
return;
|
||||
}
|
||||
this.#unUsedIdSet.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#maxId = 0;
|
||||
this.#unUsedIdSet.clear();
|
||||
this.#usedIdSet.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function useRefresh() {
|
||||
const dispenser = new IdDispenser();
|
||||
const key = ref(dispenser.getId());
|
||||
// watch(key, (value) => console.log(value));
|
||||
const key = ref(0);
|
||||
return {
|
||||
tilesKey: key,
|
||||
refresh() {
|
||||
key.value = dispenser.getId(key.value);
|
||||
key.value++;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type Chain<T extends Iterable<unknown>[]> = T extends never[]
|
||||
? []
|
||||
: T extends [infer R, ...infer S extends Iterable<unknown>[]]
|
||||
? R extends Iterable<infer U>
|
||||
? R extends unknown[]
|
||||
? [...R, ...Chain<S>]
|
||||
: [...U[], ...Chain<S>]
|
||||
: never
|
||||
: never;
|
||||
export type Future<T = void> = PromiseWithResolvers<T>;
|
||||
|
||||
export function* chainIterables<const T>(iterables: Iterable<T>[]): Generator<T, void, undefined> {
|
||||
for (const iterable of iterables) {
|
||||
yield* iterable;
|
||||
}
|
||||
}
|
||||
|
||||
export class QueueCancelledError extends Error {}
|
||||
|
||||
export class Queue<T> {
|
||||
protected array: T[];
|
||||
protected getterFutures: Future<T>[];
|
||||
|
||||
constructor(iterable: Iterable<T> = []) {
|
||||
this.array = Array.from(iterable);
|
||||
this.getterFutures = [];
|
||||
}
|
||||
|
||||
async shift(): Promise<T> {
|
||||
if (this.array.length) {
|
||||
return this.array.shift()!;
|
||||
}
|
||||
const future: Future<T> = Promise.withResolvers();
|
||||
this.getterFutures.push(future);
|
||||
return future.promise;
|
||||
}
|
||||
|
||||
push(...values: T[]): this {
|
||||
for (const value of values) {
|
||||
const future = this.getterFutures.shift();
|
||||
if (future !== undefined) {
|
||||
future.resolve(value);
|
||||
} else {
|
||||
this.array.push(value);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
size() {
|
||||
return this.array.length;
|
||||
}
|
||||
|
||||
cancelGetters() {
|
||||
for (const future of this.getterFutures) {
|
||||
future.reject(new QueueCancelledError());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export * from './2d-array';
|
||||
export * from './api';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { RoutePermission } from '@/router';
|
||||
import { useUserStore } from '@/stores';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useUserStore } from '@/stores';
|
||||
import { RoutePermission } from '@/router';
|
||||
|
||||
export function hasPermission(permission: RoutePermission) {
|
||||
const userStore = useUserStore();
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-main></el-main>
|
||||
</template>
|
||||
<style scoped lang="scss"></style>
|
||||
<script setup lang="ts"></script>
|
||||
<style lang="scss" scoped></style>
|
||||
<script lang="ts" setup></script>
|
||||
|
@ -1,44 +1,82 @@
|
||||
<template>
|
||||
<div class="page-root">
|
||||
<game2048 class="game" :key="game2048Store.gameKey" />
|
||||
<el-button @click="click">重新开始</el-button>
|
||||
<el-input-number v-model="game2048Store.width" placeholder="宽度" :min="1" :max="10" />
|
||||
<el-input-number
|
||||
v-model="game2048Store.height"
|
||||
type="number"
|
||||
placeholder="高度"
|
||||
:min="1"
|
||||
:max="10"
|
||||
/>
|
||||
<div class="outer">
|
||||
<div class="game-header">
|
||||
<div class="game-title">
|
||||
<b>{{ game2048Store.successNumber.toString().padStart(4, '0') }}</b>
|
||||
</div>
|
||||
<game2048-score :key="game2048Store.gameKey" :score="game2048Store.maxNumber">
|
||||
最高数字
|
||||
</game2048-score>
|
||||
<game2048-score :key="game2048Store.gameKey" :score="game2048Store.score">
|
||||
得分
|
||||
</game2048-score>
|
||||
</div>
|
||||
<div class="game-header">
|
||||
<div class="game-description">
|
||||
合并数字到达
|
||||
<b>{{ game2048Store.successNumber }}!</b>
|
||||
</div>
|
||||
<game2048-button @click="click"><b>新游戏</b></game2048-button>
|
||||
</div>
|
||||
<game2048 :key="game2048Store.gameKey" class="game" />
|
||||
</div>
|
||||
</div>
|
||||
<el-drawer v-model="showSettings"></el-drawer>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.page-root {
|
||||
width: 100%;
|
||||
height: calc(100vh - 50px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
--font-color: rgb(119, 110, 101);
|
||||
font-family: 'Arial', '微软雅黑', '黑体', sans-serif;
|
||||
min-height: calc(100vh - 50px);
|
||||
background-color: rgb(250, 248, 239);
|
||||
}
|
||||
|
||||
.outer {
|
||||
width: max-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.game-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.game-header:has(.game-description) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-size: 70px;
|
||||
color: var(--font-color);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.game-description {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
font-size: var(--normal-font-size);
|
||||
line-height: 18px;
|
||||
color: var(--font-color);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
margin: 0 auto 10px;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
width: auto;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import Game2048 from '@/components/Game2048.vue';
|
||||
import Game2048Score from '@/components/Game2048Score.vue';
|
||||
import { useGame2048Store } from '@/stores';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const showSettings = ref(false);
|
||||
const game2048Store = useGame2048Store();
|
||||
|
||||
function click() {
|
||||
// game2048Store.width = 4;
|
||||
// game2048Store.height = 4;
|
||||
game2048Store.$reset();
|
||||
}
|
||||
</script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-main></el-main>
|
||||
</template>
|
||||
<style scoped lang="scss"></style>
|
||||
<script setup lang="ts"></script>
|
||||
<style lang="scss" scoped></style>
|
||||
<script lang="ts" setup></script>
|
||||
|
@ -14,7 +14,7 @@
|
||||
<template v-else></template>
|
||||
</el-main>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.loading-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@ -49,12 +49,12 @@
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router';
|
||||
import { computed, onBeforeMount, ref } from 'vue';
|
||||
<script lang="ts" setup>
|
||||
import axiosInstance from '@/api';
|
||||
import { type UserInfo, userInfoResponseSchema } from '@/schemas';
|
||||
import { useUserStore } from '@/stores';
|
||||
import axiosInstance from '@/api';
|
||||
import { computed, onBeforeMount, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const initialized = ref(false);
|
||||
|
Loading…
x
Reference in New Issue
Block a user