From e96c45739e04535fca1de191b7f5def3dd9caf91 Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Fri, 3 Apr 2026 20:46:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=88=90?= =?UTF-8?q?=E9=83=BD=E9=BA=BB=E5=B0=86=E6=B8=B8=E6=88=8F=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=92=8C=E5=A4=A7=E5=8E=85=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 ChengduGamePage.vue 组件,包含完整的麻将游戏界面 - 实现 HallPage.vue 组件,支持房间列表展示、创建和加入功能 - 添加 mahjong API 接口用于房间管理操作 - 集成 store 状态管理和本地存储功能 - 实现 ChengduBottomActions 等游戏控制组件 - 添加 websocket 连接和游戏会话管理逻辑 - 实现游戏倒计时、结算等功能模块 --- src/api/mahjong.ts | 120 +- .../chengdu/ChengduBottomActions.vue | 123 + src/components/chengdu/ChengduDeskZones.vue | 55 + .../chengdu/ChengduSettlementOverlay.vue | 73 + src/components/chengdu/ChengduTableHeader.vue | 112 + src/components/chengdu/ChengduWallSeats.vue | 90 + src/game/chengdu/messageNormalizers.ts | 283 ++ src/store/index.ts | 28 +- src/store/state.ts | 12 +- src/store/storage.ts | 8 +- src/views/ChengduGamePage.vue | 3360 +---------------- src/views/HallPage.vue | 8 +- .../composables/useChengduGameActions.ts | 433 +++ .../composables/useChengduGameSession.ts | 459 +++ .../composables/useChengduGameSocket.ts | 143 + .../composables/useChengduTableView.ts | 430 +++ .../chengdu/socket/createMessageHandlers.ts | 51 + .../chengdu/socket/handlers/playerHandlers.ts | 170 + .../socket/handlers/roomInfoHandlers.ts | 89 + .../socket/handlers/roomStateHandlers.ts | 96 + .../chengdu/socket/handlers/socketDispatch.ts | 19 + .../chengdu/socket/handlers/statusHandlers.ts | 32 + .../chengdu/socket/handlers/turnHandlers.ts | 156 + .../socket/parsers/gameActionMessage.ts | 63 + .../socket/parsers/roomInfoSnapshot.ts | 274 ++ .../socket/parsers/roomStateSnapshot.ts | 109 + .../chengdu/socket/room/roomSnapshotSync.ts | 83 + .../socket/session/gameActionEffects.ts | 49 + .../socket/session/sessionStateAdapter.ts | 79 + .../chengdu/socket/store/gameStoreAdapter.ts | 109 + src/views/chengdu/socket/types.ts | 61 + src/views/chengdu/types.ts | 116 + 32 files changed, 4006 insertions(+), 3287 deletions(-) create mode 100644 src/components/chengdu/ChengduBottomActions.vue create mode 100644 src/components/chengdu/ChengduDeskZones.vue create mode 100644 src/components/chengdu/ChengduSettlementOverlay.vue create mode 100644 src/components/chengdu/ChengduTableHeader.vue create mode 100644 src/components/chengdu/ChengduWallSeats.vue create mode 100644 src/game/chengdu/messageNormalizers.ts create mode 100644 src/views/chengdu/composables/useChengduGameActions.ts create mode 100644 src/views/chengdu/composables/useChengduGameSession.ts create mode 100644 src/views/chengdu/composables/useChengduGameSocket.ts create mode 100644 src/views/chengdu/composables/useChengduTableView.ts create mode 100644 src/views/chengdu/socket/createMessageHandlers.ts create mode 100644 src/views/chengdu/socket/handlers/playerHandlers.ts create mode 100644 src/views/chengdu/socket/handlers/roomInfoHandlers.ts create mode 100644 src/views/chengdu/socket/handlers/roomStateHandlers.ts create mode 100644 src/views/chengdu/socket/handlers/socketDispatch.ts create mode 100644 src/views/chengdu/socket/handlers/statusHandlers.ts create mode 100644 src/views/chengdu/socket/handlers/turnHandlers.ts create mode 100644 src/views/chengdu/socket/parsers/gameActionMessage.ts create mode 100644 src/views/chengdu/socket/parsers/roomInfoSnapshot.ts create mode 100644 src/views/chengdu/socket/parsers/roomStateSnapshot.ts create mode 100644 src/views/chengdu/socket/room/roomSnapshotSync.ts create mode 100644 src/views/chengdu/socket/session/gameActionEffects.ts create mode 100644 src/views/chengdu/socket/session/sessionStateAdapter.ts create mode 100644 src/views/chengdu/socket/store/gameStoreAdapter.ts create mode 100644 src/views/chengdu/socket/types.ts create mode 100644 src/views/chengdu/types.ts diff --git a/src/api/mahjong.ts b/src/api/mahjong.ts index e68255e..1055192 100644 --- a/src/api/mahjong.ts +++ b/src/api/mahjong.ts @@ -1,79 +1,79 @@ -import { authedRequest, type AuthSession } from './authed-request' +import {authedRequest, type AuthSession} from './authed-request' -export interface RoomItem { - room_id: string - name: string - game_type: string - owner_id: string - max_players: number - player_count: number - players?: Array<{ - index: number - player_id: string - player_name?: string - PlayerName?: string - ready: boolean - }> - status: string - created_at: string - updated_at: string +export interface Room { + room_id: string + name: string + game_type: string + owner_id: string + max_players: number + player_count: number + players?: Array<{ + index: number + player_id: string + player_name?: string + PlayerName?: string + ready: boolean + }> + status: string + created_at: string + updated_at: string } export interface RoomListResult { - items: RoomItem[] - page: number - size: number - total: number + items: Room[] + page: number + size: number + total: number } const ROOM_CREATE_PATH = - import.meta.env.VITE_ROOM_CREATE_PATH ?? '/api/v1/game/mahjong/room/create' + import.meta.env.VITE_ROOM_CREATE_PATH ?? '/api/v1/game/mahjong/room/create' const ROOM_LIST_PATH = import.meta.env.VITE_ROOM_LIST_PATH ?? '/api/v1/game/mahjong/room/list' const ROOM_JOIN_PATH = import.meta.env.VITE_ROOM_JOIN_PATH ?? '/api/v1/game/mahjong/room/join' export async function createRoom( - auth: AuthSession, - input: { name: string; gameType: string; totalRounds: number; maxPlayers: number }, - onAuthUpdated?: (next: AuthSession) => void, -): Promise { - return authedRequest({ - method: 'POST', - path: ROOM_CREATE_PATH, - auth, - onAuthUpdated, - body: { - name: input.name, - game_type: input.gameType, - total_rounds: input.totalRounds, - max_players: input.maxPlayers, - }, - }) + auth: AuthSession, + input: { name: string; gameType: string; totalRounds: number; maxPlayers: number }, + onAuthUpdated?: (next: AuthSession) => void, +): Promise { + return authedRequest({ + method: 'POST', + path: ROOM_CREATE_PATH, + auth, + onAuthUpdated, + body: { + name: input.name, + game_type: input.gameType, + total_rounds: input.totalRounds, + max_players: input.maxPlayers, + }, + }) } export async function listRooms( - auth: AuthSession, - onAuthUpdated?: (next: AuthSession) => void, + auth: AuthSession, + onAuthUpdated?: (next: AuthSession) => void, ): Promise { - return authedRequest({ - method: 'GET', - path: ROOM_LIST_PATH, - auth, - onAuthUpdated, - }) + return authedRequest({ + method: 'GET', + path: ROOM_LIST_PATH, + auth, + onAuthUpdated, + }) } export async function joinRoom( - auth: AuthSession, - input: { roomId: string }, - onAuthUpdated?: (next: AuthSession) => void, -): Promise { - return authedRequest({ - method: 'POST', - path: ROOM_JOIN_PATH, - auth, - onAuthUpdated, - body: { - room_id: input.roomId, - }, - }) + auth: AuthSession, + input: { roomId: string }, + onAuthUpdated?: (next: AuthSession) => void, +): Promise { + return authedRequest({ + method: 'POST', + path: ROOM_JOIN_PATH, + auth, + onAuthUpdated, + body: { + room_id: input.roomId, + }, + }) } diff --git a/src/components/chengdu/ChengduBottomActions.vue b/src/components/chengdu/ChengduBottomActions.vue new file mode 100644 index 0000000..5513798 --- /dev/null +++ b/src/components/chengdu/ChengduBottomActions.vue @@ -0,0 +1,123 @@ + + + diff --git a/src/components/chengdu/ChengduDeskZones.vue b/src/components/chengdu/ChengduDeskZones.vue new file mode 100644 index 0000000..797337a --- /dev/null +++ b/src/components/chengdu/ChengduDeskZones.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/components/chengdu/ChengduSettlementOverlay.vue b/src/components/chengdu/ChengduSettlementOverlay.vue new file mode 100644 index 0000000..8ecb016 --- /dev/null +++ b/src/components/chengdu/ChengduSettlementOverlay.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/components/chengdu/ChengduTableHeader.vue b/src/components/chengdu/ChengduTableHeader.vue new file mode 100644 index 0000000..def66ab --- /dev/null +++ b/src/components/chengdu/ChengduTableHeader.vue @@ -0,0 +1,112 @@ + + + diff --git a/src/components/chengdu/ChengduWallSeats.vue b/src/components/chengdu/ChengduWallSeats.vue new file mode 100644 index 0000000..a0dc92f --- /dev/null +++ b/src/components/chengdu/ChengduWallSeats.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/game/chengdu/messageNormalizers.ts b/src/game/chengdu/messageNormalizers.ts new file mode 100644 index 0000000..7bdabdb --- /dev/null +++ b/src/game/chengdu/messageNormalizers.ts @@ -0,0 +1,283 @@ +import type { + ClaimOptionState, + MeldState, + PendingClaimState, + PlayerState, + Tile, +} from '../../types/state' + +export function normalizeWsType(type: string): string { + return type.replace(/[-\s]/g, '_').toUpperCase() +} + +export function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null +} + +export function readString(source: Record, ...keys: string[]): string { + for (const key of keys) { + const value = source[key] + if (typeof value === 'string' && value.trim()) { + return value + } + } + return '' +} + +export function readNumber(source: Record, ...keys: string[]): number | null { + for (const key of keys) { + const value = source[key] + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + } + return null +} + +export function normalizeTimestampMs(value: number | null): number | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return null + } + return value >= 1_000_000_000_000 ? value : value * 1000 +} + +export function readStringArray(source: Record, ...keys: string[]): string[] { + for (const key of keys) { + const value = source[key] + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string') + } + } + return [] +} + +export function readBoolean(source: Record, ...keys: string[]): boolean | null { + for (const key of keys) { + const value = source[key] + if (typeof value === 'boolean') { + return value + } + if (typeof value === 'number') { + if (value === 1) { + return true + } + if (value === 0) { + return false + } + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'true' || normalized === '1') { + return true + } + if (normalized === 'false' || normalized === '0') { + return false + } + } + } + return null +} + +export function readMissingSuit(source: Record | null | undefined): string | null { + if (!source) { + return null + } + return readString(source, 'missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit') || null +} + +export function readMissingSuitWithPresence( + source: Record | null | undefined, +): { present: boolean; value: string | null } { + if (!source) { + return { present: false, value: null } + } + + const keys = ['missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit'] + const hasMissingSuitField = keys.some((key) => Object.prototype.hasOwnProperty.call(source, key)) + if (!hasMissingSuitField) { + return { present: false, value: null } + } + + return { present: true, value: readMissingSuit(source) } +} + +export function tileToText(tile: Tile): string { + return `${tile.suit}${tile.value}` +} + +export function readPlayerTurnPlayerId(payload: Record): string { + return ( + (typeof payload.player_id === 'string' && payload.player_id) || + (typeof payload.playerId === 'string' && payload.playerId) || + (typeof payload.PlayerID === 'string' && payload.PlayerID) || + '' + ) +} + +export function readPlayerTurnAllowActions(payload: Record): string[] { + const source = + payload.allow_actions ?? + payload.allowActions ?? + payload.AllowActions ?? + payload.available_actions ?? + payload.availableActions ?? + payload.AvailableActions + if (!Array.isArray(source)) { + return [] + } + + const actions = source + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim().toLowerCase()) + .filter((item) => item.length > 0) + return Array.from(new Set(actions)) +} + +export function normalizeTile(tile: unknown): Tile | null { + const source = asRecord(tile) + if (!source) { + return null + } + + const id = readNumber(source, 'id') + const suit = readString(source, 'suit') as Tile['suit'] | '' + const value = readNumber(source, 'value') + if (typeof id !== 'number' || !suit || typeof value !== 'number') { + return null + } + + if (suit !== 'W' && suit !== 'T' && suit !== 'B') { + return null + } + + return { id, suit, value } +} + +export function normalizeTiles(value: unknown): Tile[] { + if (!Array.isArray(value)) { + return [] + } + + return value.map((item) => normalizeTile(item)).filter((item): item is Tile => Boolean(item)) +} + +export function normalizePendingClaim( + gameState: Record | null | undefined, + loggedInUserId: string, +): PendingClaimState | undefined { + if (!gameState || !loggedInUserId) { + return undefined + } + + const pendingClaim = asRecord(gameState.pending_claim ?? gameState.pendingClaim) + if (!pendingClaim) { + return undefined + } + + const selfOptions = asRecord(pendingClaim[loggedInUserId]) + if (!selfOptions) { + return undefined + } + + const options: ClaimOptionState[] = [] + if (readBoolean(selfOptions, 'hu')) { + options.push('hu') + } + if (readBoolean(selfOptions, 'gang')) { + options.push('gang') + } + if (readBoolean(selfOptions, 'peng')) { + options.push('peng') + } + if (options.length === 0) { + return undefined + } + + options.push('pass') + + return { + tile: normalizeTile(gameState.last_discard_tile ?? gameState.lastDiscardTile) ?? undefined, + fromPlayerId: readString(gameState, 'last_discard_by', 'lastDiscardBy') || undefined, + options, + } +} + +export function normalizeMeldType(value: unknown, concealed = false): MeldState['type'] | null { + if (typeof value !== 'string') { + return concealed ? 'an_gang' : null + } + + const normalized = value.replace(/[-\s]/g, '_').toLowerCase() + if (normalized === 'peng') { + return 'peng' + } + if (normalized === 'ming_gang' || normalized === 'gang' || normalized === 'gang_open') { + return concealed ? 'an_gang' : 'ming_gang' + } + if (normalized === 'an_gang' || normalized === 'angang' || normalized === 'concealed_gang') { + return 'an_gang' + } + + return concealed ? 'an_gang' : null +} + +export function normalizeMelds(value: unknown): PlayerState['melds'] { + if (!Array.isArray(value)) { + return [] + } + + return value + .map((item) => { + if (Array.isArray(item)) { + const tiles = normalizeTiles(item) + if (tiles.length === 3) { + return { type: 'peng', tiles, fromPlayerId: '' } satisfies MeldState + } + if (tiles.length === 4) { + return { type: 'ming_gang', tiles, fromPlayerId: '' } satisfies MeldState + } + return null + } + + const source = asRecord(item) + if (!source) { + return null + } + + const tiles = normalizeTiles( + source.tiles ?? + source.meld_tiles ?? + source.meldTiles ?? + source.cards ?? + source.card_list, + ) + if (tiles.length === 0) { + return null + } + + const concealed = + readBoolean(source, 'concealed', 'is_concealed', 'isConcealed', 'hidden', 'is_hidden') ?? false + const explicitType = normalizeMeldType( + source.type ?? source.meld_type ?? source.meldType ?? source.kind, + concealed, + ) + const type = + explicitType ?? + (tiles.length === 4 ? (concealed ? 'an_gang' : 'ming_gang') : tiles.length === 3 ? 'peng' : null) + + if (type === 'peng' || type === 'ming_gang') { + return { + type, + tiles, + fromPlayerId: readString(source, 'from_player_id', 'fromPlayerId'), + } satisfies MeldState + } + + if (type === 'an_gang') { + return { type, tiles } satisfies MeldState + } + + return null + }) + .filter((item): item is MeldState => Boolean(item)) +} diff --git a/src/store/index.ts b/src/store/index.ts index 1d4730c..677623d 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,13 +1,13 @@ import { ref } from 'vue' import type { - ActiveRoomState, - ActiveRoomSelectionInput, + RoomMetaSnapshotInput, + RoomMetaSnapshotState, } from './state' -import { clearActiveRoomSnapshot, readActiveRoomSnapshot, saveActiveRoom } from './storage' +import { clearRoomMetaSnapshot, readRoomMetaSnapshot, saveRoomMetaSnapshot } from './storage' -const activeRoom = ref(readActiveRoomSnapshot()) +const roomMetaSnapshot = ref(readRoomMetaSnapshot()) -function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState { +function normalizeRoom(input: RoomMetaSnapshotInput): RoomMetaSnapshotState { return { roomId: input.roomId, roomName: input.roomName ?? '', @@ -32,19 +32,17 @@ function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState { } } -// 设置当前房间 -export function setActiveRoom(input: ActiveRoomSelectionInput) { +export function setRoomMetaSnapshot(input: RoomMetaSnapshotInput) { const next = normalizeRoom(input) - activeRoom.value = next - saveActiveRoom(next) + roomMetaSnapshot.value = next + saveRoomMetaSnapshot(next) } -export function clearActiveRoom() { - activeRoom.value = null - clearActiveRoomSnapshot() +export function clearRoomMetaSnapshotState() { + roomMetaSnapshot.value = null + clearRoomMetaSnapshot() } -// 使用房间状态 -export function useActiveRoomState() { - return activeRoom +export function useRoomMetaSnapshotState() { + return roomMetaSnapshot } diff --git a/src/store/state.ts b/src/store/state.ts index 9a2dfdf..24b0383 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -1,5 +1,5 @@ // 房间玩家状态 -export interface RoomPlayerState { +export interface RoomMetaPlayerState { index: number playerId: string displayName?: string @@ -13,7 +13,7 @@ export interface RoomPlayerState { } // 房间整体状态 -export interface ActiveRoomState { +export interface RoomMetaSnapshotState { roomId: string roomName: string gameType: string @@ -23,7 +23,7 @@ export interface ActiveRoomState { status: string createdAt: string updatedAt: string - players: RoomPlayerState[] + players: RoomMetaPlayerState[] myHand: string[] game?: { state?: { @@ -36,7 +36,7 @@ export interface ActiveRoomState { } } -export interface ActiveRoomSelectionInput { +export interface RoomMetaSnapshotInput { roomId: string roomName?: string gameType?: string @@ -46,7 +46,7 @@ export interface ActiveRoomSelectionInput { status?: string createdAt?: string updatedAt?: string - players?: RoomPlayerState[] + players?: RoomMetaPlayerState[] myHand?: string[] - game?: ActiveRoomState['game'] + game?: RoomMetaSnapshotState['game'] } diff --git a/src/store/storage.ts b/src/store/storage.ts index c0f8f2c..fbf0270 100644 --- a/src/store/storage.ts +++ b/src/store/storage.ts @@ -1,9 +1,9 @@ -import type { ActiveRoomState } from './state' +import type { RoomMetaSnapshotState } from './state' const KEY = 'mahjong_active_room' // 读取缓存 -export function readActiveRoomSnapshot(): ActiveRoomState | null { +export function readRoomMetaSnapshot(): RoomMetaSnapshotState | null { const raw = localStorage.getItem(KEY) if (!raw) return null @@ -15,11 +15,11 @@ export function readActiveRoomSnapshot(): ActiveRoomState | null { } // 写入缓存 -export function saveActiveRoom(state: ActiveRoomState) { +export function saveRoomMetaSnapshot(state: RoomMetaSnapshotState) { localStorage.setItem(KEY, JSON.stringify(state)) } // 清除缓存 -export function clearActiveRoomSnapshot() { +export function clearRoomMetaSnapshot() { localStorage.removeItem(KEY) } diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 83a4251..2b13222 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -1,6 +1,6 @@ -