🦄 refactor: 棋类socket通用化

This commit is contained in:
Litrix 2024-12-17 20:33:47 +08:00
parent a538782606
commit 8dac3188df
5 changed files with 185 additions and 136 deletions

View File

@ -1,3 +1,5 @@
import type { MaybeArray } from '.';
export function create2DArray<T>(
height: number,
width: number,
@ -16,3 +18,7 @@ export function get2DArrayItem<T>(
): T {
return colFirst ? grid[first][second] : grid[second][first];
}
export function ensureArray<T>(obj: MaybeArray<T, true>): T[];
export function ensureArray(obj: unknown) {
return Array.isArray(obj) ? obj : [obj];
}

View File

@ -1,4 +1,9 @@
import type { MaybeArray } from '.';
import router from '@/router';
import { useUserStore } from '@/stores/user';
import { useWebSocket } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import type { ValueOf } from 'element-plus/es/components/table/src/table-column/defaults.mjs';
import { ensureArray, type MaybeArray } from '.';
export interface SimplePart<N extends string> {
name: N;
@ -7,5 +12,121 @@ type Payload = Record<string, unknown>;
export interface PayloadPart<N extends string, P extends Payload> extends SimplePart<N> {
payload: P;
}
type Relations = Record<string, MaybeArray<string>>;
export function useGameSocket<TRequest extends SimplePart<string>, const TRelations extends Relations,>{}
type BaseResp = PayloadPart<string, Record<string, unknown>>;
type Relations<TRequestNames extends string, TRespNames extends string> = Partial<
Record<TRequestNames, MaybeArray<TRespNames, true>>
>;
export interface UseGameSocketOptions<
TRequest extends SimplePart<string>,
TResp extends BaseResp,
TRelations extends Relations<TRequest['name'], TResp['name']>,
> {
succeed: {
[P in TResp['name']]?: (payload: Extract<TResp, { name: P }>['payload']) => void;
};
error?: {
[P in TRequest['name']]?: (reason: string) => boolean | void;
};
finally?: {
// 只有拥有依赖关系的请求才可以有finally
[P in keyof TRelations]?: (error: boolean) => void;
};
}
export function useGameSocket<TRequest extends SimplePart<string>, TResp extends BaseResp>() {
return <TRelations extends Relations<TRequest['name'], TResp['name']>>(
relations: TRelations,
options: UseGameSocketOptions<TRequest, TResp, TRelations>,
) => {
interface ErrorResp extends PayloadPart<'Error', { reason: string }> {
originalPacketName: TRequest['name'];
}
const isErrorResp = (obj: BaseResp): obj is ErrorResp => obj.name === 'Error';
/** resp->request */
const reversedRelations: Record<string, Set<string> | undefined> = {};
for (const [k, values] of Object.entries<ValueOf<TRelations>>(relations as any)) {
if (!values) continue;
for (const v of ensureArray(values)) {
(reversedRelations[v] ??= new Set()).add(k);
}
}
const userStore = useUserStore();
const relationMap = new Map<keyof TRelations, [TRequest, Set<string>]>();
const ws = useWebSocket<string>(`wss://wzpmc.cn:18080/chess/${userStore.token}`, {
// autoReconnect: {
// delay: 500,
// },
// onConnected() {
// // 连接无故断开的临时解决方案
// if (firstConnected) {
// console.log('connected');
// firstConnected = false;
// return;
// }
// console.log('reconnected');
// for (const [req] of relationMap.values()) {
// send(req);
// }
// },
onMessage(_, { data }) {
const resp: TResp | ErrorResp = JSON.parse(data);
console.log(resp);
const name = resp.name as TResp['name'];
let reqName: keyof TRelations | undefined;
let error = false;
try {
if (isErrorResp(resp)) {
const { originalPacketName, payload } = resp;
error = true;
if (relations[originalPacketName]) {
reqName = originalPacketName;
relationMap.delete(originalPacketName);
}
const errorHandler = options.error?.[originalPacketName];
if (errorHandler) {
const res = errorHandler(payload.reason);
if (res) return;
}
ElMessage.error(payload.reason);
return;
}
const set = reversedRelations[name];
if (set) {
for (const k of set) {
const v = relationMap.get(k);
if (!v) continue;
const [, set] = v;
set.delete(name);
if (set.size) continue;
relationMap.delete(k);
reqName = k;
break;
}
}
options.succeed[name]?.(resp.payload as any);
} finally {
if (reqName && relations[reqName]) {
options.finally?.[reqName]?.(error);
}
}
},
onDisconnected(_, ev) {
console.log('disconnected');
// 正常断开
if (ev.code === 1000) return;
ElMessage.error(`连接已断开:${ev.reason}`);
router.push('/');
},
});
const send = (data: TRequest) => {
const name = data.name as TRequest['name'];
if (relations[name]) {
relationMap.set(name, [data, new Set(ensureArray(relations[name]))]);
}
console.log('send', data);
ws.send(JSON.stringify(data));
};
return {
send,
};
};
}

View File

@ -1,6 +1,6 @@
import { reactive, readonly, type Ref, ref, watch } from 'vue';
export * from './2d-array';
export * from './array';
export * from './api';
export const timeout = (delay: number = 0) =>
@ -139,7 +139,7 @@ export function waitRef<T, const R extends T>(ref: Ref<T>, ...expect: R[]) {
});
}
export type MaybePromise<T> = T | Promise<T>;
export type MaybeArray<T> = T | T[];
export type MaybeArray<T, R extends boolean = false> = T | (R extends false ? T[] : readonly T[]);
export type ValueOf<T extends object> = T[keyof T];
/**
* key是否为obj的键key的类型.

View File

@ -68,7 +68,7 @@ export enum RoomState {
}
/** 房间UUID */
export type RoomId = string;
export interface RoomInfo {
export interface Room {
id: RoomId;
full: boolean;
state: RoomState;
@ -76,146 +76,41 @@ export interface RoomInfo {
isBlackAcceptRestart: boolean;
canWhiteDown: boolean;
}
export interface RoomDetail {}
export interface SimplePart<N extends string> {
name: N;
}
type Payload = Record<string, unknown>;
export interface PayloadPart<N extends string, P extends Payload> extends SimplePart<N> {
payload: P;
}
export type RoomDetail = {
roomId: RoomId;
state: RoomState;
pieces: (-1 | 0 | 1)[][];
whiteUser?: UserInfo;
blackUser?: UserInfo;
};
export type Request =
| SimplePart<'RoomList'>
| SimplePart<'CreateRoom'>
| PayloadPart<'PlayerJoin', { roomId: RoomId }>
| SimplePart<'ResetRoom'>;
export type Resp =
| PayloadPart<'UserInfo', SucceedUserInfoResponse>
| PayloadPart<'RoomList', { rooms: RoomInfo[] }>
| PayloadPart<'UserInfo', UserInfo>
| PayloadPart<'RoomList', { rooms: Room[] }>
| PayloadPart<'RoomCreated', { roomId: RoomId }>
| PayloadPart<'PlayerSideAllocation', { isWhite: boolean }>;
const relations = {
| PayloadPart<'PlayerSideAllocation', { isWhite: boolean }>
| PayloadPart<'RoomInfo', RoomDetail>;
export const relations = {
RoomList: 'RoomList',
RoomCreated: 'CreateRoom',
PlayerSideAllocation: 'PlayerJoin',
} as const satisfies Partial<Record<Resp['name'], Request['name']>>;
type RelatedRequestNames = ValueOf<typeof relations>;
const reversedRelations = {} as Record<RelatedRequestNames, string[]>;
for (const [k, v] of Object.entries(relations)) {
(reversedRelations[v] ??= []).push(k);
}
export interface ErrorResp extends PayloadPart<'Error', { reason: string }> {
originalPacketName: Request['name'];
}
export type RequestOf<T extends Request['name']> = Extract<Request, { name: T }>;
export type RespOf<T extends Resp['name']> = Extract<Resp, { name: T }>;
export interface UseGobangSocketOptions {
succeed: {
[P in Resp['name']]?: (payload: RespOf<P>['payload']) => void;
};
error?: {
[P in Request['name']]?: (reason: string) => boolean | void;
};
finally?: {
// finally
[P in RelatedRequestNames]?: (error: boolean) => void;
};
}
export function useGobangSocket(options: UseGobangSocketOptions) {
const userStore = useUserStore();
const relationMap = new Map<RelatedRequestNames, [Request, Set<string>]>();
// let firstConnected = true;
const ws = useWebSocket<string>(`ws://172.16.127.101:58080/chess/${userStore.token}`, {
// autoReconnect: {
// delay: 500,
// },
// onConnected() {
// //
// if (firstConnected) {
// console.log('connected');
// firstConnected = false;
// return;
// }
// console.log('reconnected');
// for (const [req] of relationMap.values()) {
// send(req);
// }
// },
onMessage(_, { data }) {
const resp: Resp | ErrorResp = JSON.parse(data);
console.log(resp);
const { name } = resp;
let reqName: Request['name'] | undefined;
let error = false;
try {
if (name === 'Error') {
const { originalPacketName } = resp;
reqName = originalPacketName;
error = true;
if (isKeyOf(relations, originalPacketName)) {
relationMap.delete(originalPacketName);
}
const errorHandler = options.error?.[originalPacketName];
if (errorHandler) {
const res = errorHandler(resp.payload.reason);
if (res) return;
}
ElMessage.error(resp.payload.reason);
return;
}
if (isKeyOf(relations, name)) {
const v = relationMap.get(relations[name]);
if (v) {
const [, set] = v;
set.delete(name);
if (!set.size) {
relationMap.delete(relations[name]);
reqName = relations[name];
}
}
}
options.succeed[name]?.(resp.payload as any);
} finally {
if (reqName && isKeyOf(reversedRelations, reqName)) {
options.finally?.[reqName]?.(error);
}
}
},
onDisconnected(_, ev) {
console.log('disconnected');
//
if (ev.code === 1000) return;
ElMessage.error(`连接已断开:${ev.reason}`);
router.push('/');
},
});
const send = (data: Request) => {
const { name } = data;
if (isKeyOf(reversedRelations, name)) {
relationMap.set(name, [data, new Set(reversedRelations[name])]);
}
console.log('send', data);
ws.send(JSON.stringify(data));
};
return {
send,
};
}
CreateRoom: 'RoomCreated',
PlayerJoin: ['PlayerSideAllocation', 'RoomInfo'],
} as const;
</script>
<script lang="ts" setup>
import CreateGobangRoomDialog from '@/components/gobang/CreateGobangRoomDialog.vue';
import GobangHeader from '@/components/gobang/GobangHeader.vue';
import router from '@/router';
import type { SucceedUserInfoResponse } from '@/schemas';
import { useUserStore } from '@/stores/user';
import { isKeyOf, type ValueOf } from '@/utils';
import { useWebSocket } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import type { SucceedUserInfoResponse, UserInfo } from '@/schemas';
import { useGameSocket, type PayloadPart, type SimplePart } from '@/utils/game-socket';
import { onMounted, ref } from 'vue';
interface RoomInfoRender extends RoomInfo {
interface RoomInfoRender extends Room {
briefId: string;
}
function toRoomRender(room: RoomInfo): RoomInfoRender {
function toRoomRender(room: Room): RoomInfoRender {
return Object.assign(room, {
briefId: room.id.slice(0, 8),
});
@ -224,7 +119,7 @@ const showDialog = ref(false);
const rooms = ref<RoomInfoRender[]>([]);
const loading = ref(false);
const loadingText = ref<string>();
const { send } = useGobangSocket({
const { send } = useGameSocket<Request, Resp>()(relations, {
succeed: {
RoomList(p) {
rooms.value = p.rooms.map(toRoomRender);

View File

@ -14,6 +14,12 @@
</div>
</gobang-header>
<el-main class="gobang-play-page-main center flex flex-column">
<div class="gobang__state">
<!-- {{ stateDisplayMap[state] }} -->
</div>
<div class="gobang-user">
<div class="gobang-user__name">{{}}</div>
</div>
<div class="gobang-chessboard">
<canvas class="gobang-chessboard__background" ref="canvas"></canvas>
<template v-for="(row, y) of grid">
@ -91,11 +97,19 @@
import GobangHeader from '@/components/gobang/GobangHeader.vue';
import router from '@/router';
import { useMediaStore } from '@/stores/media';
import { create2DArray } from '@/utils';
import { useGobangSocket, type RoomId } from '@/views/GobangListPage.vue';
import { useUserStore } from '@/stores/user';
import { useGameSocket } from '@/utils/game-socket';
import {
relations,
type Request,
type Resp,
type RoomDetail,
type RoomId,
} from '@/views/GobangListPage.vue';
import { storeToRefs } from 'pinia';
import { computed, onMounted, ref, watchEffect, type CSSProperties } from 'vue';
import { useRoute } from 'vue-router';
const userStore = useUserStore();
const { smLess } = storeToRefs(useMediaStore());
const roomId = useRoute().params.id as RoomId | undefined;
const canvas = ref<HTMLCanvasElement>();
@ -161,8 +175,7 @@ watchEffect(() => {
interface Chess {
isWhite: boolean;
}
type Grid = Chess[][];
const grid = ref<Grid>();
type Grid = (Chess | undefined)[][];
function getCellStyle(chess: Chess | undefined, x: number, y: number) {
const res: CSSProperties = {
left: `${boardPadding + boardBorderSize + gridSize * x}px`,
@ -171,9 +184,20 @@ function getCellStyle(chess: Chess | undefined, x: number, y: number) {
return res;
}
type State = 'FIRST_LOADING' | 'LOADING' | 'GAMING' | 'WAITING';
const room = ref<RoomDetail>();
const otherUser = computed(() => {
if (!room.value) return;
const { whiteUser, blackUser } = room.value;
return [whiteUser, blackUser].find((v) => v && v?.id !== userStore.userInfo!.id);
});
const grid = computed<Grid | undefined>(() =>
room.value?.pieces.map((row) =>
row.map((v) => (v === 0 ? { isWhite: false } : v === 1 ? { isWhite: true } : undefined)),
),
);
const state = ref<State>('FIRST_LOADING');
const isWhite = ref(false);
const { send } = useGobangSocket({
const { send } = useGameSocket<Request, Resp>()(relations, {
succeed: {
UserInfo() {
if (!roomId) return;
@ -187,6 +211,9 @@ const { send } = useGobangSocket({
PlayerSideAllocation(p) {
isWhite.value = p.isWhite;
},
RoomInfo(p) {
room.value = p;
},
},
error: {
PlayerJoin() {