feat(game): 实现游戏房间状态管理和WebSocket连接功能

- 添加路由参数解析和房间状态初始化逻辑
- 实现房间玩家座位视图计算和状态映射
- 集成WebSocket客户端连接管理和重连机制
- 添加房间数据持久化存储功能
- 实现游戏界面状态显示和用户交互控制
- 更新WS代理目标地址配置
- 重构房间状态管理模块分离到独立store
This commit is contained in:
2026-03-25 14:07:52 +08:00
parent 148e21f3b0
commit 4a9b2f2db2
10 changed files with 353 additions and 39 deletions

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import deskImage from '../assets/images/desk/desk_01.png'
import wanIcon from '../assets/images/flowerClolor/wan.png'
import tongIcon from '../assets/images/flowerClolor/tong.png'
@@ -15,11 +16,63 @@ import TopPlayerCard from '../components/game/TopPlayerCard.vue'
import RightPlayerCard from '../components/game/RightPlayerCard.vue'
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
import LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
import type {WsStatus} from "../ws/client.ts";
import {wsClient} from "../ws/client.ts";
import type { SeatPlayerCardModel } from '../components/game/seat-player-card'
import type { SeatKey } from '../game/seat'
import { readStoredAuth } from '../utils/auth-storage'
import type { WsStatus } from '../ws/client'
import { wsClient } from '../ws/client'
import { buildWsUrl } from '../ws/url'
import type {ActiveRoomState, RoomPlayerState} from "../store/state.ts";
import {readActiveRoomSnapshot} from "../store/storage.ts";
interface SeatViewModel {
key: SeatKey
player?: RoomPlayerState
isSelf: boolean
isTurn: boolean
}
const route = useRoute()
const router = useRouter()
const auth = ref(readStoredAuth())
function createFallbackRoomState(): ActiveRoomState {
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
const routeRoomName = typeof route.query.roomName === 'string' ? route.query.roomName : ''
return {
roomId: routeRoomId,
roomName: routeRoomName,
gameType: 'chengdu',
ownerId: '',
maxPlayers: 4,
playerCount: 0,
status: 'waiting',
createdAt: '',
updatedAt: '',
players: [],
myHand: [],
game: {
state: {
wall: [],
scores: {},
dealerIndex: -1,
currentTurn: -1,
phase: 'waiting',
},
},
}
}
const activeRoom = ref<ActiveRoomState>(readActiveRoomSnapshot() ?? createFallbackRoomState())
const now = ref(Date.now())
const wsStatus = ref<WsStatus>('idle')
const wsMessages = ref<string[]>([])
const wsError = ref('')
const selectedTile = ref<string | null>(null)
const leaveRoomPending = ref(false)
let clockTimer: number | null = null
let unsubscribe: (() => void) | null = null
@@ -29,9 +82,76 @@ const menuTriggerActive = ref(false)
let menuTriggerTimer: number | null = null
let menuOpenTimer: number | null = null
const loggedInUserId = computed(() => {
const rawId = auth.value?.user?.id
if (typeof rawId === 'string') {
return rawId
}
if (typeof rawId === 'number') {
return String(rawId)
}
return ''
})
const loggedInUserName = computed(() => {
return auth.value?.user?.nickname || auth.value?.user?.username || ''
})
const roomName = computed(() => {
const queryRoomName = typeof route.query.roomName === 'string' ? route.query.roomName : ''
return queryRoomName || activeRoom.value.roomName || ''
})
const roomState = computed(() => {
return {
...activeRoom.value,
name: activeRoom.value.roomName,
}
})
const seatViews = computed<SeatViewModel[]>(() => {
const players = roomState.value.players ?? []
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
const selfIndex = players.findIndex((player) => player.playerId === loggedInUserId.value)
const currentTurn = roomState.value.game?.state?.currentTurn
return players.slice(0, 4).map((player, index) => {
const relativeIndex = selfIndex >= 0 ? (index - selfIndex + 4) % 4 : index
const seatKey = tableOrder[relativeIndex] ?? 'top'
return {
key: seatKey,
player,
isSelf: player.playerId === loggedInUserId.value,
isTurn: typeof currentTurn === 'number' && player.index === currentTurn,
}
})
})
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
const currentPhaseText = computed(() => {
const phase = roomState.value.game?.state?.phase
const map: Record<string, string> = {
waiting: '等待中',
dealing: '发牌中',
playing: '对局中',
finished: '已结束',
}
return phase ? (map[phase] ?? phase) : '等待中'
})
const roomStatusText = computed(() => {
const map: Record<string, string> = {
waiting: '等待玩家',
playing: '游戏中',
finished: '已结束',
}
const status = roomState.value.status
return map[status] ?? status ?? '--'
})
const networkLabel = computed(() => {
const map: Record<string, string> = {
const map: Record<WsStatus, string> = {
connected: '已连接',
connecting: '连接中',
error: '连接异常',
@@ -52,7 +172,7 @@ const formattedClock = computed(() => {
})
const wallBacks = computed<Record<SeatKey, string[]>>(() => {
const wallSize = roomState.value.game?.state?.wall.length ?? 0
const wallSize = roomState.value.game?.state?.wall?.length ?? 0
const perSide = Math.max(6, Math.ceil((wallSize || 48) / 4 / 2))
return {
@@ -64,7 +184,6 @@ const wallBacks = computed<Record<SeatKey, string[]>>(() => {
})
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
const scoreMap = roomState.value.game?.state?.scores ?? {}
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
const defaultMissingSuitLabel = missingSuitLabel(null)
@@ -88,15 +207,12 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
continue
}
const playerId = seat.player.playerId
const score = scoreMap[playerId]
const displayName = seat.player.displayName || `玩家${seat.player.index + 1}`
result[seat.key] = {
avatar: seat.isSelf ? '我' : String(index + 1),
name: seat.isSelf ? '你自己' : seat.player.displayName || `玩家${seat.player.index + 1}`,
money: typeof score === 'number' ? String(score) : '--',
name: seat.isSelf ? '你自己' : displayName,
dealer: seat.player.index === dealerIndex,
isTurn: seat.isTurn,
isOnline: true,
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
}
}
@@ -104,8 +220,6 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
return result
})
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
const floatingMissingSuit = computed(() => {
const suitMap: Record<string, string> = {
: wanIcon,
@@ -113,10 +227,14 @@ const floatingMissingSuit = computed(() => {
: tiaoIcon,
}
const topLabel = seatDecor.value.top?.missingSuitLabel ?? ''
const leftLabel = seatDecor.value.left?.missingSuitLabel ?? ''
const rightLabel = seatDecor.value.right?.missingSuitLabel ?? ''
return {
top: suitMap[seatDecor.value.top.missingSuitLabel] ?? '',
left: suitMap[seatDecor.value.left.missingSuitLabel] ?? '',
right: suitMap[seatDecor.value.right.missingSuitLabel] ?? '',
top: suitMap[topLabel] ?? '',
left: suitMap[leftLabel] ?? '',
right: suitMap[rightLabel] ?? '',
}
})
@@ -127,6 +245,9 @@ function missingSuitLabel(value: string | null | undefined): string {
tiao: '条',
}
if (!value) {
return ''
}
return suitMap[value] ?? value
}
@@ -170,6 +291,40 @@ function toggleTrustMode(): void {
menuOpen.value = false
}
function selectTile(tile: string): void {
selectedTile.value = selectedTile.value === tile ? null : tile
}
function ensureWsConnected(): void {
const token = auth.value?.token
if (!token) {
wsError.value = '未找到登录凭证,无法建立连接'
return
}
wsError.value = ''
wsClient.connect(buildWsUrl(token), token)
}
function reconnectWs(): void {
const token = auth.value?.token
if (!token) {
wsError.value = '未找到登录凭证,无法建立连接'
return
}
wsError.value = ''
wsClient.reconnect(buildWsUrl(token), token)
}
function backHall(): void {
leaveRoomPending.value = true
wsClient.close()
void router.push('/hall').finally(() => {
leaveRoomPending.value = false
})
}
function handleLeaveRoom(): void {
menuOpen.value = false
backHall()
@@ -196,12 +351,35 @@ function handleGlobalEsc(event: KeyboardEvent): void {
onMounted(() => {
const handler = (status: any) => {
WsStatus.value = status
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
if (routeRoomId && activeRoom.value.roomId !== routeRoomId) {
activeRoom.value = {
...activeRoom.value,
roomId: routeRoomId,
}
}
if (!activeRoom.value.roomName && typeof route.query.roomName === 'string') {
activeRoom.value = {
...activeRoom.value,
roomName: route.query.roomName,
}
}
// 保存取消订阅函数
const handler = (status: WsStatus) => {
wsStatus.value = status
}
wsClient.onMessage((msg: unknown) => {
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
wsMessages.value.push(`[server] ${text}`)
})
wsClient.onError((message: string) => {
wsError.value = message
wsMessages.value.push(`[error] ${message}`)
})
unsubscribe = wsClient.onStatusChange(handler)
ensureWsConnected()
clockTimer = window.setInterval(() => {
now.value = Date.now()
@@ -212,7 +390,6 @@ onMounted(() => {
})
onBeforeUnmount(() => {
// 取消 ws 订阅
if (unsubscribe) {
unsubscribe()
unsubscribe = null
@@ -286,7 +463,7 @@ onBeforeUnmount(() => {
</div>
<div class="left-counter">
<span class="counter-light"></span>
<strong>{{ roomState.game?.state?.wall.length ?? 48 }}</strong>
<strong>{{ roomState.game?.state?.wall?.length ?? 48 }}</strong>
</div>
<span v-if="isTrustMode" class="trust-chip">托管中</span>
</div>
@@ -356,7 +533,7 @@ onBeforeUnmount(() => {
<p class="sidebar-title">WebSocket 消息</p>
<small>{{ networkLabel }} · {{ loggedInUserName || '未登录昵称' }}</small>
</div>
<button class="sidebar-btn" type="button" @click="connectWs">重连</button>
<button class="sidebar-btn" type="button" @click="reconnectWs">重连</button>
</div>
<div class="sidebar-stats">