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,4 +1,4 @@
VITE_API_BASE_URL=/api/v1
VITE_GAME_WS_URL=/ws
VITE_API_PROXY_TARGET=http://192.168.2.16:19000
VITE_WS_PROXY_TARGET=http://192.168.2.16:19000
VITE_API_PROXY_TARGET=http://192.168.1.5:19000
VITE_WS_PROXY_TARGET=http://192.168.1.5:19000

View File

@@ -148,7 +148,7 @@
position: absolute;
top: -4px;
left: 58px;
min-width: 124px;
min-width: 128px;
padding: 8px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.16);

45
src/store/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import { ref } from 'vue'
import type {
ActiveRoomState,
ActiveRoomSelectionInput,
} from './state'
import { readActiveRoomSnapshot, saveActiveRoom } from './storage'
const activeRoom = ref<ActiveRoomState | null>(readActiveRoomSnapshot())
function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState {
return {
roomId: input.roomId,
roomName: input.roomName ?? '',
gameType: input.gameType ?? 'chengdu',
ownerId: input.ownerId ?? '',
maxPlayers: input.maxPlayers ?? 4,
playerCount: input.playerCount ?? input.players?.length ?? 0,
status: input.status ?? 'waiting',
createdAt: input.createdAt ?? '',
updatedAt: input.updatedAt ?? '',
players: input.players ?? [],
myHand: [],
game: {
state: {
wall: [],
scores: {},
dealerIndex: -1,
currentTurn: -1,
phase: 'waiting',
},
},
}
}
// 设置当前房间
export function setActiveRoom(input: ActiveRoomSelectionInput) {
const next = normalizeRoom(input)
activeRoom.value = next
saveActiveRoom(next)
}
// 使用房间状态
export function useActiveRoomState() {
return activeRoom
}

49
src/store/state.ts Normal file
View File

@@ -0,0 +1,49 @@
// 房间玩家状态
export interface RoomPlayerState {
index: number
playerId: string
displayName?: string
missingSuit?: string | null
ready: boolean
hand: string[]
melds: string[]
outTiles: string[]
hasHu: boolean
}
// 房间整体状态
export interface ActiveRoomState {
roomId: string
roomName: string
gameType: string
ownerId: string
maxPlayers: number
playerCount: number
status: string
createdAt: string
updatedAt: string
players: RoomPlayerState[]
myHand: string[]
game?: {
state?: {
wall?: string[]
scores?: Record<string, number>
dealerIndex?: number
currentTurn?: number
phase?: string
}
}
}
export interface ActiveRoomSelectionInput {
roomId: string
roomName?: string
gameType?: string
ownerId?: string
maxPlayers?: number
playerCount?: number
status?: string
createdAt?: string
updatedAt?: string
players?: RoomPlayerState[]
}

20
src/store/storage.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { ActiveRoomState } from './state'
const KEY = 'mahjong_active_room'
// 读取缓存
export function readActiveRoomSnapshot(): ActiveRoomState | null {
const raw = localStorage.getItem(KEY)
if (!raw) return null
try {
return JSON.parse(raw)
} catch {
return null
}
}
// 写入缓存
export function saveActiveRoom(state: ActiveRoomState) {
localStorage.setItem(KEY, JSON.stringify(state))
}

View File

@@ -1,4 +1,4 @@
import type {Tile} from "../../models";
import type { Tile } from '../tile'
export type Meld =
| {

View File

@@ -1,5 +1,5 @@
import type {Tile} from "../../models";
import type {Meld} from "./meld.ts";
import type { Tile } from '../tile'
import type { Meld } from './meld'
export interface Player{
playerId: string

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">

View File

@@ -4,10 +4,12 @@ import { useRouter } from 'vue-router'
import { AuthExpiredError, type AuthSession } from '../api/authed-request'
import { createRoom, joinRoom, listRooms, type RoomItem } from '../api/mahjong'
import { getUserInfo, type UserInfo } from '../api/user'
import { hydrateActiveRoomFromSelection } from '../store/active-room-store'
import type { RoomPlayerState } from '../store/active-room-store'
import { setActiveRoom } from '../store'
import type { RoomPlayerState } from '../store/state'
import type { StoredAuth } from '../types/session'
import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
import { wsClient } from '../ws/client'
import { buildWsUrl } from '../ws/url'
const router = useRouter()
@@ -176,6 +178,14 @@ function currentSession(): AuthSession | null {
return toSession(auth.value)
}
function connectGameWs(): void {
const token = auth.value?.token
if (!token) {
return
}
wsClient.connect(buildWsUrl(token), token)
}
async function refreshRooms(): Promise<void> {
const session = currentSession()
if (!session) {
@@ -236,7 +246,7 @@ async function submitCreateRoom(): Promise<void> {
)
createdRoom.value = room
hydrateActiveRoomFromSelection({
setActiveRoom({
roomId: room.room_id,
roomName: room.name,
gameType: room.game_type,
@@ -282,7 +292,7 @@ async function handleJoinRoom(room?: { roomId?: string; roomName?: string }): Pr
roomSubmitting.value = true
try {
const joinedRoom = await joinRoom(session, { roomId: targetRoomId }, syncAuth)
hydrateActiveRoomFromSelection({
setActiveRoom({
roomId: joinedRoom.room_id,
roomName: joinedRoom.name,
gameType: joinedRoom.game_type,
@@ -297,6 +307,7 @@ async function handleJoinRoom(room?: { roomId?: string; roomName?: string }): Pr
quickJoinRoomId.value = joinedRoom.room_id
successMessage.value = `已加入房间:${joinedRoom.room_id}`
await refreshRooms()
connectGameWs()
await router.push({
path: `/game/chengdu/${joinedRoom.room_id}`,
query: joinedRoom.name ? { roomName: joinedRoom.name } : undefined,
@@ -335,7 +346,7 @@ async function enterCreatedRoom(): Promise<void> {
}
showCreatedModal.value = false
hydrateActiveRoomFromSelection({
setActiveRoom({
roomId: createdRoom.value.room_id,
roomName: createdRoom.value.name,
gameType: createdRoom.value.game_type,
@@ -347,6 +358,7 @@ async function enterCreatedRoom(): Promise<void> {
updatedAt: createdRoom.value.updated_at,
players: mapRoomPlayers(createdRoom.value),
})
connectGameWs()
await router.push({
path: `/game/chengdu/${createdRoom.value.room_id}`,
query: { roomName: createdRoom.value.name },

View File

@@ -31,6 +31,7 @@ class WsClient {
private reconnectTimer: number | null = null
private reconnectDelay = 2000 // 重连间隔(毫秒)
private manualClosing = false
// 构造带 token 的 URL
@@ -46,8 +47,8 @@ class WsClient {
// 建立连接
connect(url: string, token?: string) {
// 已连接则不重复连接
if (this.ws && this.status === 'connected') {
// 已连接或连接中则不重复连接
if (this.ws && (this.status === 'connected' || this.status === 'connecting')) {
return
}
@@ -57,6 +58,7 @@ class WsClient {
}
this.setStatus('connecting')
this.manualClosing = false
const finalUrl = this.buildUrl()
this.ws = new WebSocket(finalUrl)
@@ -85,7 +87,10 @@ class WsClient {
// 连接关闭
this.ws.onclose = () => {
this.setStatus('closed')
this.tryReconnect()
if (!this.manualClosing) {
this.tryReconnect()
}
this.manualClosing = false
}
}
@@ -100,6 +105,7 @@ class WsClient {
// 手动关闭
close() {
this.manualClosing = true
if (this.ws) {
this.ws.close()
this.ws = null
@@ -107,6 +113,11 @@ class WsClient {
this.clearReconnect()
}
reconnect(url: string, token?: string) {
this.close()
this.connect(url, token)
}
// 订阅消息
onMessage(handler: MessageHandler) {