feat: 2048V2版持久化
All checks were successful
ci / build (push) Successful in 44s

This commit is contained in:
Litrix 2025-03-25 20:45:38 +08:00
parent 74965d2b8c
commit db5184bc37
7 changed files with 87 additions and 55 deletions

View File

@ -316,7 +316,7 @@ const tileBackgroundColorMapping: Record<number, string | undefined> = {
*/
function storeTileGrid() {
game2048Store.rawTileGrid = tileGrid.map((row) =>
row.map((tiles) => tiles.map((tile) => pick(tile, 'number'))),
row.map((tiles) => (tiles.length ? pick(tiles[0], 'number') : undefined)),
);
}
@ -735,17 +735,18 @@ onMounted(async () => {
0,
0,
...game2048Store.rawTileGrid.map((row) =>
row.map((rawTiles) =>
rawTiles.map((rawTile) => {
const id = maxId++;
tileAddPromises.push(tileAddFutureMap.add(id));
return {
row.map((rawTile) => {
if (!rawTile) return [];
const id = maxId++;
tileAddPromises.push(tileAddFutureMap.add(id));
return [
{
...rawTile,
id,
fromOthers: false,
};
}),
),
},
];
}),
),
);
if (game2048Store.isInitial) {

View File

@ -9,38 +9,22 @@
import { GAME_2048_DEBUG } from '@/env';
import { onBeforeUnmount, ref, useTemplateRef, watch } from 'vue';
const { leaveDuration, enterDuration, linearEnter, reversed, ...props } = defineProps<{
digit: number;
const { leaveDuration, enterDuration, linearEnter, linearLeave, reversed, ...props } = defineProps<{
digit: number | string;
leaveDuration: number;
enterDuration: number;
linearEnter: boolean;
linearLeave: boolean;
reversed: boolean;
}>();
const emit = defineEmits<{
up: [number];
finish: [number];
up: [];
finish: [];
}>();
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) => {
@ -52,9 +36,8 @@ watch(
fill: 'forwards',
duration: leaveDuration,
}).finished;
linearLeave = linearEnter;
digit.value = d;
emit('up', d);
emit('up');
if (GAME_2048_DEBUG) {
console.log('c2', linearEnter, enterDuration);
}
@ -63,7 +46,7 @@ watch(
fill: 'forwards',
duration: enterDuration,
}).finished;
emit('finish', d);
emit('finish');
},
);
onBeforeUnmount(() => {

View File

@ -7,12 +7,13 @@
<transition-group :css="false" @enter="onEnter" @leave="onLeave">
<game2048-score-digit
ref="digitRefs"
v-for="[i, d] of getDigits()"
v-for="[i, d] of getDigitChars()"
:key="i"
:digit="Number(d)"
:digit="d"
:leave-duration="leaveDuration"
:enter-duration="enterDuration"
:linear-enter="linearEnter"
:linear-leave="linearLeave"
:reversed="reversed"
@up="futureMap.resolve('leave')"
@finish="futureMap.resolve('enter')"
@ -51,6 +52,7 @@
</style>
<script lang="ts">
export const baseDuration = (tileShowDuration + tileMoveDuration) / 2;
// export const baseDuration = 1000;
const fadeOutKeyframe = { fontSize: 0, scale: 0 };
</script>
<script lang="ts" setup>
@ -58,7 +60,7 @@ 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 { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import Game2048ScoreDigit from './Game2048ScoreDigit.vue';
import { tileMoveDuration, tileShowDuration } from './Game2048V2.vue';
@ -66,17 +68,19 @@ const props = defineProps<{
score: number;
}>();
const score = ref(props.score);
const getDigits = (n: number = score.value) => {
const getDigitChars = (n: number = score.value) => {
const s = String(n);
return s.split('').map((d, i): [i: number, d: number] => [s.length - 1 - i, Number(d)]);
return s.split('').map((d, i): [i: number, d: string] => [s.length - 1 - i, 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);
const linearLeave = ref(false);
const delta = ref(0);
const reversed = computed(() => delta.value < 0);
scoreQueue.watchPush(props, 'score');
const onEnter = async (el: Element, done: () => void) => {
await el.animate(fadeOutKeyframe, { duration: 0, fill: 'forwards' }).finished;
@ -98,24 +102,31 @@ const onLeave = async (el: Element, done: () => void) => {
futureMap.resolve('leave');
done();
};
const reversed = ref(false);
let isUnmounted = false;
const getDelta = (v: number, o: number): [sameDirection: boolean, delta: number] => {
const newDelta = v - o;
const sameDirection = delta.value >= 0 ? newDelta >= 0 : newDelta < 0;
return [sameDirection, newDelta];
};
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;
delta = newDelta;
let sameDirection: boolean;
[sameDirection, delta.value] = getDelta(s, old);
leaveDuration.value = baseDuration / (scoreQueue.size + 1 + Number(linearEnter.value));
enterDuration.value = baseDuration / (scoreQueue.size + 1);
let nextSameDirection = !scoreQueue.size || getDelta(scoreQueue.getImmediate(0)!, s)[0];
linearLeave.value = linearEnter.value && sameDirection;
linearEnter.value = !!scoreQueue.size && nextSameDirection;
futureMap.clear();
if (GAME_2048_DEBUG) {
console.log('score', s, nextSameDirection);
}
if (!new RegExp(`.+${old}`).test(String(s))) {
futureMap.add('leave').then(() => {
linearEnter.value = !!scoreQueue.size && (delta >= 0 ? newDelta >= 0 : newDelta < 0);
});
futureMap.add('leave');
}
if (!new RegExp(`.+${s}$`).test(String(old))) {
futureMap.add('enter');
@ -126,6 +137,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
isUnmounted = true;
scoreQueue.cancelGetters();
futureMap.values().forEach((v) => v.resolve());
scoreDiv.value?.getAnimations().forEach((animation) => animation.finish());
});
</script>

View File

@ -78,15 +78,15 @@ export const tileMoveDuration = 110,
</script>
<script lang="ts" setup>
import { GAME_2048_DEBUG } from '@/env';
import { useGame2048Store } from '@/stores/2048';
import { useGame2048Store, type TileSerialized } from '@/stores/2048';
import { chainIterables, waitRef } from '@/utils';
import { arrayIncludes } from '@/utils/array';
import { arrayIncludes, create2DArray, entries, iter2DArray } from '@/utils/array';
import { setDefault } from '@/utils/map';
import { TableView, toPos, type Pos } from '@/utils/table-view';
import type { Exposed } from '@/utils/types';
import { Directions } from '@/views/Game2048PageV2.vue';
import { useEventListener } from '@vueuse/core';
import { sample, shuffle } from 'lodash-es';
import { pick, sample, shuffle } from 'lodash-es';
import { storeToRefs } from 'pinia';
import { onMounted, reactive, ref, useTemplateRef, watch } from 'vue';
const game2048Store = useGame2048Store();
@ -215,6 +215,19 @@ watch(
game2048Store.maxNumber = v;
},
);
watch(tiles, () => {
const grid = create2DArray<TileSerialized | undefined>(
game2048Store.height,
game2048Store.width,
undefined,
);
Iterator.from(tiles)
.filter((t) => t.exists)
.forEach((tile) => {
grid[tile.y][tile.x] = pick(tile, 'number');
});
game2048Store.rawTileGrid = grid;
});
const idleTiles = new Set(tiles);
const createShowKeyframeCommon = (
pos: Pos | undefined,
@ -268,7 +281,20 @@ const addRandomTiles = (count: number) => {
}
return addedTiles;
};
const initTable = () => {
const res: Exposed<Tile>[] = [];
for (const [i, [x, y, t]] of entries(
iter2DArray(game2048Store.rawTileGrid).filter(([, , t]) => t),
)) {
const tile = tiles[i];
tile.setPos([y, x], 'show', true);
tile.number = t!.number;
res.push(tile);
}
return res;
};
onMounted(async () => {
const originalTiles = initTable();
await Promise.all(
[
gameContainerRef.value?.animate(
@ -290,7 +316,10 @@ onMounted(async () => {
}),
].flat(),
);
await Promise.all(addRandomTiles(2).map((t) => t.show(false)));
await Promise.all([
Promise.all(originalTiles.map((t) => t.show(false))),
game2048Store.isInitial && Promise.all(addRandomTiles(2).map((t) => t.show(false))),
]);
animating.value = false;
});
const firstIndices = (d: Directions) =>

View File

@ -10,7 +10,7 @@ export const useGame2048Store = defineStore('2048', () => {
const { key: gameKey, refresh: refreshGame } = useRefresh();
function create() {
return create2DArray<TileSerialized[]>(height.value, width.value, () => []);
return create2DArray<TileSerialized | undefined>(height.value, width.value, () => undefined);
}
function $reset() {
@ -32,7 +32,7 @@ export const useGame2048Store = defineStore('2048', () => {
height.value = 4;
successNumber.value = 2048;
watch([width, height], $reset, { flush: 'sync' });
const rawTileGrid = useLocalStorage<TileSerialized[][][]>('2048-tile-grid', create());
const rawTileGrid = useLocalStorage<(TileSerialized | undefined)[][]>('2048-tile-grid', create());
watch(
rawTileGrid,
() => {

View File

@ -10,7 +10,9 @@ export function create2DArray<T>(
Array.from({ length: width }, (_, x) => (cell instanceof Function ? cell(x, y) : cell)),
);
}
export function* iter2DArray<T>(grid: T[][]): Generator<[number, number, T], void, unknown> {
export function* iter2DArray<T>(
grid: T[][],
): Generator<[x: number, y: number, item: T], void, unknown> {
for (let y = 0; y < grid.length; y++) {
for (let x = 0; x < grid[y].length; x++) {
yield [x, y, grid[y][x]];

View File

@ -22,7 +22,12 @@ export class AsyncQueue<T> {
this._getters.push(future);
return future.promise;
}
shiftImmediate(): T | undefined {
return this._queue.shift();
}
getImmediate(i: number) {
return this._queue.get(i);
}
push(...values: T[]): this {
for (const value of values) {
const future = this._getters.shift();