feat: 2048支持移动端

This commit is contained in:
Litrix2 2024-12-22 11:02:50 +08:00
parent 47952a774e
commit 656538fecc
14 changed files with 169 additions and 91 deletions

View File

@ -18,7 +18,7 @@
"@vueuse/core": "^10.11.1",
"axios": "^1.7.9",
"crypto-js": "^4.2.0",
"element-plus": "^2.9.0",
"element-plus": "^2.9.1",
"jwt-decode": "^4.0.0",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
@ -26,18 +26,19 @@
"vfonts": "^0.0.3",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"zod": "^3.23.8"
"vue3-touch-events": "^4.2.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@iconify-json/ep": "^1.2.1",
"@iconify-json/ep": "^1.2.2",
"@rushstack/eslint-patch": "^1.10.4",
"@tsconfig/node20": "^20.1.4",
"@types/crypto-js": "^4.2.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.17.9",
"@unocss/transformer-directives": "^0.65.1",
"@types/node": "^20.17.10",
"@unocss/transformer-directives": "^0.65.2",
"@vitejs/plugin-legacy": "^6.0.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
@ -48,15 +49,14 @@
"eslint-plugin-vue": "^9.32.0",
"npm-run-all2": "^6.2.6",
"prettier": "^3.4.2",
"sass": "^1.82.0",
"sass": "^1.83.0",
"terser": "^5.37.0",
"typescript": "^5.7.2",
"unocss": "^0.65.1",
"unplugin-auto-import": "^0.17.8",
"unocss": "^0.65.2",
"unplugin-icons": "^0.18.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^6.0.0",
"vue-tsc": "^1.8.27"
"vite": "^6.0.5",
"vue-tsc": "^2.1.10"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@ -114,6 +114,8 @@ import LoginRegisterDialog, {
loginImplKey,
registerImplKey,
} from '@/components/app/LoginRegisterDialog.vue';
import router from '@/router';
import { loginRespSchema, registerRespSchema } from '@/schemas/response';
import { useMediaStore } from '@/stores/media';
import { usePageStore } from '@/stores/page.js';
import { useUserStore } from '@/stores/user';
@ -123,8 +125,6 @@ import type { AxiosError } from 'axios';
import { ElMessage } from 'element-plus';
import { storeToRefs } from 'pinia';
import { provide, ref, watch } from 'vue';
import { loginResponseSchema, registerResponseSchema } from './schemas/response';
import router from '@/router';
const userStore = useUserStore();
const pageStore = usePageStore();
const { mdLess } = storeToRefs(useMediaStore());
@ -138,7 +138,7 @@ provide(loginImplKey, async (params) => {
ElMessage.error([err.code, err.message].join(':'));
});
if (!loginRespRaw) return false;
const resp = loginResponseSchema.parse(loginRespRaw);
const resp = loginRespSchema.parse(loginRespRaw);
if (resp.type === 'error') {
ElMessage.error([resp.code, resp.msg].join(':'));
return false;
@ -153,7 +153,7 @@ provide(registerImplKey, async (params) => {
ElMessage.error([err.code, err.message].join(':'));
});
if (!raw) return false;
const resp = registerResponseSchema.parse(raw);
const resp = registerRespSchema.parse(raw);
if (resp.type === 'error') {
ElMessage.error([resp.code, resp.msg].join(':'));
return false;

View File

@ -1,4 +1,9 @@
import { userInfoResponseSchema } from '@/schemas';
import {
userInfoRespSchema,
userInfoRespSchemaNullable,
type SucceedUserInfoResp,
type SucceedUserInfoRespNullable,
} from '@/schemas';
import { useUserStore } from '@/stores/user';
import axios, { type AxiosError } from 'axios';
import { ElMessage } from 'element-plus';
@ -26,9 +31,14 @@ axiosInstance.interceptors.response.use((response) => {
});
export default axiosInstance;
export type RawResp = Record<string, unknown>;
export async function getUserInfo(showErrorMessage: boolean, userID?: string) {
export async function getUserInfo(showErrorMessage: boolean): Promise<SucceedUserInfoResp>;
export async function getUserInfo(
showErrorMessage: boolean,
userID: number,
): Promise<SucceedUserInfoRespNullable>;
export async function getUserInfo(showErrorMessage: boolean, userID?: number) {
let url = '/api/user/info';
if (userID) {
if (userID !== undefined) {
url += `/${userID}`;
}
const raw = await axiosInstance
@ -39,7 +49,7 @@ export async function getUserInfo(showErrorMessage: boolean, userID?: string) {
ElMessage.error(errorMessage('获取用户信息失败', e.code, e.message));
});
if (!raw) return;
const resp = userInfoResponseSchema.parse(raw);
const resp = (userID !== undefined ? userInfoRespSchemaNullable : userInfoRespSchema).parse(raw);
if (resp.type === 'error') {
if (showErrorMessage) {
ElMessage.error(errorMessage('获取用户信息失败', resp.code, resp.msg));

View File

@ -1,4 +0,0 @@
import mitt from 'mitt';
type Events = {};
export const bus = mitt<Events>();

View File

@ -179,7 +179,7 @@ const inputLength = 3;
const loginFormRules = reactive<FormRules<typeof loginFormData>>({
username: [
{ required: true, message: '请输入用户名' },
{ min: inputLength, message: `用户名长度不能小${inputLength}6` },
{ min: inputLength, message: `用户名长度不能小${inputLength}` },
],
password: [
{ required: true, message: '请输入密码' },

View File

@ -36,7 +36,7 @@
</style>
<script lang="ts" setup>
import axiosInstance from '@/api';
import { verifyResponseSchema } from '@/schemas';
import { verifyRespSchema } from '@/schemas';
import { timeout } from '@/utils';
import { errorMessage } from '@/utils/api';
import { AxiosError } from 'axios';
@ -96,7 +96,7 @@ async function updateVerifyImage() {
model.value.verifyImage = 'fetching';
try {
try {
const verifyResponse = verifyResponseSchema.parse(
const verifyResponse = verifyRespSchema.parse(
(await axiosInstance.get('/api/user/verify')).data,
);
console.log(verifyResponse);

View File

@ -190,28 +190,27 @@
}
</style>
<script lang="ts" setup>
import { type GameState, type Tile, useGame2048Store } from '@/stores/2048';
import { useGame2048Store, type GameState, type Tile } from '@/stores/2048';
import { chainIterables } from '@/utils';
import { get2DArrayItem } from '@/utils/array';
import { useEmitterEventListener } from '@/utils/emitter';
import type { Future } from '@/utils/types';
import { Directions, swipeEmitterKey } from '@/views/Game2048Page.vue';
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';
import {
computed,
inject,
onMounted,
onUnmounted,
reactive,
ref,
watch
} from 'vue';
const game2048Store = useGame2048Store();
/**
* 方向键代码.
*/
enum Directions {
UP = 'ArrowUp',
DOWN = 'ArrowDown',
LEFT = 'ArrowLeft',
RIGHT = 'ArrowRight',
}
type SingleTileLine = (Tile | undefined)[];
type OverlappedTileLine = Tile[][];
/**
@ -575,14 +574,14 @@ async function mergeTiles(direction: Directions) {
return Array.from(
(function* () {
switch (d) {
case 'ArrowUp':
case 'ArrowDown':
case Directions.UP:
case Directions.DOWN:
for (let x = 0; x < width.value; x++) {
yield x;
}
break;
case 'ArrowLeft':
case 'ArrowRight':
case Directions.LEFT:
case Directions.RIGHT:
for (let y = 0; y < height.value; y++) {
yield y;
}
@ -596,23 +595,23 @@ async function mergeTiles(direction: Directions) {
return Array.from(
(function* () {
switch (d) {
case 'ArrowUp':
case Directions.UP:
for (let y = height.value - 1; y >= 0; y--) {
yield y;
}
break;
case 'ArrowDown':
case Directions.DOWN:
for (let y = 0; y < height.value; y++) {
yield y;
}
break;
case 'ArrowLeft':
case Directions.LEFT:
for (let x = width.value - 1; x >= 0; x--) {
yield x;
}
break;
case 'ArrowRight':
case Directions.RIGHT:
for (let x = 0; x < width.value; x++) {
yield x;
}
@ -693,12 +692,11 @@ function onTileTransitionComplete(event: TransitionEvent) {
const tileId = getElementTileId(event.currentTarget as HTMLDivElement);
tileTransitionFutureMap.resolve(tileId);
}
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
async function move(key: string) {
if (locked.value || gameStatus.value !== 'playing') {
return;
}
switch (e.key) {
switch (key) {
case Directions.UP:
case Directions.DOWN:
case Directions.LEFT:
@ -716,7 +714,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
locked.value = false;
}
isTileTransitioning.value = true;
if (!(await mergeTiles(e.key)) && gameStatus.value === 'playing') {
if (!(await mergeTiles(key)) && gameStatus.value === 'playing') {
gameStatus.value = 'failed';
}
if (gameStatus.value !== 'playing') {
@ -728,6 +726,11 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
isTileTransitioning.value = false;
break;
}
}
const emitter = inject(swipeEmitterKey)!;
useEmitterEventListener(emitter, 'swipe', move);
useEventListener(document, 'keydown', async (e) => {
move(e.key);
});
onMounted(async () => {
await gameReadyFuture.promise;

View File

@ -1,11 +1,10 @@
import 'virtual:uno.css';
import '@/assets/global.scss';
import 'element-plus/theme-chalk/index.css';
import 'element-plus/theme-chalk/display.css';
import App from '@/App.vue';
import '@/assets/global.scss';
import router from '@/router';
import 'element-plus/dist/index.css';
import { createPinia } from 'pinia';
import 'virtual:uno.css';
import { createApp } from 'vue';
import Vue3TouchEvents, { type Vue3TouchEventsOptions } from 'vue3-touch-events';
const app = createApp(App);
app.use(createPinia()).use(router).mount('#app');
app.use(createPinia()).use(router).use<Vue3TouchEventsOptions>(Vue3TouchEvents, {}).mount('#app');

View File

@ -1,6 +1,6 @@
import { z } from 'zod';
function createResponseSchema<T extends z.ZodTypeAny>(data: T) {
function createRespSchema<T extends z.ZodTypeAny>(data: T) {
return z.union([
z
.object({
@ -28,11 +28,11 @@ function createResponseSchema<T extends z.ZodTypeAny>(data: T) {
]);
}
export type SucceedResponseOf<T> = Extract<T, { type: 'success' }>;
export type ErrorResponseOf<T> = Exclude<SucceedResponseOf<T>, T>;
export const ordinarySchema = createResponseSchema(z.literal(true));
export const loginResponseSchema = ordinarySchema;
export const registerResponseSchema = ordinarySchema;
export type SucceedRespOf<T> = Extract<T, { type: 'success' }>;
export type ErrorRespOf<T> = Exclude<SucceedRespOf<T>, T>;
export const ordinarySchema = createRespSchema(z.literal(true));
export const loginRespSchema = ordinarySchema;
export const registerRespSchema = ordinarySchema;
export const idAndNameSchema = z.object({
id: z.number(),
name: z.string(),
@ -41,21 +41,22 @@ export const idAndNameSchema = z.object({
const authSchema = idAndNameSchema.extend({
permissions: idAndNameSchema.array(),
});
export const userInfoResponseSchema = createResponseSchema(
idAndNameSchema.extend({
avatar: z.nullable(z.string()),
auth: authSchema,
club: z.nullable(
idAndNameSchema.extend({
commit: z.string(),
auth: authSchema,
}),
),
}),
);
export type SucceedUserInfoResponse = SucceedResponseOf<z.infer<typeof userInfoResponseSchema>>;
export type UserInfo = SucceedUserInfoResponse['data'];
export const verifyResponseSchema = createResponseSchema(
const userInfoDataSchema = idAndNameSchema.extend({
avatar: z.nullable(z.string()),
auth: authSchema,
club: z.nullable(
idAndNameSchema.extend({
commit: z.string(),
auth: authSchema,
}),
),
});
export const userInfoRespSchema = createRespSchema(userInfoDataSchema);
export const userInfoRespSchemaNullable = createRespSchema(userInfoDataSchema.nullable());
export type SucceedUserInfoResp = SucceedRespOf<z.infer<typeof userInfoRespSchema>>;
export type SucceedUserInfoRespNullable = SucceedRespOf<z.infer<typeof userInfoRespSchemaNullable>>;
export type UserInfo = SucceedUserInfoResp['data'];
export const verifyRespSchema = createRespSchema(
z
.object({
img: z.string(),

23
src/utils/emitter.ts Normal file
View File

@ -0,0 +1,23 @@
import type { Emitter, EventType } from 'mitt';
import mitt from 'mitt';
import { onUnmounted, provide, type InjectionKey } from 'vue';
export function useEmitter<M extends Record<EventType, unknown>>(
key?: InjectionKey<Emitter<M>>,
): Emitter<M> {
const emitter = mitt<M>();
if (key) provide(key, emitter);
onUnmounted(() => {
emitter.all.clear();
});
return emitter;
}
export function useEmitterEventListener<M extends Record<EventType, unknown>, T extends keyof M>(
emitter: Emitter<M>,
type: T,
listener: (event: M[T]) => void,
) {
emitter.on(type, listener);
onUnmounted(() => {
emitter.off(type, listener);
});
}

View File

@ -1,5 +1,5 @@
<template>
<el-main class="game-2048-page-wrapper !flex justify-center">
<el-main v-touch:swipe="onSwipe" class="game-2048-page-wrapper !flex justify-center">
<div class="game-2048-page">
<div class="game-header">
<div class="game-title">
@ -65,20 +65,54 @@
width: auto;
}
</style>
<script lang="ts">
/**
* 方向键代码.
*/
export enum Directions {
UP = 'ArrowUp',
DOWN = 'ArrowDown',
LEFT = 'ArrowLeft',
RIGHT = 'ArrowRight',
}
export type SwipeEventMap = {
swipe: Directions;
};
const swipeDirectionMap = {
top: Directions.UP,
bottom: Directions.DOWN,
left: Directions.LEFT,
right: Directions.RIGHT,
};
export const swipeEmitterKey = Symbol() as InjectionKey<Emitter<SwipeEventMap>>;
</script>
<script lang="ts" setup>
import Game2048 from '@/components/game2048/Game2048.vue';
import Game2048Button from '@/components/game2048/Game2048Button.vue';
import Game2048Score from '@/components/game2048/Game2048Score.vue';
import { useGame2048Store } from '@/stores/2048';
import { useMediaStore } from '@/stores/media';
import { useEmitter } from '@/utils/emitter';
import { useEventListener } from '@vueuse/core';
import type { Emitter } from 'mitt';
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { computed, type InjectionKey } from 'vue';
const emitter = useEmitter<SwipeEventMap>(swipeEmitterKey);
function onSwipe(d: keyof typeof swipeDirectionMap) {
emitter.emit('swipe', swipeDirectionMap[d]);
}
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);

View File

@ -46,17 +46,33 @@ import { getUserInfo } from '@/api';
import { type UserInfo } from '@/schemas';
import { useMediaStore } from '@/stores/media';
import { useUserStore } from '@/stores/user';
import { ElMessage } from 'element-plus';
import { storeToRefs } from 'pinia';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
const { lgLess, lgOnly } = storeToRefs(useMediaStore());
const route = useRoute();
const userStore = useUserStore();
const loading = ref(true);
const userInfo = ref<UserInfo>();
async function loadUserInfo() {
loading.value = true;
const id = Number(route.params.id as string);
try {
const resp = await getUserInfo(true);
if (isNaN(id)) {
ElMessage.error('参数错误');
return;
}
if (id === -1) {
ElMessage.error('用户不存在');
return;
}
const resp = await getUserInfo(true, id);
if (!resp) return;
if (!resp.data) {
ElMessage.error('用户不存在');
return;
}
userInfo.value = resp.data;
} finally {
loading.value = false;

View File

@ -10,6 +10,7 @@
"paths": {
"@/*": ["src/*"]
},
"noEmit": true,
"types": ["unplugin-icons/types/vue"],
"jsx": "preserve",
"jsxImportSource": "vue"

View File

@ -10,14 +10,10 @@ import Components from 'unplugin-vue-components/vite';
import { defineConfig } from 'vite';
// https://vitejs.dev/config/
export default defineConfig({
build: {
target: 'es2015',
},
plugins: [
legacy({
targets: ['defaults', 'Chrome >= 100', 'Edge >= 100', 'FireFox >= 110'],
polyfills: ['es.promise.with-resolvers'],
modernPolyfills: ['es.promise.with-resolvers'],
modernPolyfills: true,
}),
vue(),
Components({
@ -30,10 +26,10 @@ export default defineConfig({
}),
],
globs: [
'!src/components/*.vue',
'!src/components/**/*.vue',
'!src/views/*.vue',
'!src/views/**/*.vue',
'!./src/components/*.vue',
'!./src/components/**/*.vue',
'!./src/views/*.vue',
'!./src/views/**/*.vue',
],
}),
Icons({
@ -47,7 +43,6 @@ export default defineConfig({
resolve: {
alias: {
'@': resolve('./src'),
// vue: 'vue/dist/vue.esm-bundler.js'
},
},
server: {