feat(game): 添加房间管理和游戏启动功能
- 添加 setActiveRoom 导入和房间状态管理功能 - 实现房间所有者判断逻辑和玩家准备状态检查 - 添加游戏启动按钮和相关权限控制 - 实现房间信息请求和响应处理机制 - 添加 WebSocket 消息规范化处理函数 - 集成 tile 数据标准化和验证逻辑 - 更新 CSS 样式以支持新的界面元素 - 修复 Vite 配置以支持外部访问 - 优化 UI 组件布局和交互反馈机制
This commit is contained in:
@@ -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<string | null>(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<string, unknown> | null {
|
||||
return value && typeof value === 'object' ? value as Record<string, unknown> : null
|
||||
}
|
||||
|
||||
function readString(source: Record<string, unknown>, ...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<string, unknown>, ...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<string, unknown>, ...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<string, unknown>, ...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<string, {
|
||||
roomPlayer: {
|
||||
index: number
|
||||
playerId: string
|
||||
displayName?: string
|
||||
missingSuit?: string | null
|
||||
ready: boolean
|
||||
hand: string[]
|
||||
melds: string[]
|
||||
outTiles: string[]
|
||||
hasHu: boolean
|
||||
}
|
||||
gamePlayer: {
|
||||
playerId: string
|
||||
seatIndex: number
|
||||
displayName?: string
|
||||
avatarURL?: string
|
||||
missingSuit?: string | null
|
||||
isReady: boolean
|
||||
handTiles: Tile[]
|
||||
melds: PlayerState['melds']
|
||||
discardTiles: Tile[]
|
||||
score: number
|
||||
}
|
||||
}>()
|
||||
|
||||
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<string, unknown>)[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<string, typeof gameStore.phase> = {
|
||||
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<string, number>
|
||||
}
|
||||
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<WsStatus, string> = {
|
||||
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(() => {
|
||||
|
||||
<WindSquare class="center-wind-square" :seat-winds="seatWinds"/>
|
||||
|
||||
<button
|
||||
v-if="showReadyToggle"
|
||||
class="ready-toggle"
|
||||
type="button"
|
||||
:disabled="readyTogglePending"
|
||||
@click="toggleReadyState"
|
||||
>
|
||||
<span class="ready-toggle-label">{{ myReadyState ? '取 消' : '准 备' }}</span>
|
||||
</button>
|
||||
<div v-if="showWaitingOwnerTip" class="waiting-owner-tip">
|
||||
<span>等待房主开始游戏</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bottom-control-panel">
|
||||
<div v-if="showReadyToggle || showStartGameButton" class="bottom-action-bar">
|
||||
<button
|
||||
v-if="showReadyToggle"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
type="button"
|
||||
:disabled="readyTogglePending"
|
||||
@click="toggleReadyState"
|
||||
>
|
||||
<span class="ready-toggle-label">{{ myReadyState ? '取 消' : '准 备' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showStartGameButton && isRoomOwner"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
type="button"
|
||||
:disabled="!canStartGame"
|
||||
@click="startGame"
|
||||
>
|
||||
<span class="ready-toggle-label">开始游戏</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="player-hand" v-if="myHandTiles.length > 0">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user