完成2048

添加分数和最高方块数字记分板
现在方块移动动画可以打断
修复部分Promise无法fulfil的问题
This commit is contained in:
Litrix2 2024-07-08 21:31:47 +08:00
parent 7b934108f5
commit 9190604d05
19 changed files with 594 additions and 320 deletions

2
components.d.ts vendored
View File

@ -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'];

View File

@ -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';

View File

@ -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({

View File

@ -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,

View File

@ -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>

View 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>

View 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>

View File

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

View File

@ -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';

View File

@ -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');

View File

@ -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) {

View File

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

View File

@ -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) {

View File

@ -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';

View File

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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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);