🎈 perf: 准备优化2048和表格组件
All checks were successful
ci / build (push) Successful in 44s

This commit is contained in:
Litrix 2025-02-28 13:02:14 +08:00
parent f3bdeb25bc
commit 3516ddd55b
10 changed files with 588 additions and 15 deletions

View File

@ -624,7 +624,6 @@ async function mergeTiles(direction: Directions) {
await Promise.all(tileAddPromises.concat(addRandomTiles(changed ? 1 : 0).promise));
return Object.values(Directions).some((d) => getMergedGrid(d)[1]);
}
function onMaskClick() {
hideMask.value = true;
}

View File

@ -0,0 +1,400 @@
<template>
<div class="game-container" :style="gameContainerStyle" ref="gameContainer">
<template v-for="y in height">
<div
v-for="x in width"
:key="`${y}-${x}`"
:style="[getTilePositionStyle(y - 1, x - 1)]"
class="bg-tile"
ref="backgroundTiles"
></div>
</template>
<div
v-for="tile of tiles"
v-show="tile.renderState.exists"
class="tile"
:data-id="`tile-${tile.id}`"
:style="[
getTilePositionStyle(tile.renderState.y, tile.renderState.x),
getNumberTileStyle(tile.renderState),
]"
>
{{ tile.number }}
</div>
</div>
</template>
<style lang="scss" scoped>
.game-container {
position: relative;
width: calc(max-content);
height: calc(max-content);
background-color: rgb(187, 174, 158);
border-radius: 6px;
margin-bottom: 10px;
opacity: 0;
}
.tile,
.bg-tile {
position: absolute;
width: v-bind(tileWidthStyle);
height: v-bind(tileWidthStyle);
border-radius: 6px;
opacity: 0;
user-select: none;
}
.tile {
text-align: center;
font-weight: bold;
line-height: v-bind(tileWidthStyle);
}
.bg-tile {
background-color: rgb(205, 193, 180);
}
</style>
<script lang="ts">
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)',
};
namespace TableView {
export type GenKey = (first: number, second: number, xFirst: boolean) => string;
export type Pos = [y: number, x: number];
}
class TableView<T> {
private map = new Map<string, T>();
constructor(
private genKey: TableView.GenKey,
public size: TableView.Pos,
) {}
get(first: number, second: number, xFirst: boolean = false) {
return this.map.get(this.genKey(first, second, xFirst));
}
set(first: number, second: number, value: T, xFirst: boolean = false) {
this.map.set(this.genKey(first, second, xFirst), value);
}
delete(first: number, second: number, xFirst: boolean = false) {
this.map.delete(this.genKey(first, second, xFirst));
}
getLine(first: number, xFirst: boolean = false) {
return new LineView(this, first, xFirst);
}
*iter(xFirst: boolean = false) {
for (let i = 0; i < this.size[xFirst ? 1 : 0]; i++) {
yield this.getLine(i, xFirst);
}
}
toArray(xFirst: boolean = false) {
return Array.from(this.iter(xFirst).map((line) => line.toArray()));
}
clear() {
this.map.clear();
}
getEmptyPositions() {
const emptyPositions: TableView.Pos[] = [];
for (const [y, line] of entries(this.iter())) {
for (const [x, tile] of entries(line)) {
if (tile !== undefined) continue;
emptyPositions.push([y, x]);
}
}
return emptyPositions;
}
}
class LineView<T> {
constructor(
private table: TableView<T>,
private readonly first: number,
private readonly xFirst: boolean,
) {}
get(second: number) {
return this.table.get(this.first, second, this.xFirst);
}
set(second: number, value: T) {
this.table.set(this.first, second, value, this.xFirst);
}
delete(second: number) {
this.table.delete(this.first, second, this.xFirst);
}
get size() {
return this.table.size[this.xFirst ? 0 : 1];
}
toArray() {
return Array.from(this);
}
*[Symbol.iterator]() {
for (let i = 0; i < this.size; i++) {
yield this.get(i);
}
}
}
const showKeyframesEase = (scale: number = 0) => [
{
opacity: 0,
transform: `scale(${scale})`,
},
{
opacity: 1,
transform: 'none',
},
];
</script>
<script lang="ts" setup>
import { useGame2048Store } from '@/stores/2048';
import { arrayIncludes, entries, zip } from '@/utils/array';
import type { Exposed } from '@/utils/types';
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';
const game2048Store = useGame2048Store();
const { width, height } = storeToRefs(game2048Store);
const table = new TableView<Tile>(
(first, second, xFirst) => (xFirst ? `${first}-${second}` : `${second}-${first}`),
[game2048Store.width, game2048Store.height],
);
type TileState = {
number: number;
x: number;
y: number;
exists: boolean;
};
class Tile implements TileState {
private static lastId = 0;
readonly id: number;
private _x!: number;
private _y!: number;
number: number = 0;
renderState!: TileState;
_exists!: boolean;
constructor() {
this.id = Tile.lastId++;
this.x = -1;
this.y = -1;
this.syncAllState();
}
get x() {
return this._x;
}
set x(value) {
table.delete(this.y, this.x);
this._x = value;
table.set(this.y, this.x, this);
}
get y() {
return this._y;
}
set y(value) {
table.delete(this.y, this.x);
this._y = value;
table.set(this.y, this.x, this);
}
get el() {
return gameContainerRef.value?.querySelector(`.tile[data-id="tile-${this.id}"]`);
}
get exists() {
return this._exists;
}
set exists(value) {
this._exists = value;
if (value) {
idleTiles.delete(this);
} else {
idleTiles.add(this);
table.delete(this.y, this.x);
}
}
syncAllState() {
this.renderState = {
number: this.number,
x: this.x,
y: this.y,
exists: this._exists,
};
}
finish() {
this.el?.getAnimations().forEach((animation) => animation.finish());
}
async show(fromOthers: boolean) {
this.finish();
this._exists = true;
idleTiles.delete(this);
this.syncAllState();
await this.el?.animate(
fromOthers
? [
{
opacity: 0,
transform: `scale(0.5)`,
},
{
transform: `scale(1.1)`,
offset: 0.8,
},
{
opacity: 1,
transform: `scale(1)`,
},
]
: showKeyframesEase(),
keyframeOptions({ duration: 150 }),
).finished;
}
hide() {
this.finish();
this.exists = false;
this.renderState.exists = false;
}
async move() {
if (!this._exists) return;
this.finish();
const oldKeyframe = getTilePositionStyle(this.renderState.y, this.renderState.x);
this.renderState.y = this.y;
this.renderState.x = this.x;
const newKeyframe = getTilePositionStyle(this.renderState.y, this.renderState.x);
await this.el?.animate([oldKeyframe, newKeyframe], keyframeOptions({ duration: 100 })).finished;
}
}
const tiles = reactive<Exposed<Tile>[]>(
Array.from({ length: game2048Store.width * game2048Store.height }, () => new Tile()),
);
const idleTiles = new Set(tiles);
const keyframeOptions = ({
duration,
delay,
...options
}: KeyframeAnimationOptions): KeyframeAnimationOptions => ({
easing: 'ease',
fill: 'forwards',
duration: animate.value ? duration : 0,
delay: animate.value ? 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 getTilePositionStyle = (y: number, x: number) => ({
left: `${borderWidth + x * (tileWidth + borderWidth)}px`,
top: `${borderWidth + y * (tileWidth + borderWidth)}px`,
});
const getNumberTileStyle = ({ number }: TileState) => ({
fontSize: `${number < 128 ? 55 : number < 1024 ? 45 : 35}px`,
color: number < 8 ? 'rgb(119, 110, 101)' : 'white',
backgroundColor: tileBackgroundColorMapping[number] ?? 'rgb(56, 56, 56)',
});
const gameContainerRef = useTemplateRef('gameContainer');
const backgroundTilesRef = useTemplateRef('backgroundTiles');
const addRandomTiles = async (count: number) => {
const emptyPositions = shuffle(table.getEmptyPositions());
const addedTiles: Exposed<Tile>[] = [];
for (const [, tile] of zip(Array(count), Array.from(idleTiles))) {
const pos = emptyPositions.pop();
if (!pos) break;
tile.number = sample([2, 4]);
[tile.y, tile.x] = pos;
addedTiles.push(tile);
}
await Promise.all(addedTiles.map((t) => t.show(false)));
};
const init = async () => {
await addRandomTiles(2);
console.log(table.toArray());
};
onMounted(async () => {
await Promise.all(
[
gameContainerRef.value?.animate(showKeyframesEase(0.95), keyframeOptions({ duration: 500 })),
backgroundTilesRef.value?.map((el, i) => {
const x = i % width.value,
y = Math.floor(i / width.value);
return el.animate(
showKeyframesEase(),
keyframeOptions({ duration: 500, delay: (x + y) * 100 }),
);
}),
]
.flat()
.map((a) => a?.finished),
);
init();
});
const firstIndices = (d: Directions) =>
Array.from(
(function* () {
switch (d) {
case Directions.UP:
case Directions.DOWN:
for (let x = 0; x < width.value; x++) {
yield x;
}
break;
case Directions.LEFT:
case Directions.RIGHT:
for (let y = 0; y < height.value; y++) {
yield y;
}
break;
}
})(),
);
const secondIndices = (d: Directions) =>
Array.from(
(function* () {
switch (d) {
case Directions.UP:
for (let y = height.value - 1; y >= 0; y--) {
yield y;
}
break;
case Directions.DOWN:
for (let y = 0; y < height.value; y++) {
yield y;
}
break;
case Directions.LEFT:
for (let x = width.value - 1; x >= 0; x--) {
yield x;
}
break;
case Directions.RIGHT:
for (let x = 0; x < width.value; x++) {
yield x;
}
break;
}
})(),
);
const animate = ref(true);
const move = async (key: Directions) => {
const first = Array.from(firstIndices(key));
const second = Array.from(secondIndices(key));
const xFirst = arrayIncludes([Directions.UP, Directions.DOWN], key);
for (const y of first) {
for (const x of second.toReversed()) {
const tile = table.get(y, x, xFirst);
if (!tile) continue;
}
}
};
useEventListener(document, 'keydown', async (e) => {
if (!arrayIncludes(Object.values(Directions), e.key)) {
return;
}
e.preventDefault();
move(e.key);
});
</script>

View File

@ -0,0 +1,38 @@
<template>
<component :is="render()"></component>
</template>
<style lang="scss" module></style>
<script
setup
lang="tsx"
generic="T extends Record<string, unknown>, TCustomKey extends string = never"
>
import type { Override } from '@/utils/types';
import { ElTableV2, type ElTableColumn } from 'element-plus';
import { type ComputedRef } from 'vue';
import type { ComponentProps } from 'vue-component-type-helpers';
export type CustomColumn<K> = Override<
ComponentProps<typeof ElTableColumn>,
{
key: K;
show?: ComputedRef<boolean>;
},
'prop'
>;
export type DataSlot<T> = (props: { row: T }) => unknown;
export type DataSlotRecord<T, K extends keyof any = keyof T> = Record<K, DataSlot<T>>;
const { rows, customCols = [] } = defineProps<{
rows: T[];
customCols?: MaybeArray<CustomColumn<TCustomKey>>;
selectable?: boolean | ((row: T, index: number) => boolean);
}>();
type Slots = DataSlotRecord<T, TCustomKey> & {
default: () => unknown;
};
defineSlots<Slots>();
defineEmits<{
selectionChange: [rows: T[]];
}>();
const render = () => <ElTableV2 data={rows} columns={rows} width={'123'}></ElTableV2>;
</script>

View File

@ -7,12 +7,11 @@
>
<el-table-column prop="id" label="序号" />
<el-table-column prop="name" label="用户名" />
<!-- FIXME 插槽作用域还是any疑似LSP的bug-->
<template #auth="{ row }">
{{ userAuthNameMapping[(row as T).auth.id] ?? row.auth.name }}
{{ userAuthNameMapping[row.auth.id] ?? row.auth.name }}
</template>
<template v-if="showClubAuth" #clubAuth="{ row }">
{{ clubAuthNameMapping[(row as T).clubAuth?.id ?? 'NONE'] ?? row.clubAuth.name }}
{{ clubAuthNameMapping[row.clubAuth?.id ?? 'NONE'] ?? row.clubAuth?.name }}
</template>
<slot></slot>
<template v-for="{ key } of ensureArray(customCols)" v-slot:[key]="props">

View File

@ -47,6 +47,10 @@ const routes: RouteRecordRaw[] = [
breadcrumb: '首页',
},
},
{
path: '/test',
component: () => import('@/components/table/DataTableV2.vue'),
},
{
path: '/user/:id',
name: 'User',
@ -93,6 +97,11 @@ const routes: RouteRecordRaw[] = [
name: '2048',
component: () => import('@/views/Game2048Page.vue'),
},
{
path: '/2048v2',
name: '2048v2',
component: () => import('@/views/Game2048PageV2.vue'),
},
{
path: '/gobang',
name: 'GobangList',
@ -203,17 +212,17 @@ router.beforeEach(async (to) => {
const userStore = useUserStore();
const pageStore = usePageStore();
pageStore.setNewRouteId(to);
if (!userStore.userInfo) {
const succeed = await userStore.updateSelfUserInfo();
if (!succeed) {
return pageStore.createTempErrorRoute(
{
type: PageErrorType.NETWORK_ERROR,
},
to,
);
}
}
// if (!userStore.userInfo) {
// const succeed = await userStore.updateSelfUserInfo();
// if (!succeed) {
// return pageStore.createTempErrorRoute(
// {
// type: PageErrorType.NETWORK_ERROR,
// },
// to,
// );
// }
// }
const type = userStore.isRouteAccessible(to.fullPath);
if (type !== true) {

View File

@ -32,6 +32,12 @@ export function* zip<T extends Iterable<unknown>[]>(...its: T): Generator<Zip<T>
yield nextValues.map(({ value }) => value) as any;
}
}
export function* entries<T>(it: Iterable<T>): Generator<[number, T], void, unknown> {
let i = 0;
for (const value of it) {
yield [i++, value];
}
}
export function get2DArrayItem<T>(
grid: T[][],
first: number,
@ -46,3 +52,4 @@ export function ensureArray(obj: unknown) {
}
export const arrayIncludes = <T, R>(array: T[], value: R): value is T & R =>
array.includes(value as any);
export const len = (len: number) => ({ length: len });

View File

@ -82,3 +82,6 @@ export function waitRef<T, const R extends T>(ref: Ref<T>, ...expect: R[]) {
*/
export const isKeyOf = <T extends object>(obj: T, key: keyof any): key is keyof typeof obj =>
key in obj;
export const setProps = <T extends object>(obj: T, props: Partial<T>): T => {
return Object.assign(obj, props);
};

View File

@ -16,3 +16,6 @@ export type Rename<
};
export type Nullable<T = never> = T | null | undefined;
export type ComparablePrimitive = Exclude<Primitive, symbol>;
export type Exposed<T> = {
[P in keyof T]: T[P];
};

View File

@ -0,0 +1,113 @@
<template>
<el-main class="game-2048-page-wrapper !flex justify-center">
<div class="game-2048-page">
<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-v2 :key="game2048Store.gameKey" class="game" />
</div>
</el-main>
</template>
<style lang="scss" scoped>
.game-2048-page-wrapper {
--font-color: rgb(119, 110, 101);
font-family: 'Arial', '微软雅黑', '黑体', sans-serif;
background-color: rgb(250, 248, 239);
}
.game-2048-page {
width: max-content;
zoom: v-bind(zoomCSS);
}
.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 lang="ts">
/**
* 方向键代码.
*/
export enum Directions {
UP = 'ArrowUp',
DOWN = 'ArrowDown',
LEFT = 'ArrowLeft',
RIGHT = 'ArrowRight',
}
export type SwipeEventMap = {
swipe: Directions;
};
</script>
<script lang="ts" setup>
import Game2048Button from '@/components/game2048/Game2048Button.vue';
import Game2048Score from '@/components/game2048/Game2048Score.vue';
import Game2048V2 from '@/components/game2048/Game2048V2.vue';
import { useGame2048Store } from '@/stores/2048';
import { useMediaStore } from '@/stores/media';
import { useEventListener } from '@vueuse/core';
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
const game2048Store = useGame2048Store();
const { smLess } = storeToRefs(useMediaStore());
const zoomCSS = computed(() => {
return smLess.value ? 0.75 : 1;
});
useEventListener(
'touchmove',
(e) => {
e.preventDefault();
},
{ passive: false },
);
function click() {
game2048Store.$reset();
console.log(game2048Store.gameKey);
}
</script>

View File

@ -1,5 +1,6 @@
import legacy from '@vitejs/plugin-legacy';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import { resolve } from 'node:path';
import UnoCSS from 'unocss/vite';
import { FileSystemIconLoader } from 'unplugin-icons/loaders';
@ -16,6 +17,7 @@ export default defineConfig({
modernPolyfills: true,
}),
vue(),
vueJsx(),
Components({
resolvers: [
ElementPlusResolver(),