feat(game): 实现游戏房间状态管理和WebSocket连接功能
- 添加路由参数解析和房间状态初始化逻辑 - 实现房间玩家座位视图计算和状态映射 - 集成WebSocket客户端连接管理和重连机制 - 添加房间数据持久化存储功能 - 实现游戏界面状态显示和用户交互控制 - 更新WS代理目标地址配置 - 重构房间状态管理模块分离到独立store
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
VITE_API_BASE_URL=/api/v1
|
VITE_API_BASE_URL=/api/v1
|
||||||
VITE_GAME_WS_URL=/ws
|
VITE_GAME_WS_URL=/ws
|
||||||
VITE_API_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.2.16:19000
|
VITE_WS_PROXY_TARGET=http://192.168.1.5:19000
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
left: 58px;
|
left: 58px;
|
||||||
min-width: 124px;
|
min-width: 128px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
|||||||
45
src/store/index.ts
Normal file
45
src/store/index.ts
Normal 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
49
src/store/state.ts
Normal 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
20
src/store/storage.ts
Normal 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))
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type {Tile} from "../../models";
|
import type { Tile } from '../tile'
|
||||||
|
|
||||||
export type Meld =
|
export type Meld =
|
||||||
| {
|
| {
|
||||||
@@ -14,4 +14,4 @@ export type Meld =
|
|||||||
| {
|
| {
|
||||||
type: 'an_gang'
|
type: 'an_gang'
|
||||||
tiles: Tile[]
|
tiles: Tile[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type {Tile} from "../../models";
|
import type { Tile } from '../tile'
|
||||||
import type {Meld} from "./meld.ts";
|
import type { Meld } from './meld'
|
||||||
|
|
||||||
export interface Player{
|
export interface Player{
|
||||||
playerId: string
|
playerId: string
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<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 deskImage from '../assets/images/desk/desk_01.png'
|
||||||
import wanIcon from '../assets/images/flowerClolor/wan.png'
|
import wanIcon from '../assets/images/flowerClolor/wan.png'
|
||||||
import tongIcon from '../assets/images/flowerClolor/tong.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 RightPlayerCard from '../components/game/RightPlayerCard.vue'
|
||||||
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
|
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
|
||||||
import LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
|
import LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
|
||||||
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
|
import type { SeatPlayerCardModel } from '../components/game/seat-player-card'
|
||||||
import type {WsStatus} from "../ws/client.ts";
|
import type { SeatKey } from '../game/seat'
|
||||||
import {wsClient} from "../ws/client.ts";
|
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 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 clockTimer: number | null = null
|
||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
@@ -29,9 +82,76 @@ const menuTriggerActive = ref(false)
|
|||||||
let menuTriggerTimer: number | null = null
|
let menuTriggerTimer: number | null = null
|
||||||
let menuOpenTimer: 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 networkLabel = computed(() => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<WsStatus, string> = {
|
||||||
connected: '已连接',
|
connected: '已连接',
|
||||||
connecting: '连接中',
|
connecting: '连接中',
|
||||||
error: '连接异常',
|
error: '连接异常',
|
||||||
@@ -52,7 +172,7 @@ const formattedClock = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const wallBacks = computed<Record<SeatKey, string[]>>(() => {
|
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))
|
const perSide = Math.max(6, Math.ceil((wallSize || 48) / 4 / 2))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -64,7 +184,6 @@ const wallBacks = computed<Record<SeatKey, string[]>>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
||||||
const scoreMap = roomState.value.game?.state?.scores ?? {}
|
|
||||||
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
|
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
|
||||||
const defaultMissingSuitLabel = missingSuitLabel(null)
|
const defaultMissingSuitLabel = missingSuitLabel(null)
|
||||||
|
|
||||||
@@ -88,15 +207,12 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerId = seat.player.playerId
|
const displayName = seat.player.displayName || `玩家${seat.player.index + 1}`
|
||||||
const score = scoreMap[playerId]
|
|
||||||
result[seat.key] = {
|
result[seat.key] = {
|
||||||
avatar: seat.isSelf ? '我' : String(index + 1),
|
avatar: seat.isSelf ? '我' : String(index + 1),
|
||||||
name: seat.isSelf ? '你自己' : seat.player.displayName || `玩家${seat.player.index + 1}`,
|
name: seat.isSelf ? '你自己' : displayName,
|
||||||
money: typeof score === 'number' ? String(score) : '--',
|
|
||||||
dealer: seat.player.index === dealerIndex,
|
dealer: seat.player.index === dealerIndex,
|
||||||
isTurn: seat.isTurn,
|
isTurn: seat.isTurn,
|
||||||
isOnline: true,
|
|
||||||
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,8 +220,6 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
|
|
||||||
|
|
||||||
const floatingMissingSuit = computed(() => {
|
const floatingMissingSuit = computed(() => {
|
||||||
const suitMap: Record<string, string> = {
|
const suitMap: Record<string, string> = {
|
||||||
万: wanIcon,
|
万: wanIcon,
|
||||||
@@ -113,10 +227,14 @@ const floatingMissingSuit = computed(() => {
|
|||||||
条: tiaoIcon,
|
条: tiaoIcon,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const topLabel = seatDecor.value.top?.missingSuitLabel ?? ''
|
||||||
|
const leftLabel = seatDecor.value.left?.missingSuitLabel ?? ''
|
||||||
|
const rightLabel = seatDecor.value.right?.missingSuitLabel ?? ''
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: suitMap[seatDecor.value.top.missingSuitLabel] ?? '',
|
top: suitMap[topLabel] ?? '',
|
||||||
left: suitMap[seatDecor.value.left.missingSuitLabel] ?? '',
|
left: suitMap[leftLabel] ?? '',
|
||||||
right: suitMap[seatDecor.value.right.missingSuitLabel] ?? '',
|
right: suitMap[rightLabel] ?? '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,6 +245,9 @@ function missingSuitLabel(value: string | null | undefined): string {
|
|||||||
tiao: '条',
|
tiao: '条',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
return suitMap[value] ?? value
|
return suitMap[value] ?? value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +291,40 @@ function toggleTrustMode(): void {
|
|||||||
menuOpen.value = false
|
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 {
|
function handleLeaveRoom(): void {
|
||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
backHall()
|
backHall()
|
||||||
@@ -196,12 +351,35 @@ function handleGlobalEsc(event: KeyboardEvent): void {
|
|||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const handler = (status: any) => {
|
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
||||||
WsStatus.value = status
|
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)
|
unsubscribe = wsClient.onStatusChange(handler)
|
||||||
|
ensureWsConnected()
|
||||||
|
|
||||||
clockTimer = window.setInterval(() => {
|
clockTimer = window.setInterval(() => {
|
||||||
now.value = Date.now()
|
now.value = Date.now()
|
||||||
@@ -212,7 +390,6 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
// 取消 ws 订阅
|
|
||||||
if (unsubscribe) {
|
if (unsubscribe) {
|
||||||
unsubscribe()
|
unsubscribe()
|
||||||
unsubscribe = null
|
unsubscribe = null
|
||||||
@@ -286,7 +463,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="left-counter">
|
<div class="left-counter">
|
||||||
<span class="counter-light"></span>
|
<span class="counter-light"></span>
|
||||||
<strong>{{ roomState.game?.state?.wall.length ?? 48 }}</strong>
|
<strong>{{ roomState.game?.state?.wall?.length ?? 48 }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="isTrustMode" class="trust-chip">托管中</span>
|
<span v-if="isTrustMode" class="trust-chip">托管中</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,7 +533,7 @@ onBeforeUnmount(() => {
|
|||||||
<p class="sidebar-title">WebSocket 消息</p>
|
<p class="sidebar-title">WebSocket 消息</p>
|
||||||
<small>{{ networkLabel }} · {{ loggedInUserName || '未登录昵称' }}</small>
|
<small>{{ networkLabel }} · {{ loggedInUserName || '未登录昵称' }}</small>
|
||||||
</div>
|
</div>
|
||||||
<button class="sidebar-btn" type="button" @click="connectWs">重连</button>
|
<button class="sidebar-btn" type="button" @click="reconnectWs">重连</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-stats">
|
<div class="sidebar-stats">
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { useRouter } from 'vue-router'
|
|||||||
import { AuthExpiredError, type AuthSession } from '../api/authed-request'
|
import { AuthExpiredError, type AuthSession } from '../api/authed-request'
|
||||||
import { createRoom, joinRoom, listRooms, type RoomItem } from '../api/mahjong'
|
import { createRoom, joinRoom, listRooms, type RoomItem } from '../api/mahjong'
|
||||||
import { getUserInfo, type UserInfo } from '../api/user'
|
import { getUserInfo, type UserInfo } from '../api/user'
|
||||||
import { hydrateActiveRoomFromSelection } from '../store/active-room-store'
|
import { setActiveRoom } from '../store'
|
||||||
import type { RoomPlayerState } from '../store/active-room-store'
|
import type { RoomPlayerState } from '../store/state'
|
||||||
import type { StoredAuth } from '../types/session'
|
import type { StoredAuth } from '../types/session'
|
||||||
import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
|
import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
|
||||||
|
import { wsClient } from '../ws/client'
|
||||||
|
import { buildWsUrl } from '../ws/url'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -176,6 +178,14 @@ function currentSession(): AuthSession | null {
|
|||||||
return toSession(auth.value)
|
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> {
|
async function refreshRooms(): Promise<void> {
|
||||||
const session = currentSession()
|
const session = currentSession()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -236,7 +246,7 @@ async function submitCreateRoom(): Promise<void> {
|
|||||||
)
|
)
|
||||||
|
|
||||||
createdRoom.value = room
|
createdRoom.value = room
|
||||||
hydrateActiveRoomFromSelection({
|
setActiveRoom({
|
||||||
roomId: room.room_id,
|
roomId: room.room_id,
|
||||||
roomName: room.name,
|
roomName: room.name,
|
||||||
gameType: room.game_type,
|
gameType: room.game_type,
|
||||||
@@ -282,7 +292,7 @@ async function handleJoinRoom(room?: { roomId?: string; roomName?: string }): Pr
|
|||||||
roomSubmitting.value = true
|
roomSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const joinedRoom = await joinRoom(session, { roomId: targetRoomId }, syncAuth)
|
const joinedRoom = await joinRoom(session, { roomId: targetRoomId }, syncAuth)
|
||||||
hydrateActiveRoomFromSelection({
|
setActiveRoom({
|
||||||
roomId: joinedRoom.room_id,
|
roomId: joinedRoom.room_id,
|
||||||
roomName: joinedRoom.name,
|
roomName: joinedRoom.name,
|
||||||
gameType: joinedRoom.game_type,
|
gameType: joinedRoom.game_type,
|
||||||
@@ -297,6 +307,7 @@ async function handleJoinRoom(room?: { roomId?: string; roomName?: string }): Pr
|
|||||||
quickJoinRoomId.value = joinedRoom.room_id
|
quickJoinRoomId.value = joinedRoom.room_id
|
||||||
successMessage.value = `已加入房间:${joinedRoom.room_id}`
|
successMessage.value = `已加入房间:${joinedRoom.room_id}`
|
||||||
await refreshRooms()
|
await refreshRooms()
|
||||||
|
connectGameWs()
|
||||||
await router.push({
|
await router.push({
|
||||||
path: `/game/chengdu/${joinedRoom.room_id}`,
|
path: `/game/chengdu/${joinedRoom.room_id}`,
|
||||||
query: joinedRoom.name ? { roomName: joinedRoom.name } : undefined,
|
query: joinedRoom.name ? { roomName: joinedRoom.name } : undefined,
|
||||||
@@ -335,7 +346,7 @@ async function enterCreatedRoom(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showCreatedModal.value = false
|
showCreatedModal.value = false
|
||||||
hydrateActiveRoomFromSelection({
|
setActiveRoom({
|
||||||
roomId: createdRoom.value.room_id,
|
roomId: createdRoom.value.room_id,
|
||||||
roomName: createdRoom.value.name,
|
roomName: createdRoom.value.name,
|
||||||
gameType: createdRoom.value.game_type,
|
gameType: createdRoom.value.game_type,
|
||||||
@@ -347,6 +358,7 @@ async function enterCreatedRoom(): Promise<void> {
|
|||||||
updatedAt: createdRoom.value.updated_at,
|
updatedAt: createdRoom.value.updated_at,
|
||||||
players: mapRoomPlayers(createdRoom.value),
|
players: mapRoomPlayers(createdRoom.value),
|
||||||
})
|
})
|
||||||
|
connectGameWs()
|
||||||
await router.push({
|
await router.push({
|
||||||
path: `/game/chengdu/${createdRoom.value.room_id}`,
|
path: `/game/chengdu/${createdRoom.value.room_id}`,
|
||||||
query: { roomName: createdRoom.value.name },
|
query: { roomName: createdRoom.value.name },
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class WsClient {
|
|||||||
|
|
||||||
private reconnectTimer: number | null = null
|
private reconnectTimer: number | null = null
|
||||||
private reconnectDelay = 2000 // 重连间隔(毫秒)
|
private reconnectDelay = 2000 // 重连间隔(毫秒)
|
||||||
|
private manualClosing = false
|
||||||
|
|
||||||
|
|
||||||
// 构造带 token 的 URL
|
// 构造带 token 的 URL
|
||||||
@@ -46,8 +47,8 @@ class WsClient {
|
|||||||
|
|
||||||
// 建立连接
|
// 建立连接
|
||||||
connect(url: string, token?: string) {
|
connect(url: string, token?: string) {
|
||||||
// 已连接则不重复连接
|
// 已连接或连接中则不重复连接
|
||||||
if (this.ws && this.status === 'connected') {
|
if (this.ws && (this.status === 'connected' || this.status === 'connecting')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ class WsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.setStatus('connecting')
|
this.setStatus('connecting')
|
||||||
|
this.manualClosing = false
|
||||||
|
|
||||||
const finalUrl = this.buildUrl()
|
const finalUrl = this.buildUrl()
|
||||||
this.ws = new WebSocket(finalUrl)
|
this.ws = new WebSocket(finalUrl)
|
||||||
@@ -85,7 +87,10 @@ class WsClient {
|
|||||||
// 连接关闭
|
// 连接关闭
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
this.setStatus('closed')
|
this.setStatus('closed')
|
||||||
this.tryReconnect()
|
if (!this.manualClosing) {
|
||||||
|
this.tryReconnect()
|
||||||
|
}
|
||||||
|
this.manualClosing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +105,7 @@ class WsClient {
|
|||||||
|
|
||||||
// 手动关闭
|
// 手动关闭
|
||||||
close() {
|
close() {
|
||||||
|
this.manualClosing = true
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
this.ws.close()
|
this.ws.close()
|
||||||
this.ws = null
|
this.ws = null
|
||||||
@@ -107,6 +113,11 @@ class WsClient {
|
|||||||
this.clearReconnect()
|
this.clearReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reconnect(url: string, token?: string) {
|
||||||
|
this.close()
|
||||||
|
this.connect(url, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 订阅消息
|
// 订阅消息
|
||||||
onMessage(handler: MessageHandler) {
|
onMessage(handler: MessageHandler) {
|
||||||
@@ -159,4 +170,4 @@ class WsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 单例
|
// 单例
|
||||||
export const wsClient = new WsClient()
|
export const wsClient = new WsClient()
|
||||||
|
|||||||
Reference in New Issue
Block a user