🦄 refactor: 棋类socket通用化
This commit is contained in:
parent
a538782606
commit
8dac3188df
@ -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];
|
||||
}
|
@ -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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
@ -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的类型.
|
||||
|
@ -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);
|
||||
|
@ -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() {
|
||||
|
Loading…
x
Reference in New Issue
Block a user