feat(game): 添加房间管理和游戏启动功能

- 添加 setActiveRoom 导入和房间状态管理功能
- 实现房间所有者判断逻辑和玩家准备状态检查
- 添加游戏启动按钮和相关权限控制
- 实现房间信息请求和响应处理机制
- 添加 WebSocket 消息规范化处理函数
- 集成 tile 数据标准化和验证逻辑
- 更新 CSS 样式以支持新的界面元素
- 修复 Vite 配置以支持外部访问
- 优化 UI 组件布局和交互反馈机制
This commit is contained in:
2026-03-26 23:37:00 +08:00
parent 0fa3c4f1df
commit 921f47d916
5 changed files with 525 additions and 20 deletions

View File

@@ -592,9 +592,6 @@
} }
.ready-toggle { .ready-toggle {
position: absolute;
right: 120px;
bottom: 70px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -615,6 +612,10 @@
animation: ready-toggle-pop 180ms ease-out; animation: ready-toggle-pop 180ms ease-out;
} }
.ready-toggle-inline {
position: static;
}
.ready-toggle-label { .ready-toggle-label {
color: #e5c472; color: #e5c472;
font-size: 14px; font-size: 14px;
@@ -631,6 +632,15 @@
transform: translateY(1px) scale(0.96); 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 { @keyframes ready-toggle-pop {
from { from {
opacity: 0; opacity: 0;
@@ -762,6 +772,38 @@
font-size: 18px; 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 { .bottom-control-panel {
position: absolute; position: absolute;
left: 50%; left: 50%;
@@ -771,6 +813,12 @@
padding: 8px 14px 12px; padding: 8px 14px 12px;
} }
.bottom-action-bar {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.control-copy { .control-copy {
margin-bottom: 10px; margin-bottom: 10px;
text-align: center; text-align: center;
@@ -1085,10 +1133,21 @@
width: calc(100% - 40px); width: calc(100% - 40px);
} }
.waiting-owner-tip {
top: 23%;
min-width: 0;
width: calc(100% - 60px);
font-size: 16px;
}
.bottom-control-panel { .bottom-control-panel {
width: calc(100% - 20px); width: calc(100% - 20px);
} }
.bottom-action-bar {
justify-content: center;
}
.action-orbs { .action-orbs {
position: static; position: static;
justify-content: center; justify-content: center;

View File

@@ -19,8 +19,8 @@ function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState {
createdAt: input.createdAt ?? '', createdAt: input.createdAt ?? '',
updatedAt: input.updatedAt ?? '', updatedAt: input.updatedAt ?? '',
players: input.players ?? [], players: input.players ?? [],
myHand: [], myHand: input.myHand ?? [],
game: { game: input.game ?? {
state: { state: {
wall: [], wall: [],
scores: {}, scores: {},
@@ -42,4 +42,4 @@ export function setActiveRoom(input: ActiveRoomSelectionInput) {
// 使用房间状态 // 使用房间状态
export function useActiveRoomState() { export function useActiveRoomState() {
return activeRoom return activeRoom
} }

View File

@@ -46,4 +46,6 @@ export interface ActiveRoomSelectionInput {
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
players?: RoomPlayerState[] players?: RoomPlayerState[]
} myHand?: string[]
game?: ActiveRoomState['game']
}

View File

@@ -34,7 +34,7 @@ import {wsClient} from '../ws/client'
import {sendWsMessage} from '../ws/sender' import {sendWsMessage} from '../ws/sender'
import {buildWsUrl} from '../ws/url' import {buildWsUrl} from '../ws/url'
import {useGameStore} from '../store/gameStore' import {useGameStore} from '../store/gameStore'
import {useActiveRoomState} from '../store' import {setActiveRoom, useActiveRoomState} from '../store'
import type {PlayerState} from '../types/state' import type {PlayerState} from '../types/state'
import type {Tile} from '../types/tile' import type {Tile} from '../types/tile'
@@ -65,8 +65,10 @@ const wsError = ref('')
const selectedTile = ref<string | null>(null) const selectedTile = ref<string | null>(null)
const leaveRoomPending = ref(false) const leaveRoomPending = ref(false)
const readyTogglePending = ref(false) const readyTogglePending = ref(false)
const startGamePending = ref(false)
let clockTimer: number | null = null let clockTimer: number | null = null
let unsubscribe: (() => void) | null = null let unsubscribe: (() => void) | null = null
let pendingRoomInfoRequest = false
const menuOpen = ref(false) const menuOpen = ref(false)
const isTrustMode = ref(false) const isTrustMode = ref(false)
@@ -238,8 +240,46 @@ const myReadyState = computed(() => {
return Boolean(myPlayer.value?.isReady) 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(() => { 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 { 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 networkLabel = computed(() => {
const map: Record<WsStatus, string> = { const map: Record<WsStatus, string> = {
connected: '已连接', connected: '已连接',
@@ -450,7 +854,7 @@ function toGameAction(message: unknown): GameAction | null {
return null return null
} }
const type = source.type.replace(/[-\s]/g, '_').toUpperCase() const type = normalizeWsType(source.type)
const payload = source.payload const payload = source.payload
switch (type) { switch (type) {
@@ -509,7 +913,7 @@ function handleReadyStateResponse(message: unknown): void {
return return
} }
const type = source.type.replace(/[-\s]/g, '_').toUpperCase() const type = normalizeWsType(source.type)
if (type !== 'SET_READY') { if (type !== 'SET_READY') {
return 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 { function handleLeaveRoom(): void {
menuOpen.value = false menuOpen.value = false
backHall() backHall()
@@ -824,24 +1243,33 @@ function hydrateFromActiveRoom(routeRoomId: string): void {
onMounted(() => { onMounted(() => {
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : '' const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
pendingRoomInfoRequest = true
void ensureCurrentUserLoaded().finally(() => { void ensureCurrentUserLoaded().finally(() => {
hydrateFromActiveRoom(routeRoomId) hydrateFromActiveRoom(routeRoomId)
if (routeRoomId) { if (routeRoomId) {
gameStore.roomId = routeRoomId gameStore.roomId = routeRoomId
} }
requestRoomInfo()
}) })
const handler = (status: WsStatus) => { const handler = (status: WsStatus) => {
wsStatus.value = status wsStatus.value = status
if (status === 'connected' && pendingRoomInfoRequest) {
requestRoomInfo()
}
} }
wsClient.onMessage((msg: unknown) => { wsClient.onMessage((msg: unknown) => {
const text = typeof msg === 'string' ? msg : JSON.stringify(msg) const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
wsMessages.value.push(`[server] ${text}`) wsMessages.value.push(`[server] ${text}`)
handleRoomInfoResponse(msg)
handleReadyStateResponse(msg) handleReadyStateResponse(msg)
const gameAction = toGameAction(msg) const gameAction = toGameAction(msg)
if (gameAction) { if (gameAction) {
dispatchGameAction(gameAction) dispatchGameAction(gameAction)
if (gameAction.type === 'GAME_START') {
startGamePending.value = false
}
if (gameAction.type === 'ROOM_PLAYER_UPDATE') { if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
syncReadyStatesFromRoomUpdate(gameAction.payload) syncReadyStatesFromRoomUpdate(gameAction.payload)
readyTogglePending.value = false readyTogglePending.value = false
@@ -1000,18 +1428,33 @@ onBeforeUnmount(() => {
<WindSquare class="center-wind-square" :seat-winds="seatWinds"/> <WindSquare class="center-wind-square" :seat-winds="seatWinds"/>
<button <div v-if="showWaitingOwnerTip" class="waiting-owner-tip">
v-if="showReadyToggle" <span>等待房主开始游戏</span>
class="ready-toggle" </div>
type="button"
:disabled="readyTogglePending"
@click="toggleReadyState"
>
<span class="ready-toggle-label">{{ myReadyState ? '取 消' : '准 备' }}</span>
</button>
<div class="bottom-control-panel"> <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"> <div class="player-hand" v-if="myHandTiles.length > 0">
<button <button

View File

@@ -9,6 +9,7 @@ export default defineConfig(({ mode }) => {
return { return {
plugins: [vue()], plugins: [vue()],
server: { server: {
host: '0.0.0.0',
proxy: { proxy: {
'/ws': { '/ws': {
target: wsProxyTarget, target: wsProxyTarget,