🎈 perf: 2048计分板数字显示方式改进
All checks were successful
ci / build (push) Successful in 54s

This commit is contained in:
Litrix 2025-03-25 13:05:54 +08:00
parent 14525db49c
commit bbe310bc77
12 changed files with 314 additions and 74 deletions

2
.env
View File

@ -2,4 +2,4 @@ VITE_REQUEST_BASE_URL=https://wzpmc.cn:18080
# VITE_REQUEST_BASE_URL=http://172.16.114.84:58082
VITE_WEBSOCKET_BASE_URL=wss://wzpmc.cn:18080
# VITE_WEBSOCKET_BASE_URL=ws://172.16.114.84:58082
VITE_GAME_2048_DEBUG=true
VITE_GAME_2048_DEBUG=false

View File

@ -295,8 +295,8 @@ let enteredBackgroundTileCount = 0;
*/
let maxId = 0;
const tileGrid = reactive<OverlappedTileLine[]>([]);
const tileAddFutureMap = new FutureMap();
const tileTransitionFutureMap = new FutureMap();
const tileAddFutureMap = new FutureMap<number>();
const tileTransitionFutureMap = new FutureMap<number>();
const tileBackgroundColorMapping: Record<number, string | undefined> = {
2: 'rgb(238, 228, 218)',
4: 'rgb(237, 224, 200)',

View File

@ -4,7 +4,7 @@
<slot>最高数字</slot>
</div>
<div class="score-text-outer">
<div ref="scoreDiv" class="score-text">{{ displayedScore }}</div>
<div ref="scoreDiv" class="score-text">{{ score }}</div>
</div>
</div>
</template>
@ -43,13 +43,15 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = defineProps<{
score: number;
}>();
const displayedScore = ref(props.score);
const score = ref(props.score);
const scoreDiv = ref<HTMLDivElement>();
const scoreQueue = new AsyncQueue<number>();
let isUnmounted = false;
watch(
() => props.score,
(score) => {
console.log(score);
scoreQueue.push(score);
},
);
@ -57,13 +59,13 @@ onMounted(async () => {
let continuouslyRolling = false;
while (!isUnmounted) {
try {
const score = await scoreQueue.shift();
const s = 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;
score.value = s;
await nextTick();
continuouslyRolling = scoreQueue.size > 0;
await scoreDiv.value?.animate([{ transform: 'translateY(100%)' }, { transform: 'none' }], {

View File

@ -0,0 +1,72 @@
<template>
<div class="digit">
<div ref="div">{{ digit }}</div>
</div>
</template>
<style lang="scss" scoped></style>
<script lang="ts" setup>
import { GAME_2048_DEBUG } from '@/env';
import { onBeforeUnmount, ref, useTemplateRef, watch } from 'vue';
const { leaveDuration, enterDuration, linearEnter, reversed, ...props } = defineProps<{
digit: number;
leaveDuration: number;
enterDuration: number;
linearEnter: boolean;
reversed: boolean;
}>();
const emit = defineEmits<{
up: [number];
finish: [number];
}>();
const digit = ref(props.digit);
const div = useTemplateRef('div');
const up = 'translateY(-100%)',
down = 'translateY(100%)';
let linearLeave = false;
watch(
() => linearEnter,
(v) => {
if (v) {
linearLeave = true;
}
},
{ flush: 'sync' },
);
watch(
() => reversed,
() => {
linearLeave = false;
},
{ flush: 'sync' },
);
watch(
() => props.digit,
async (d) => {
if (GAME_2048_DEBUG) {
console.log('c1', linearLeave);
}
await div.value?.animate([{ transform: 'none' }, { transform: reversed ? down : up }], {
easing: linearLeave ? 'linear' : 'ease-in',
fill: 'forwards',
duration: leaveDuration,
}).finished;
linearLeave = linearEnter;
digit.value = d;
emit('up', d);
if (GAME_2048_DEBUG) {
console.log('c2', linearEnter);
}
await div.value?.animate([{ transform: reversed ? up : down }, { transform: 'none' }], {
easing: linearEnter ? 'linear' : 'ease-out',
fill: 'forwards',
duration: enterDuration,
}).finished;
emit('finish', d);
},
);
onBeforeUnmount(() => {
div.value?.getAnimations().forEach((animation) => animation.finish());
});
</script>

View File

@ -0,0 +1,135 @@
<template>
<div class="score-container">
<div class="score-description">
<slot>最高数字</slot>
</div>
<div class="score-text-outer flex items-center">
<transition-group :css="false" @enter="onEnter" @leave="onLeave">
<game2048-score-digit
ref="digitRefs"
v-for="[i, d] of getDigits()"
:key="i"
:digit="Number(d)"
:leave-duration="leaveDuration"
:enter-duration="enterDuration"
:linear-enter="linearEnter"
:reversed="reversed"
@up="futureMap.resolve('leave')"
@finish="futureMap.resolve('enter')"
/>
</transition-group>
</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">
export const baseDuration = (tileMoveDuration + tileShowDuration) / 2;
const fadeOutKeyframe = { fontSize: 0, scale: 0 };
</script>
<script lang="ts" setup>
import { GAME_2048_DEBUG } from '@/env';
import { AsyncQueue } from '@/utils/async-queue';
import { FutureMap } from '@/utils/future';
import { noop } from 'lodash-es';
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import Game2048ScoreDigit from './Game2048ScoreDigit.vue';
import { tileMoveDuration, tileShowDuration } from './Game2048V2.vue';
const props = defineProps<{
score: number;
}>();
const score = ref(props.score);
const getDigits = (n: number = score.value) => {
const s = String(n);
return s.split('').map((d, i): [i: number, d: number] => [s.length - 1 - i, Number(d)]);
};
const futureMap = new FutureMap<'leave' | 'enter'>();
const scoreDiv = ref<HTMLDivElement>();
const scoreQueue = new AsyncQueue<number>();
const leaveDuration = ref(baseDuration);
const enterDuration = ref(baseDuration);
let isUnmounted = false;
const linearEnter = ref(false);
scoreQueue.watchPush(props, 'score');
const onEnter = async (el: Element, done: () => void) => {
await el.animate(fadeOutKeyframe, { duration: 0, fill: 'forwards' }).finished;
await futureMap.get('leave')?.promise;
await el.animate([{ fontSize: '1em', scale: 1 }], {
duration: enterDuration.value,
easing: 'ease-out',
fill: 'forwards',
}).finished;
futureMap.resolve('enter');
done();
};
const onLeave = async (el: Element, done: () => void) => {
await el.animate(fadeOutKeyframe, {
duration: leaveDuration.value,
easing: 'ease-out',
fill: 'forwards',
}).finished;
futureMap.resolve('leave');
done();
};
const reversed = ref(false);
onMounted(async () => {
let delta = 0;
while (!isUnmounted) {
const s = await scoreQueue.shift().catch(noop);
if (s === undefined) break;
const old = score.value;
score.value = s;
const newDelta = s - old;
reversed.value = newDelta < 0;
linearEnter.value = !!scoreQueue.size && (delta >= 0 ? newDelta >= 0 : newDelta < 0);
if (GAME_2048_DEBUG) {
console.log('score', s);
console.log(linearEnter.value);
}
delta = newDelta;
leaveDuration.value = baseDuration / (scoreQueue.size + 1);
enterDuration.value = baseDuration / (scoreQueue.size + 1 + Number(linearEnter.value));
futureMap.clear();
if (!new RegExp(`.+${old}`).test(String(s))) {
futureMap.add('leave');
}
if (!new RegExp(`.+${s}$`).test(String(old))) {
futureMap.add('enter');
}
await nextTick();
await futureMap.waitAll();
}
});
onBeforeUnmount(() => {
isUnmounted = true;
scoreQueue.cancelGetters();
scoreDiv.value?.getAnimations().forEach((animation) => animation.finish());
});
</script>

View File

@ -57,6 +57,25 @@
line-height: 1;
}
</style>
<script lang="ts">
const tileWidth = 105;
const borderWidth = 15;
const tileBackgroundColorMapping: Record<number, string | undefined> = {
2: 'rgb(238, 228, 218)',
4: 'rgb(237, 224, 200)',
8: 'rgb(242, 177, 121)',
16: 'rgb(242, 177, 121)',
32: 'rgb(246, 124, 95)',
64: 'rgb(246, 94, 59)',
128: 'rgb(237, 207, 114)',
256: 'rgb(237, 204, 97)',
512: 'rgb(237, 200, 80)',
1024: 'rgb(237, 197, 63)',
2048: 'rgb(237, 194, 46)',
};
export const tileMoveDuration = 110,
tileShowDuration = 150;
</script>
<script lang="ts" setup>
import { GAME_2048_DEBUG } from '@/env';
import { useGame2048Store } from '@/stores/2048';
@ -69,10 +88,14 @@ import { Directions } from '@/views/Game2048PageV2.vue';
import { useEventListener } from '@vueuse/core';
import { sample, shuffle } from 'lodash-es';
import { storeToRefs } from 'pinia';
import { onMounted, reactive, ref, useTemplateRef } from 'vue';
import { onMounted, reactive, ref, useTemplateRef, watch } from 'vue';
const game2048Store = useGame2048Store();
const { width, height } = storeToRefs(game2048Store);
const table = new TableView<Tile>([game2048Store.height, game2048Store.width]);
const tileWidthStyle = `${tileWidth}px`;
const gameContainerStyle = {
minWidth: `${width.value * tileWidth + (width.value + 1) * borderWidth}px`,
minHeight: `${height.value * tileWidth + (height.value + 1) * borderWidth}px`,
};
type TileRenderState = {
number: number;
fromPos: Pos;
@ -182,9 +205,16 @@ class Tile {
return `Tile#${this.id}`;
}
}
const table = new TableView<Tile>([game2048Store.height, game2048Store.width]);
const tiles = reactive<Exposed<Tile>[]>(
Array.from({ length: game2048Store.width * game2048Store.height }, () => new Tile()),
);
watch(
() => Math.max(...tiles.map((t) => t.number)),
(v) => {
game2048Store.maxNumber = v;
},
);
const idleTiles = new Set(tiles);
const createShowKeyframeCommon = (
pos: Pos | undefined,
@ -215,28 +245,6 @@ const createKeyframeOptions = ({
delay: !breaking ? delay : 0,
...options,
});
const tileWidth = 105;
const borderWidth = 15;
const tileWidthStyle = `${tileWidth}px`;
const gameContainerStyle = {
minWidth: `${width.value * tileWidth + (width.value + 1) * borderWidth}px`,
minHeight: `${height.value * tileWidth + (height.value + 1) * borderWidth}px`,
};
const tileBackgroundColorMapping: Record<number, string | undefined> = {
2: 'rgb(238, 228, 218)',
4: 'rgb(237, 224, 200)',
8: 'rgb(242, 177, 121)',
16: 'rgb(242, 177, 121)',
32: 'rgb(246, 124, 95)',
64: 'rgb(246, 94, 59)',
128: 'rgb(237, 207, 114)',
256: 'rgb(237, 204, 97)',
512: 'rgb(237, 200, 80)',
1024: 'rgb(237, 197, 63)',
2048: 'rgb(237, 194, 46)',
};
const tileMoveDuration = 125,
tileShowDuration = 150;
const getTilePositionStyle = ([y, x]: Pos) => ({
left: `${borderWidth + x * (tileWidth + borderWidth)}px`,
top: `${borderWidth + y * (tileWidth + borderWidth)}px`,
@ -370,9 +378,9 @@ const move = async (key: Directions) => {
tilesToHide = new Set<Tile>(),
tilesToMove = new Set<Tile>(),
relationMap = new Map<Tile, Tile[]>();
let changed = false;
let finished: boolean;
for (const f of first) {
let scoreDelta = 0;
let prev: Tile | undefined;
do {
finished = true;
@ -385,18 +393,19 @@ const move = async (key: Directions) => {
}
if (prev.number === tile.number) {
finished = false;
changed = true;
prev.remove();
tile.number *= 2;
setDefault(relationMap, tile, []).push(prev);
tilesToReshow.add(tile);
tilesToHide.add(prev);
prev = once ? undefined : tile;
scoreDelta += tile.number;
} else {
prev = tile;
}
}
} while (!once && !finished);
game2048Store.score += scoreDelta;
let i = 0;
let zIndex = second.length - 1;
for (const s of second) {
@ -405,7 +414,6 @@ const move = async (key: Directions) => {
const dumpIndex = second[i++],
pos = toPos(f, dumpIndex, xFirst);
if (s !== dumpIndex) {
changed = true;
tile.zIndex = zIndex--;
tilesToMove.add(tile);
}

View File

@ -1,4 +1,4 @@
export const DEV = import.meta.env.DEV;
export const REQUEST_BASE_URL = import.meta.env.VITE_REQUEST_BASE_URL;
export const WEBSOCKET_BASE_URL = import.meta.env.VITE_WEBSOCKET_BASE_URL;
export const GAME_2048_DEBUG = import.meta.env.VITE_GAME_2048_DEBUG;
export const GAME_2048_DEBUG = import.meta.env.VITE_GAME_2048_DEBUG === 'true';

View File

@ -1,5 +1,7 @@
import { watch } from 'vue';
import { DoubleQueue } from './double-queue';
import type { Future } from './future';
import type { ValidKey } from './types';
export class AsyncQueue<T> {
protected _queue: DoubleQueue<T>;
protected _getters = new DoubleQueue<Future<T>>();
@ -24,7 +26,7 @@ export class AsyncQueue<T> {
push(...values: T[]): this {
for (const value of values) {
const future = this._getters.shift();
if (future !== undefined) {
if (future) {
future.resolve(value);
} else {
this._queue.push(value);
@ -38,8 +40,20 @@ export class AsyncQueue<T> {
future.reject(new AsyncQueue.CancelledError());
}
}
watchPush<O extends Record<string, unknown>>(obj: O, key: ValidKey<T, O>) {
watch(
() => obj[key],
(value) => {
this.push(value as T);
},
);
}
}
export namespace AsyncQueue {
export class CancelledError extends Error {}
export function shift() {
throw new Error('Function not implemented.');
}
}

View File

@ -1,3 +1,5 @@
import { ref } from 'vue';
interface Node<T> {
value: T;
prev?: Node<T>;
@ -6,7 +8,7 @@ interface Node<T> {
export class DoubleQueue<T> {
protected _head?: Node<T>;
protected _tail?: Node<T>;
protected _size = 0;
protected _size = ref(0);
constructor(it?: Iterable<T>) {
if (it) {
for (const item of it) {
@ -15,17 +17,19 @@ export class DoubleQueue<T> {
}
}
get size() {
return this._size;
return this._size.value;
}
push(item: T) {
const node: Node<T> = { value: item };
if (this.size) {
(this._tail!.next = node).prev = this._tail;
this._tail = node;
} else {
this._head = this._tail = node;
push(...items: T[]) {
for (const item of items) {
const node: Node<T> = { value: item };
if (this.size) {
(this._tail!.next = node).prev = this._tail;
this._tail = node;
} else {
this._head = this._tail = node;
}
this._size.value++;
}
this._size++;
}
pop(): T | undefined {
if (!this.size) return undefined;
@ -35,19 +39,22 @@ export class DoubleQueue<T> {
} else {
node.prev!.next = undefined;
}
this._size--;
this._tail = node.prev;
this._size.value--;
if (this.size === 1) this._head = this._tail;
return node.value;
}
unshift(item: T) {
const node: Node<T> = { value: item };
if (this.size) {
(this._head!.prev = node).next = this._head;
this._head = node;
} else {
this._head = this._tail = node;
unshift(...items: T[]) {
for (const item of items) {
const node: Node<T> = { value: item };
if (this.size) {
(this._head!.prev = node).next = this._head;
this._head = node;
} else {
this._head = this._tail = node;
}
this._size.value++;
}
this._size++;
}
shift(): T | undefined {
if (!this.size) return undefined;
@ -57,7 +64,8 @@ export class DoubleQueue<T> {
} else {
node.next!.prev = undefined;
}
this._size--;
this._head = node.next;
this._size.value--;
if (this.size === 1) this._tail = this._head;
return node.value;
}

View File

@ -1,18 +1,21 @@
export type Future<T = void> = PromiseWithResolvers<T>;
export class FutureMap<T = void> extends Map<number, Future<T>> {
add(tileId: number) {
const future = Promise.withResolvers<T>();
this.set(tileId, future);
export class FutureMap<K, V = void> extends Map<K, Future<V>> {
add(key: K) {
const future = Promise.withResolvers<V>();
this.set(key, future);
return future.promise;
}
resolve(tileId: number, value: T) {
this.get(tileId)?.resolve(value);
this.delete(tileId);
resolve(key: K, value: V) {
this.get(key)?.resolve(value);
this.delete(key);
}
reject(tileId: number, reason: unknown) {
this.get(tileId)?.reject(reason);
this.delete(tileId);
reject(key: K, reason: unknown) {
this.get(key)?.reject(reason);
this.delete(key);
}
waitAll() {
return Promise.all(this.values().map((f) => f.promise));
}
}

View File

@ -19,3 +19,6 @@ export type ComparablePrimitive = Exclude<Primitive, symbol>;
export type Exposed<T> = {
[P in keyof T]: T[P];
};
export type ValidKey<T, O extends Record<string, unknown>> = {
[P in keyof O]: O[P] extends T ? P : never;
}[keyof O];

View File

@ -11,12 +11,8 @@
DEBUG
</div>
</div>
<game2048-score :key="game2048Store.gameKey" :score="game2048Store.maxNumber">
最高数字
</game2048-score>
<game2048-score :key="game2048Store.gameKey" :score="game2048Store.score">
得分
</game2048-score>
<game2048-score-v2 :score="game2048Store.maxNumber">最高数字</game2048-score-v2>
<game2048-score-v2 :score="game2048Store.score">得分</game2048-score-v2>
</div>
<div class="game-header">
<div class="game-description">
@ -93,7 +89,7 @@ export type SwipeEventMap = {
</script>
<script lang="ts" setup>
import Game2048Button from '@/components/game2048/Game2048Button.vue';
import Game2048Score from '@/components/game2048/Game2048Score.vue';
import Game2048ScoreV2 from '@/components/game2048/Game2048ScoreV2.vue';
import Game2048V2 from '@/components/game2048/Game2048V2.vue';
import { GAME_2048_DEBUG } from '@/env';
import { useGame2048Store } from '@/stores/2048';
@ -117,6 +113,5 @@ useEventListener(
function click() {
game2048Store.$reset();
console.log(game2048Store.gameKey);
}
</script>