From 921f47d916561729d2c63d3e3668c6bc733a402a Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Thu, 26 Mar 2026 23:37:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=88=BF?= =?UTF-8?q?=E9=97=B4=E7=AE=A1=E7=90=86=E5=92=8C=E6=B8=B8=E6=88=8F=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 setActiveRoom 导入和房间状态管理功能 - 实现房间所有者判断逻辑和玩家准备状态检查 - 添加游戏启动按钮和相关权限控制 - 实现房间信息请求和响应处理机制 - 添加 WebSocket 消息规范化处理函数 - 集成 tile 数据标准化和验证逻辑 - 更新 CSS 样式以支持新的界面元素 - 修复 Vite 配置以支持外部访问 - 优化 UI 组件布局和交互反馈机制 --- src/assets/styles/room.css | 65 ++++- src/store/index.ts | 6 +- src/store/state.ts | 4 +- src/views/ChengduGamePage.vue | 469 +++++++++++++++++++++++++++++++++- vite.config.ts | 1 + 5 files changed, 525 insertions(+), 20 deletions(-) diff --git a/src/assets/styles/room.css b/src/assets/styles/room.css index 5f512f3..15e19b5 100644 --- a/src/assets/styles/room.css +++ b/src/assets/styles/room.css @@ -592,9 +592,6 @@ } .ready-toggle { - position: absolute; - right: 120px; - bottom: 70px; display: inline-flex; align-items: center; justify-content: center; @@ -615,6 +612,10 @@ animation: ready-toggle-pop 180ms ease-out; } +.ready-toggle-inline { + position: static; +} + .ready-toggle-label { color: #e5c472; font-size: 14px; @@ -631,6 +632,15 @@ transform: translateY(1px) scale(0.96); } +.ready-toggle:disabled { + opacity: 0.56; + cursor: not-allowed; + box-shadow: + inset 0 1px 0 rgba(255, 244, 214, 0.06), + inset 0 -1px 0 rgba(0, 0, 0, 0.18), + 0 4px 10px rgba(0, 0, 0, 0.14); +} + @keyframes ready-toggle-pop { from { opacity: 0; @@ -762,6 +772,38 @@ font-size: 18px; } +.waiting-owner-tip { + position: absolute; + top: 25%; + left: 50%; + transform: translate(-50%, -50%); + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 220px; + min-height: 44px; + padding: 0 20px; + border: 1px solid rgba(220, 191, 118, 0.2); + border-radius: 999px; + color: #f2d68f; + font-size: 20px; + font-weight: 800; + letter-spacing: 1px; + text-shadow: + -1px 0 rgba(0, 0, 0, 0.38), + 0 1px rgba(0, 0, 0, 0.38), + 1px 0 rgba(0, 0, 0, 0.38), + 0 -1px rgba(0, 0, 0, 0.38); + background: + linear-gradient(180deg, rgba(14, 55, 40, 0.78), rgba(8, 36, 27, 0.82)), + radial-gradient(circle at 20% 24%, rgba(237, 214, 157, 0.08), transparent 34%); + box-shadow: + inset 0 1px 0 rgba(255, 244, 214, 0.08), + 0 8px 18px rgba(0, 0, 0, 0.16); + z-index: 4; + pointer-events: none; +} + .bottom-control-panel { position: absolute; left: 50%; @@ -771,6 +813,12 @@ padding: 8px 14px 12px; } +.bottom-action-bar { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + .control-copy { margin-bottom: 10px; text-align: center; @@ -1085,10 +1133,21 @@ width: calc(100% - 40px); } + .waiting-owner-tip { + top: 23%; + min-width: 0; + width: calc(100% - 60px); + font-size: 16px; + } + .bottom-control-panel { width: calc(100% - 20px); } + .bottom-action-bar { + justify-content: center; + } + .action-orbs { position: static; justify-content: center; diff --git a/src/store/index.ts b/src/store/index.ts index ddd02b7..0056eb1 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -19,8 +19,8 @@ function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState { createdAt: input.createdAt ?? '', updatedAt: input.updatedAt ?? '', players: input.players ?? [], - myHand: [], - game: { + myHand: input.myHand ?? [], + game: input.game ?? { state: { wall: [], scores: {}, @@ -42,4 +42,4 @@ export function setActiveRoom(input: ActiveRoomSelectionInput) { // 使用房间状态 export function useActiveRoomState() { return activeRoom -} \ No newline at end of file +} diff --git a/src/store/state.ts b/src/store/state.ts index e1d66ff..22fa278 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -46,4 +46,6 @@ export interface ActiveRoomSelectionInput { createdAt?: string updatedAt?: string players?: RoomPlayerState[] -} \ No newline at end of file + myHand?: string[] + game?: ActiveRoomState['game'] +} diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 9c07271..9a2cec7 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -34,7 +34,7 @@ import {wsClient} from '../ws/client' import {sendWsMessage} from '../ws/sender' import {buildWsUrl} from '../ws/url' import {useGameStore} from '../store/gameStore' -import {useActiveRoomState} from '../store' +import {setActiveRoom, useActiveRoomState} from '../store' import type {PlayerState} from '../types/state' import type {Tile} from '../types/tile' @@ -65,8 +65,10 @@ const wsError = ref('') const selectedTile = ref(null) const leaveRoomPending = ref(false) const readyTogglePending = ref(false) +const startGamePending = ref(false) let clockTimer: number | null = null let unsubscribe: (() => void) | null = null +let pendingRoomInfoRequest = false const menuOpen = ref(false) const isTrustMode = ref(false) @@ -238,8 +240,46 @@ const myReadyState = computed(() => { return Boolean(myPlayer.value?.isReady) }) +const isRoomOwner = computed(() => { + const room = activeRoom.value + return Boolean( + room && + room.roomId === gameStore.roomId && + room.ownerId && + loggedInUserId.value && + room.ownerId === loggedInUserId.value, + ) +}) + +const allPlayersReady = computed(() => { + return ( + gamePlayers.value.length === 4 && + gamePlayers.value.every((player) => Boolean(player.isReady)) + ) +}) + +const showStartGameButton = computed(() => { + return gameStore.phase === 'waiting' && allPlayersReady.value +}) + +const showWaitingOwnerTip = computed(() => { + return showStartGameButton.value && !isRoomOwner.value +}) + +const canStartGame = computed(() => { + return showStartGameButton.value && isRoomOwner.value && !startGamePending.value +}) + const showReadyToggle = computed(() => { - return gameStore.phase === 'waiting' && Boolean(gameStore.roomId) + if (gameStore.phase !== 'waiting' || !gameStore.roomId) { + return false + } + + if (showStartGameButton.value) { + return !isRoomOwner.value + } + + return true }) function applyPlayerReadyState(playerId: string, ready: boolean): void { @@ -284,6 +324,370 @@ function syncReadyStatesFromRoomUpdate(payload: RoomPlayerUpdatePayload): void { } } +function normalizeWsType(type: string): string { + return type.replace(/[-\s]/g, '_').toUpperCase() +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? value as Record : null +} + +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 '' +} + +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 +} + +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 [] +} + +function readBoolean(source: Record, ...keys: string[]): boolean | null { + for (const key of keys) { + const value = source[key] + if (typeof value === 'boolean') { + return value + } + } + return null +} + +function tileToText(tile: Tile): string { + return `${tile.suit}${tile.value}` +} + +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, + } +} + +function normalizeTiles(value: unknown): Tile[] { + if (!Array.isArray(value)) { + return [] + } + + return value + .map((item) => normalizeTile(item)) + .filter((item): item is Tile => Boolean(item)) +} + +function requestRoomInfo(): void { + const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : '' + const roomId = routeRoomId || gameStore.roomId || activeRoom.value?.roomId || '' + if (!roomId) { + pendingRoomInfoRequest = true + wsMessages.value.push('[client] get_room_info pending: missing roomId') + return + } + + if (wsStatus.value !== 'connected') { + pendingRoomInfoRequest = true + wsMessages.value.push(`[client] get_room_info pending: ws=${wsStatus.value}`) + return + } + + pendingRoomInfoRequest = false + wsMessages.value.push(`[client] get_room_info ${roomId}`) + sendWsMessage({ + type: 'get_room_info', + roomId, + payload: { + room_id: roomId, + }, + }) +} + +function handleRoomInfoResponse(message: unknown): void { + const source = asRecord(message) + if (!source || typeof source.type !== 'string') { + return + } + + const normalizedType = normalizeWsType(source.type) + if (normalizedType !== 'GET_ROOM_INFO' && normalizedType !== 'ROOM_INFO') { + return + } + + const payload = asRecord(source.payload) ?? source + const summary = asRecord(payload.summary) ?? asRecord(payload.room_summary) ?? null + const publicState = asRecord(payload.public) ?? asRecord(payload.public_state) ?? null + const privateState = asRecord(payload.private) ?? asRecord(payload.private_state) ?? null + const roomId = + readString(summary ?? {}, 'room_id', 'roomId') || + readString(publicState ?? {}, 'room_id', 'roomId') || + readString(privateState ?? {}, 'room_id', 'roomId') || + readString(payload, 'room_id', 'roomId') || + readString(source, 'roomId') + if (!roomId) { + return + } + + const summaryPlayers = Array.isArray(summary?.players) ? summary.players : [] + const publicPlayers = Array.isArray(publicState?.players) ? publicState.players : [] + const playerMap = new Map() + + summaryPlayers.forEach((item, fallbackIndex) => { + const player = asRecord(item) + if (!player) { + return + } + + const playerId = readString(player, 'player_id', 'PlayerID', 'id', 'user_id') + if (!playerId) { + return + } + + const seatIndex = readNumber(player, 'index', 'Index', 'seat_index', 'seatIndex') ?? fallbackIndex + const displayName = + readString(player, 'player_name', 'PlayerName', 'display_name', 'displayName', 'nickname', 'username') || + (playerId === loggedInUserId.value ? loggedInUserName.value : '') + const ready = readBoolean(player, 'ready', 'Ready') ?? false + const missingSuit = readString(player, 'missing_suit', 'MissingSuit') || null + + playerMap.set(playerId, { + roomPlayer: { + index: seatIndex, + playerId, + displayName: displayName || undefined, + missingSuit, + ready, + hand: [], + melds: [], + outTiles: [], + hasHu: false, + }, + gamePlayer: { + playerId, + seatIndex, + displayName: displayName || undefined, + avatarURL: readString(player, 'avatar_url', 'AvatarUrl', 'avatar', 'avatarUrl') || undefined, + missingSuit, + isReady: ready, + handTiles: [], + melds: [], + discardTiles: [], + score: 0, + }, + }) + }) + + publicPlayers.forEach((item, fallbackIndex) => { + const player = asRecord(item) + if (!player) { + return + } + + const playerId = readString(player, 'player_id', 'PlayerID') + if (!playerId) { + return + } + + const existing = playerMap.get(playerId) + const seatIndex = + existing?.gamePlayer.seatIndex ?? + readNumber(player, 'index', 'Index', 'seat_index', 'seatIndex') ?? + fallbackIndex + const displayName = existing?.gamePlayer.displayName || (playerId === loggedInUserId.value ? loggedInUserName.value : '') + const missingSuit = readString(player, 'missing_suit', 'MissingSuit') || existing?.gamePlayer.missingSuit || null + const handCount = readNumber(player, 'hand_count', 'handCount') ?? 0 + const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles) + + playerMap.set(playerId, { + roomPlayer: { + index: seatIndex, + playerId, + displayName: displayName || undefined, + missingSuit, + ready: existing?.roomPlayer.ready ?? false, + hand: Array.from({length: handCount}, () => ''), + melds: [], + outTiles: outTiles.map((tile) => tileToText(tile)), + hasHu: Boolean(player.has_hu ?? player.hasHu), + }, + gamePlayer: { + playerId, + seatIndex, + displayName: displayName || undefined, + avatarURL: existing?.gamePlayer.avatarURL, + missingSuit, + isReady: existing?.gamePlayer.isReady ?? false, + handTiles: existing?.gamePlayer.handTiles ?? [], + melds: existing?.gamePlayer.melds ?? [], + discardTiles: outTiles, + score: existing?.gamePlayer.score ?? 0, + }, + }) + }) + + const privateHand = normalizeTiles(privateState?.hand) + if (loggedInUserId.value && playerMap.has(loggedInUserId.value)) { + const current = playerMap.get(loggedInUserId.value) + if (current) { + current.roomPlayer.hand = privateHand.map((tile) => tileToText(tile)) + current.gamePlayer.handTiles = privateHand + } + } + + const players = Array.from(playerMap.values()).sort((a, b) => a.gamePlayer.seatIndex - b.gamePlayer.seatIndex) + + const previousPlayers = gameStore.players + const nextPlayers: typeof gameStore.players = {} + players.forEach(({gamePlayer}) => { + const previous = previousPlayers[gamePlayer.playerId] + const score = (publicState?.scores && typeof publicState.scores === 'object' + ? (publicState.scores as Record)[gamePlayer.playerId] + : undefined) + nextPlayers[gamePlayer.playerId] = { + playerId: gamePlayer.playerId, + seatIndex: gamePlayer.seatIndex, + displayName: gamePlayer.displayName ?? previous?.displayName, + avatarURL: gamePlayer.avatarURL ?? previous?.avatarURL, + missingSuit: gamePlayer.missingSuit ?? previous?.missingSuit, + handTiles: gamePlayer.handTiles.length > 0 ? gamePlayer.handTiles : previous?.handTiles ?? [], + melds: gamePlayer.melds.length > 0 ? gamePlayer.melds : previous?.melds ?? [], + discardTiles: gamePlayer.discardTiles.length > 0 ? gamePlayer.discardTiles : previous?.discardTiles ?? [], + score: typeof score === 'number' ? score : previous?.score ?? gamePlayer.score ?? 0, + isReady: gamePlayer.isReady, + } + }) + + const status = + readString(publicState ?? {}, 'status') || + readString(summary ?? {}, 'status') || + readString(publicState ?? {}, 'phase') || + 'waiting' + const phase = + readString(publicState ?? {}, 'phase') || + readString(summary ?? {}, 'status') || + 'waiting' + const wallCount = readNumber(publicState ?? {}, 'wall_count', 'wallCount') + const dealerIndex = readNumber(publicState ?? {}, 'dealer_index', 'dealerIndex') + const currentTurnSeat = readNumber(publicState ?? {}, 'current_turn', 'currentTurn') + const currentTurnPlayerId = readString(publicState ?? {}, 'current_turn_player', 'currentTurnPlayer') + const currentTurn = + currentTurnSeat ?? + (currentTurnPlayerId && nextPlayers[currentTurnPlayerId] + ? nextPlayers[currentTurnPlayerId].seatIndex + : null) + + gameStore.roomId = roomId + if (Object.keys(nextPlayers).length > 0) { + gameStore.players = nextPlayers + } + + const phaseMap: Record = { + waiting: 'waiting', + dealing: 'dealing', + playing: 'playing', + action: 'action', + settlement: 'settlement', + finished: 'settlement', + } + gameStore.phase = phaseMap[phase] ?? gameStore.phase + if (typeof wallCount === 'number') { + gameStore.remainingTiles = wallCount + } + if (typeof dealerIndex === 'number') { + gameStore.dealerIndex = dealerIndex + } + if (typeof currentTurn === 'number') { + gameStore.currentTurn = currentTurn + } + const scores = asRecord(publicState?.scores) + if (scores) { + gameStore.scores = Object.fromEntries( + Object.entries(scores).filter(([, value]) => typeof value === 'number'), + ) as Record + } + gameStore.winners = readStringArray(publicState ?? {}, 'winners') + + setActiveRoom({ + roomId, + roomName: readString(summary ?? {}, 'name', 'room_name', 'roomName') || activeRoom.value?.roomName || roomName.value, + gameType: readString(summary ?? {}, 'game_type', 'gameType') || activeRoom.value?.gameType || 'chengdu', + ownerId: readString(summary ?? {}, 'owner_id', 'ownerId') || activeRoom.value?.ownerId || '', + maxPlayers: readNumber(summary ?? {}, 'max_players', 'maxPlayers') ?? activeRoom.value?.maxPlayers ?? 4, + playerCount: readNumber(summary ?? {}, 'player_count', 'playerCount') ?? players.length, + status, + createdAt: readString(summary ?? {}, 'created_at', 'createdAt') || activeRoom.value?.createdAt || '', + updatedAt: readString(summary ?? {}, 'updated_at', 'updatedAt') || activeRoom.value?.updatedAt || '', + players: players.map((item) => item.roomPlayer), + myHand: privateHand.map((tile) => tileToText(tile)), + game: { + state: { + wall: Array.from({length: wallCount ?? 0}, (_, index) => `wall-${index}`), + scores: gameStore.scores, + dealerIndex: typeof dealerIndex === 'number' ? dealerIndex : activeRoom.value?.game?.state?.dealerIndex ?? -1, + currentTurn: typeof currentTurn === 'number' ? currentTurn : activeRoom.value?.game?.state?.currentTurn ?? -1, + phase, + }, + }, + }) +} + const networkLabel = computed(() => { const map: Record = { connected: '已连接', @@ -450,7 +854,7 @@ function toGameAction(message: unknown): GameAction | null { return null } - const type = source.type.replace(/[-\s]/g, '_').toUpperCase() + const type = normalizeWsType(source.type) const payload = source.payload switch (type) { @@ -509,7 +913,7 @@ function handleReadyStateResponse(message: unknown): void { return } - const type = source.type.replace(/[-\s]/g, '_').toUpperCase() + const type = normalizeWsType(source.type) if (type !== 'SET_READY') { return } @@ -754,6 +1158,21 @@ function toggleReadyState(): void { }) } +function startGame(): void { + if (!canStartGame.value) { + return + } + + startGamePending.value = true + sendWsMessage({ + type: 'start_game', + roomId: gameStore.roomId, + payload: { + room_id: gameStore.roomId, + }, + }) +} + function handleLeaveRoom(): void { menuOpen.value = false backHall() @@ -824,24 +1243,33 @@ function hydrateFromActiveRoom(routeRoomId: string): void { onMounted(() => { const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : '' + pendingRoomInfoRequest = true void ensureCurrentUserLoaded().finally(() => { hydrateFromActiveRoom(routeRoomId) if (routeRoomId) { gameStore.roomId = routeRoomId } + requestRoomInfo() }) const handler = (status: WsStatus) => { wsStatus.value = status + if (status === 'connected' && pendingRoomInfoRequest) { + requestRoomInfo() + } } wsClient.onMessage((msg: unknown) => { const text = typeof msg === 'string' ? msg : JSON.stringify(msg) wsMessages.value.push(`[server] ${text}`) + handleRoomInfoResponse(msg) handleReadyStateResponse(msg) const gameAction = toGameAction(msg) if (gameAction) { dispatchGameAction(gameAction) + if (gameAction.type === 'GAME_START') { + startGamePending.value = false + } if (gameAction.type === 'ROOM_PLAYER_UPDATE') { syncReadyStatesFromRoomUpdate(gameAction.payload) readyTogglePending.value = false @@ -1000,18 +1428,33 @@ onBeforeUnmount(() => { - +
+ 等待房主开始游戏 +
+
+ + + +