Merge remote-tracking branch 'origin/dev'
# Conflicts: # src/views/ChengduGamePage.vue
This commit is contained in:
@@ -7,6 +7,11 @@ export interface RoomItem {
|
||||
owner_id: string
|
||||
max_players: number
|
||||
player_count: number
|
||||
players?: Array<{
|
||||
index: number
|
||||
player_id: string
|
||||
ready: boolean
|
||||
}>
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -58,8 +63,8 @@ export async function joinRoom(
|
||||
auth: AuthSession,
|
||||
input: { roomId: string },
|
||||
onAuthUpdated?: (next: AuthSession) => void,
|
||||
): Promise<void> {
|
||||
await authedRequest<Record<string, never> | RoomItem>({
|
||||
): Promise<RoomItem> {
|
||||
return authedRequest<RoomItem>({
|
||||
method: 'POST',
|
||||
path: ROOM_JOIN_PATH,
|
||||
auth,
|
||||
|
||||
139
src/state/active-room.ts
Normal file
139
src/state/active-room.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const DEFAULT_MAX_PLAYERS = 4
|
||||
export type RoomStatus = 'waiting' | 'playing' | 'finished'
|
||||
|
||||
export interface RoomPlayerState {
|
||||
index: number
|
||||
playerId: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
export interface RuleState {
|
||||
name: string
|
||||
isBloodFlow: boolean
|
||||
hasHongZhong: boolean
|
||||
}
|
||||
|
||||
export interface GamePlayerState {
|
||||
playerId: string
|
||||
index: number
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
export interface EngineState {
|
||||
phase: string
|
||||
dealerIndex: number
|
||||
currentTurn: number
|
||||
needDraw: boolean
|
||||
players: GamePlayerState[]
|
||||
wall: string[]
|
||||
lastDiscardTile: string | null
|
||||
lastDiscardBy: string
|
||||
pendingClaim: Record<string, unknown> | null
|
||||
winners: string[]
|
||||
scores: Record<string, number>
|
||||
lastDrawPlayerId: string
|
||||
lastDrawFromGang: boolean
|
||||
lastDrawIsLastTile: boolean
|
||||
huWay: string
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
rule: RuleState | null
|
||||
state: EngineState | null
|
||||
}
|
||||
|
||||
export interface RoomState {
|
||||
id: string
|
||||
name: string
|
||||
gameType: string
|
||||
ownerId: string
|
||||
maxPlayers: number
|
||||
playerCount: number
|
||||
status: RoomStatus | string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
game: GameState | null
|
||||
players: RoomPlayerState[]
|
||||
currentTurnIndex: number | null
|
||||
}
|
||||
|
||||
function createInitialRoomState(): RoomState {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
gameType: 'chengdu',
|
||||
ownerId: '',
|
||||
maxPlayers: DEFAULT_MAX_PLAYERS,
|
||||
playerCount: 0,
|
||||
status: 'waiting',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
game: null,
|
||||
players: [],
|
||||
currentTurnIndex: null,
|
||||
}
|
||||
}
|
||||
|
||||
export const activeRoomState = ref<RoomState>(createInitialRoomState())
|
||||
|
||||
export function destroyActiveRoomState(): void {
|
||||
activeRoomState.value = createInitialRoomState()
|
||||
}
|
||||
|
||||
export function resetActiveRoomState(seed?: Partial<RoomState>): void {
|
||||
destroyActiveRoomState()
|
||||
if (!seed) {
|
||||
return
|
||||
}
|
||||
|
||||
activeRoomState.value = {
|
||||
...activeRoomState.value,
|
||||
...seed,
|
||||
players: seed.players ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeActiveRoomState(next: RoomState): void {
|
||||
if (activeRoomState.value.id && next.id && next.id !== activeRoomState.value.id) {
|
||||
return
|
||||
}
|
||||
|
||||
activeRoomState.value = {
|
||||
...activeRoomState.value,
|
||||
...next,
|
||||
game: next.game ?? activeRoomState.value.game,
|
||||
players: next.players.length > 0 ? next.players : activeRoomState.value.players,
|
||||
currentTurnIndex:
|
||||
next.currentTurnIndex !== null ? next.currentTurnIndex : activeRoomState.value.currentTurnIndex,
|
||||
}
|
||||
}
|
||||
|
||||
export function hydrateActiveRoomFromSelection(input: {
|
||||
roomId: string
|
||||
roomName?: string
|
||||
gameType?: string
|
||||
ownerId?: string
|
||||
maxPlayers?: number
|
||||
playerCount?: number
|
||||
status?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
players?: RoomPlayerState[]
|
||||
currentTurnIndex?: number | null
|
||||
}): void {
|
||||
resetActiveRoomState({
|
||||
id: input.roomId,
|
||||
name: input.roomName ?? '',
|
||||
gameType: input.gameType ?? 'chengdu',
|
||||
ownerId: input.ownerId ?? '',
|
||||
maxPlayers: input.maxPlayers ?? DEFAULT_MAX_PLAYERS,
|
||||
playerCount: input.playerCount ?? 0,
|
||||
status: input.status ?? 'waiting',
|
||||
createdAt: input.createdAt ?? '',
|
||||
updatedAt: input.updatedAt ?? '',
|
||||
players: input.players ?? [],
|
||||
currentTurnIndex: input.currentTurnIndex ?? null,
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,19 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import deskImage from '../assets/images/desk/desk_01.png'
|
||||
import { readStoredAuth } from '../utils/auth-storage'
|
||||
import type { AuthSession } from '../api/authed-request'
|
||||
import { getUserInfo } from '../api/user'
|
||||
import {
|
||||
DEFAULT_MAX_PLAYERS,
|
||||
activeRoomState,
|
||||
destroyActiveRoomState,
|
||||
mergeActiveRoomState,
|
||||
resetActiveRoomState,
|
||||
type GameState,
|
||||
type RoomPlayerState,
|
||||
type RoomState,
|
||||
} from '../state/active-room'
|
||||
import { readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -20,35 +33,24 @@ const chosenMissingSuit = ref<'wan' | 'tiao' | 'tong'>('tiao')
|
||||
const overlayState = ref<'missing' | 'action' | 'settlement' | null>('missing')
|
||||
|
||||
let clockTimer: number | undefined
|
||||
const leaveRoomPending = ref(false)
|
||||
const lastLeaveRoomRequestId = ref('')
|
||||
const leaveHallAfterAck = ref(false)
|
||||
|
||||
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? 'ws://127.0.0.1:8080/ws'
|
||||
const DEFAULT_MAX_PLAYERS = 4
|
||||
|
||||
type SeatKey = 'top' | 'right' | 'bottom' | 'left'
|
||||
type TileSuit = 'wan' | 'tong' | 'tiao' | 'honor'
|
||||
type MissingSuit = 'wan' | 'tiao' | 'tong'
|
||||
type TileDirection = 'bottom' | 'top' | 'left' | 'right'
|
||||
|
||||
interface RoomPlayerState {
|
||||
index: number
|
||||
playerId: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
interface RoomState {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
maxPlayers: number
|
||||
status: string
|
||||
players: RoomPlayerState[]
|
||||
currentTurnIndex: number | null
|
||||
}
|
||||
|
||||
interface ActionEventLike {
|
||||
type?: unknown
|
||||
status?: unknown
|
||||
requestId?: unknown
|
||||
request_id?: unknown
|
||||
roomId?: unknown
|
||||
room_id?: unknown
|
||||
payload?: unknown
|
||||
data?: unknown
|
||||
}
|
||||
@@ -98,7 +100,8 @@ const roomName = computed(() => {
|
||||
})
|
||||
|
||||
const currentUserId = computed(() => {
|
||||
const candidate = auth.value?.user?.id
|
||||
const user = auth.value?.user as Record<string, unknown> | undefined
|
||||
const candidate = user?.id ?? user?.userID ?? user?.user_id
|
||||
if (typeof candidate === 'string') {
|
||||
return candidate
|
||||
}
|
||||
@@ -116,20 +119,12 @@ const loggedInUserName = computed(() => {
|
||||
return auth.value.user.nickname ?? auth.value.user.username ?? ''
|
||||
})
|
||||
|
||||
const roomState = ref<RoomState>({
|
||||
id: roomId.value,
|
||||
name: roomName.value,
|
||||
ownerId: '',
|
||||
maxPlayers: DEFAULT_MAX_PLAYERS,
|
||||
status: 'waiting',
|
||||
players: [],
|
||||
currentTurnIndex: null,
|
||||
})
|
||||
const roomState = activeRoomState
|
||||
|
||||
const isRoomFull = computed(() => {
|
||||
return (
|
||||
roomState.value.maxPlayers > 0 &&
|
||||
roomState.value.players.length === roomState.value.maxPlayers
|
||||
roomState.value.playerCount === roomState.value.maxPlayers
|
||||
)
|
||||
})
|
||||
|
||||
@@ -152,6 +147,16 @@ const seatViews = computed<SeatRenderItem[]>(() => {
|
||||
}
|
||||
|
||||
const players = [...roomState.value.players].sort((a, b) => a.index - b.index)
|
||||
const hasSelf = players.some((player) => player.playerId === currentUserId.value)
|
||||
if (currentUserId.value && roomState.value.id && !hasSelf) {
|
||||
// Fallback before WS full player list arrives: keep current player at bottom.
|
||||
players.unshift({
|
||||
index: 0,
|
||||
playerId: currentUserId.value,
|
||||
ready: false,
|
||||
})
|
||||
}
|
||||
|
||||
const me = players.find((player) => player.playerId === currentUserId.value) ?? null
|
||||
const anchorIndex = me?.index ?? players[0]?.index ?? 0
|
||||
const clockwiseSeatByDelta: SeatKey[] = ['bottom', 'left', 'top', 'right']
|
||||
@@ -533,8 +538,16 @@ function missingSuitLabel(suit: MissingSuit): string {
|
||||
}
|
||||
|
||||
function backHall(): void {
|
||||
sendLeaveRoom()
|
||||
void router.push('/hall')
|
||||
if (leaveRoomPending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
leaveHallAfterAck.value = true
|
||||
const sent = sendLeaveRoom()
|
||||
if (!sent) {
|
||||
leaveHallAfterAck.value = false
|
||||
pushWsMessage('[client] 退出房间失败:未发送请求')
|
||||
}
|
||||
}
|
||||
|
||||
function pushWsMessage(text: string): void {
|
||||
@@ -580,6 +593,56 @@ function toStringOrEmpty(value: unknown): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function toSession(source: NonNullable<typeof auth.value>): AuthSession {
|
||||
return {
|
||||
token: source.token,
|
||||
tokenType: source.tokenType,
|
||||
refreshToken: source.refreshToken,
|
||||
expiresIn: source.expiresIn,
|
||||
}
|
||||
}
|
||||
|
||||
function syncAuth(next: AuthSession): void {
|
||||
if (!auth.value) {
|
||||
return
|
||||
}
|
||||
|
||||
auth.value = {
|
||||
...auth.value,
|
||||
token: next.token,
|
||||
tokenType: next.tokenType ?? auth.value.tokenType,
|
||||
refreshToken: next.refreshToken ?? auth.value.refreshToken,
|
||||
expiresIn: next.expiresIn,
|
||||
}
|
||||
writeStoredAuth(auth.value)
|
||||
}
|
||||
|
||||
async function ensureCurrentUserId(): Promise<void> {
|
||||
if (currentUserId.value || !auth.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const userInfo = await getUserInfo(toSession(auth.value), syncAuth)
|
||||
const payload = userInfo as Record<string, unknown>
|
||||
const resolvedId = toStringOrEmpty(payload.userID ?? payload.user_id ?? payload.id)
|
||||
if (!resolvedId) {
|
||||
return
|
||||
}
|
||||
|
||||
auth.value = {
|
||||
...auth.value,
|
||||
user: {
|
||||
...(auth.value.user ?? {}),
|
||||
id: resolvedId,
|
||||
},
|
||||
}
|
||||
writeStoredAuth(auth.value)
|
||||
} catch {
|
||||
wsError.value = '获取当前用户ID失败,部分操作可能不可用'
|
||||
}
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
@@ -591,6 +654,36 @@ function toFiniteNumber(value: unknown): number | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function toBoolean(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizeScores(value: unknown): Record<string, number> {
|
||||
const record = toRecord(value)
|
||||
if (!record) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const scores: Record<string, number> = {}
|
||||
for (const [key, score] of Object.entries(record)) {
|
||||
const parsed = toFiniteNumber(score)
|
||||
if (parsed !== null) {
|
||||
scores[key] = parsed
|
||||
}
|
||||
}
|
||||
return scores
|
||||
}
|
||||
|
||||
function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null {
|
||||
const player = toRecord(input)
|
||||
if (!player) {
|
||||
@@ -611,7 +704,13 @@ function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState
|
||||
}
|
||||
|
||||
function extractCurrentTurnIndex(value: Record<string, unknown>): number | null {
|
||||
const game = toRecord(value.game)
|
||||
const gameState = toRecord(game?.state)
|
||||
const keys = [
|
||||
gameState?.currentTurn,
|
||||
gameState?.current_turn,
|
||||
gameState?.currentTurnIndex,
|
||||
gameState?.current_turn_index,
|
||||
value.currentTurnIndex,
|
||||
value.current_turn_index,
|
||||
value.currentPlayerIndex,
|
||||
@@ -630,13 +729,70 @@ function extractCurrentTurnIndex(value: Record<string, unknown>): number | null
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeRoom(input: unknown): RoomState | null {
|
||||
const room = toRecord(input)
|
||||
if (!room) {
|
||||
function normalizeGame(input: unknown): GameState | null {
|
||||
const game = toRecord(input)
|
||||
if (!game) {
|
||||
return null
|
||||
}
|
||||
|
||||
const id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
|
||||
const rule = toRecord(game.rule)
|
||||
const rawState = toRecord(game.state)
|
||||
const playersRaw =
|
||||
(Array.isArray(rawState?.players) ? rawState?.players : null) ??
|
||||
(Array.isArray(rawState?.playerStates) ? rawState?.playerStates : null) ??
|
||||
[]
|
||||
|
||||
const normalizedPlayers = playersRaw
|
||||
.map((item, index) => normalizePlayer(item, index))
|
||||
.filter((item): item is RoomPlayerState => Boolean(item))
|
||||
|
||||
return {
|
||||
rule: rule
|
||||
? {
|
||||
name: toStringOrEmpty(rule.name),
|
||||
isBloodFlow: toBoolean(rule.isBloodFlow ?? rule.is_blood_flow),
|
||||
hasHongZhong: toBoolean(rule.hasHongZhong ?? rule.has_hong_zhong),
|
||||
}
|
||||
: null,
|
||||
state: rawState
|
||||
? {
|
||||
phase: toStringOrEmpty(rawState.phase),
|
||||
dealerIndex: toFiniteNumber(rawState.dealerIndex ?? rawState.dealer_index) ?? 0,
|
||||
currentTurn: toFiniteNumber(rawState.currentTurn ?? rawState.current_turn) ?? 0,
|
||||
needDraw: toBoolean(rawState.needDraw ?? rawState.need_draw),
|
||||
players: normalizedPlayers,
|
||||
wall: Array.isArray(rawState.wall) ? rawState.wall.map((item) => toStringOrEmpty(item)).filter(Boolean) : [],
|
||||
lastDiscardTile: toStringOrEmpty(rawState.lastDiscardTile ?? rawState.last_discard_tile) || null,
|
||||
lastDiscardBy: toStringOrEmpty(rawState.lastDiscardBy ?? rawState.last_discard_by),
|
||||
pendingClaim: toRecord(rawState.pendingClaim ?? rawState.pending_claim),
|
||||
winners: Array.isArray(rawState.winners)
|
||||
? rawState.winners.map((item) => toStringOrEmpty(item)).filter(Boolean)
|
||||
: [],
|
||||
scores: normalizeScores(rawState.scores),
|
||||
lastDrawPlayerId: toStringOrEmpty(rawState.lastDrawPlayerID ?? rawState.last_draw_player_id),
|
||||
lastDrawFromGang: toBoolean(rawState.lastDrawFromGang ?? rawState.last_draw_from_gang),
|
||||
lastDrawIsLastTile: toBoolean(rawState.lastDrawIsLastTile ?? rawState.last_draw_is_last_tile),
|
||||
huWay: toStringOrEmpty(rawState.huWay ?? rawState.hu_way),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRoom(input: unknown): RoomState | null {
|
||||
const source = toRecord(input)
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
|
||||
let room = source
|
||||
let id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
|
||||
if (!id) {
|
||||
const nestedRoom = toRecord(room.data)
|
||||
if (nestedRoom) {
|
||||
room = nestedRoom
|
||||
id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
|
||||
}
|
||||
}
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
@@ -648,19 +804,38 @@ function normalizeRoom(input: unknown): RoomState | null {
|
||||
(Array.isArray(room.playerList) ? room.playerList : null) ??
|
||||
(Array.isArray(room.player_list) ? room.player_list : null) ??
|
||||
[]
|
||||
const playerIdsRaw =
|
||||
(Array.isArray(room.player_ids) ? room.player_ids : null) ??
|
||||
(Array.isArray(room.playerIds) ? room.playerIds : null) ??
|
||||
[]
|
||||
|
||||
const players = playersRaw
|
||||
.map((item, index) => normalizePlayer(item, index))
|
||||
.filter((item): item is RoomPlayerState => Boolean(item))
|
||||
.sort((a, b) => a.index - b.index)
|
||||
const playersFromIds = playerIdsRaw
|
||||
.map((item, index) => ({
|
||||
index,
|
||||
playerId: toStringOrEmpty(item),
|
||||
ready: false,
|
||||
}))
|
||||
.filter((item) => Boolean(item.playerId))
|
||||
const resolvedPlayers = players.length > 0 ? players : playersFromIds
|
||||
const parsedPlayerCount = toFiniteNumber(room.player_count ?? room.playerCount)
|
||||
const game = normalizeGame(room.game)
|
||||
|
||||
return {
|
||||
id,
|
||||
name: toStringOrEmpty(room.name) || roomState.value.name,
|
||||
gameType: toStringOrEmpty(room.gameType ?? room.game_type) || roomState.value.gameType || 'chengdu',
|
||||
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id),
|
||||
maxPlayers,
|
||||
playerCount: parsedPlayerCount ?? resolvedPlayers.length,
|
||||
status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting',
|
||||
players,
|
||||
createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || roomState.value.createdAt,
|
||||
updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || roomState.value.updatedAt,
|
||||
game: game ?? roomState.value.game,
|
||||
players: resolvedPlayers,
|
||||
currentTurnIndex: extractCurrentTurnIndex(room),
|
||||
}
|
||||
}
|
||||
@@ -669,14 +844,7 @@ function mergeRoomState(next: RoomState): void {
|
||||
if (roomId.value && next.id !== roomId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
roomState.value = {
|
||||
...roomState.value,
|
||||
...next,
|
||||
players: next.players.length > 0 ? next.players : roomState.value.players,
|
||||
currentTurnIndex:
|
||||
next.currentTurnIndex !== null ? next.currentTurnIndex : roomState.value.currentTurnIndex,
|
||||
}
|
||||
mergeActiveRoomState(next)
|
||||
}
|
||||
|
||||
function consumeGameEvent(raw: string): void {
|
||||
@@ -692,12 +860,58 @@ function consumeGameEvent(raw: string): void {
|
||||
return
|
||||
}
|
||||
|
||||
const candidates: unknown[] = [event, event.payload, event.data]
|
||||
const payload = toRecord(event.payload)
|
||||
if (payload) {
|
||||
candidates.push(payload.room, payload.state, payload.roomState)
|
||||
const data = toRecord(event.data)
|
||||
const eventType = toStringOrEmpty(event.type)
|
||||
const eventStatus = toStringOrEmpty(event.status)
|
||||
const eventRoomId = toStringOrEmpty(event.roomId ?? event.room_id ?? payload?.roomId ?? payload?.room_id)
|
||||
const eventRequestId = toStringOrEmpty(
|
||||
event.requestId ?? event.request_id ?? payload?.requestId ?? payload?.request_id ?? data?.requestId ?? data?.request_id,
|
||||
)
|
||||
const payloadPlayerIds = Array.isArray(payload?.player_ids)
|
||||
? payload.player_ids.map((item) => toStringOrEmpty(item)).filter(Boolean)
|
||||
: Array.isArray(payload?.playerIds)
|
||||
? payload.playerIds.map((item) => toStringOrEmpty(item)).filter(Boolean)
|
||||
: null
|
||||
const leaveByRequestIdMatched = Boolean(eventRequestId && eventRequestId === lastLeaveRoomRequestId.value)
|
||||
const leaveByPlayerUpdateMatched =
|
||||
leaveRoomPending.value &&
|
||||
eventType === 'room_player_update' &&
|
||||
eventStatus === 'ok' &&
|
||||
eventRoomId === (roomState.value.id || roomId.value) &&
|
||||
Array.isArray(payloadPlayerIds) &&
|
||||
Boolean(currentUserId.value) &&
|
||||
!payloadPlayerIds.includes(currentUserId.value)
|
||||
|
||||
if (leaveByRequestIdMatched || leaveByPlayerUpdateMatched) {
|
||||
leaveRoomPending.value = false
|
||||
lastLeaveRoomRequestId.value = ''
|
||||
if (event.status === 'error') {
|
||||
leaveHallAfterAck.value = false
|
||||
wsError.value = '退出房间失败,请稍后重试'
|
||||
pushWsMessage(`[client] 退出房间失败 requestId=${eventRequestId}`)
|
||||
} else {
|
||||
if (leaveByPlayerUpdateMatched) {
|
||||
pushWsMessage('[client] 已确认退出房间 player_update')
|
||||
} else {
|
||||
pushWsMessage(`[client] 已确认退出房间 requestId=${eventRequestId}`)
|
||||
}
|
||||
if (leaveHallAfterAck.value) {
|
||||
leaveHallAfterAck.value = false
|
||||
void router.push('/hall')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const candidates: unknown[] = [event.payload, event.data]
|
||||
if (payload) {
|
||||
candidates.push(payload.room, payload.state, payload.roomState, payload.data)
|
||||
}
|
||||
if (data) {
|
||||
candidates.push(data.room, data.state, data.roomState, data.data)
|
||||
}
|
||||
candidates.push(event)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeRoom(candidate)
|
||||
if (normalized) {
|
||||
@@ -725,6 +939,7 @@ function sendStartGame(): void {
|
||||
}
|
||||
|
||||
const sender = currentUserId.value
|
||||
|
||||
if (!sender) {
|
||||
return
|
||||
}
|
||||
@@ -749,18 +964,26 @@ function sendStartGame(): void {
|
||||
pushWsMessage(`[client] 请求开始游戏 requestId=${requestId}`)
|
||||
}
|
||||
|
||||
function sendLeaveRoom(): void {
|
||||
function sendLeaveRoom(): boolean {
|
||||
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
wsError.value = 'WebSocket 未连接,无法退出房间'
|
||||
return false
|
||||
}
|
||||
|
||||
const sender = currentUserId.value
|
||||
const targetRoomId = roomState.value.id || roomId.value
|
||||
if (!sender || !targetRoomId) {
|
||||
return
|
||||
if (!sender) {
|
||||
wsError.value = '缺少当前用户ID,无法退出房间'
|
||||
return false
|
||||
}
|
||||
if (!targetRoomId) {
|
||||
wsError.value = '缺少房间ID,无法退出房间'
|
||||
return false
|
||||
}
|
||||
|
||||
const requestId = createRequestId('leave-room')
|
||||
leaveRoomPending.value = true
|
||||
lastLeaveRoomRequestId.value = requestId
|
||||
const message = {
|
||||
type: 'leave_room',
|
||||
sender,
|
||||
@@ -775,6 +998,8 @@ function sendLeaveRoom(): void {
|
||||
logWsSend(message)
|
||||
ws.value.send(JSON.stringify(message))
|
||||
pushWsMessage(`[client] 请求离开房间 requestId=${requestId}`)
|
||||
pushWsMessage(`[client] 请求退出房间 requestId=${requestId}`)
|
||||
return true
|
||||
}
|
||||
|
||||
function connectWs(): void {
|
||||
@@ -823,6 +1048,13 @@ function connectWs(): void {
|
||||
socket.onclose = () => {
|
||||
wsStatus.value = 'disconnected'
|
||||
startGamePending.value = false
|
||||
if (leaveRoomPending.value) {
|
||||
leaveRoomPending.value = false
|
||||
lastLeaveRoomRequestId.value = ''
|
||||
leaveHallAfterAck.value = false
|
||||
wsError.value = '连接已断开,未收到退出房间确认'
|
||||
pushWsMessage('[client] 连接断开:退出房间请求未确认')
|
||||
}
|
||||
pushWsMessage('WebSocket 已断开')
|
||||
}
|
||||
}
|
||||
@@ -850,27 +1082,29 @@ function startNextRound(): void {
|
||||
watch(
|
||||
roomId,
|
||||
(nextRoomId) => {
|
||||
roomState.value = {
|
||||
id: nextRoomId,
|
||||
name: roomName.value,
|
||||
ownerId: '',
|
||||
maxPlayers: DEFAULT_MAX_PLAYERS,
|
||||
status: 'waiting',
|
||||
players: [],
|
||||
currentTurnIndex: null,
|
||||
const currentRoom = roomState.value
|
||||
if (!nextRoomId) {
|
||||
destroyActiveRoomState()
|
||||
} else if (currentRoom.id !== nextRoomId) {
|
||||
resetActiveRoomState({
|
||||
id: nextRoomId,
|
||||
name: roomName.value,
|
||||
})
|
||||
} else if (!currentRoom.name && roomName.value) {
|
||||
roomState.value = { ...currentRoom, name: roomName.value }
|
||||
}
|
||||
overlayState.value = 'missing'
|
||||
startGamePending.value = false
|
||||
lastStartRequestId.value = ''
|
||||
leaveRoomPending.value = false
|
||||
lastLeaveRoomRequestId.value = ''
|
||||
leaveHallAfterAck.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(roomName, (next) => {
|
||||
roomState.value = {
|
||||
...roomState.value,
|
||||
name: next || roomState.value.name,
|
||||
}
|
||||
roomState.value = { ...roomState.value, name: next || roomState.value.name }
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -908,7 +1142,8 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
await ensureCurrentUserId()
|
||||
connectWs()
|
||||
clockTimer = window.setInterval(() => {
|
||||
now.value = new Date()
|
||||
@@ -917,6 +1152,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnectWs()
|
||||
destroyActiveRoomState()
|
||||
if (clockTimer !== undefined) {
|
||||
window.clearInterval(clockTimer)
|
||||
}
|
||||
@@ -937,6 +1173,10 @@ onBeforeUnmount(() => {
|
||||
<strong>{{ roomState.name || roomName || '成都麻将' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="ghost-btn" type="button" :disabled="leaveRoomPending" @click="backHall">
|
||||
{{ leaveRoomPending ? '退出中...' : '返回大厅' }}
|
||||
</button>
|
||||
|
||||
<div class="topbar-center">
|
||||
<div class="title-stack">
|
||||
@@ -952,6 +1192,25 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="status-chip clock-chip">{{ formattedClock }}</div>
|
||||
<button class="header-btn ghost-btn" type="button" @click="connectWs">重连</button>
|
||||
<section class="table-panel game-table-panel">
|
||||
<div class="room-brief">
|
||||
<span class="room-brief-title">当前房间</span>
|
||||
<span class="room-brief-item">
|
||||
<em>房间名:</em>
|
||||
<strong>{{ roomState.name || roomName || '未命名房间' }}</strong>
|
||||
</span>
|
||||
<span class="room-brief-item room-brief-id">
|
||||
<em>room_id:</em>
|
||||
<strong>{{ roomId || '未选择房间' }}</strong>
|
||||
</span>
|
||||
<span class="room-brief-item">
|
||||
<em>状态:</em>
|
||||
<strong>{{ roomStatusText }}</strong>
|
||||
</span>
|
||||
<span class="room-brief-item">
|
||||
<em>人数:</em>
|
||||
<strong>{{ roomState.playerCount }}/{{ roomState.maxPlayers }}</strong>
|
||||
</span>
|
||||
<button
|
||||
class="header-btn primary-btn"
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,8 @@ 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 '../state/active-room'
|
||||
import type { RoomPlayerState } from '../state/active-room'
|
||||
import type { StoredAuth } from '../types/session'
|
||||
import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
|
||||
|
||||
@@ -121,6 +123,16 @@ function isMyRoom(room: RoomItem): boolean {
|
||||
return Boolean(currentUserId.value) && room.owner_id === currentUserId.value
|
||||
}
|
||||
|
||||
function mapRoomPlayers(room: RoomItem): RoomPlayerState[] {
|
||||
return (room.players ?? [])
|
||||
.map((item, fallbackIndex) => ({
|
||||
index: Number.isFinite(item.index) ? item.index : fallbackIndex,
|
||||
playerId: item.player_id,
|
||||
ready: Boolean(item.ready),
|
||||
}))
|
||||
.filter((item) => Boolean(item.playerId))
|
||||
}
|
||||
|
||||
function toSession(source: StoredAuth): AuthSession {
|
||||
return {
|
||||
token: source.token,
|
||||
@@ -220,6 +232,18 @@ async function submitCreateRoom(): Promise<void> {
|
||||
)
|
||||
|
||||
createdRoom.value = room
|
||||
hydrateActiveRoomFromSelection({
|
||||
roomId: room.room_id,
|
||||
roomName: room.name,
|
||||
gameType: room.game_type,
|
||||
ownerId: room.owner_id,
|
||||
maxPlayers: room.max_players,
|
||||
playerCount: room.player_count,
|
||||
status: room.status,
|
||||
createdAt: room.created_at,
|
||||
updatedAt: room.updated_at,
|
||||
players: mapRoomPlayers(room),
|
||||
})
|
||||
quickJoinRoomId.value = room.room_id
|
||||
createRoomForm.value.name = ''
|
||||
showCreateModal.value = false
|
||||
@@ -251,18 +275,27 @@ async function handleJoinRoom(room?: { roomId?: string; roomName?: string }): Pr
|
||||
return
|
||||
}
|
||||
|
||||
const targetRoomName =
|
||||
room?.roomName ?? rooms.value.find((item) => item.room_id === targetRoomId)?.name ?? ''
|
||||
|
||||
roomSubmitting.value = true
|
||||
try {
|
||||
await joinRoom(session, { roomId: targetRoomId }, syncAuth)
|
||||
quickJoinRoomId.value = targetRoomId
|
||||
successMessage.value = `已加入房间:${targetRoomId}`
|
||||
const joinedRoom = await joinRoom(session, { roomId: targetRoomId }, syncAuth)
|
||||
hydrateActiveRoomFromSelection({
|
||||
roomId: joinedRoom.room_id,
|
||||
roomName: joinedRoom.name,
|
||||
gameType: joinedRoom.game_type,
|
||||
ownerId: joinedRoom.owner_id,
|
||||
maxPlayers: joinedRoom.max_players,
|
||||
playerCount: joinedRoom.player_count,
|
||||
status: joinedRoom.status,
|
||||
createdAt: joinedRoom.created_at,
|
||||
updatedAt: joinedRoom.updated_at,
|
||||
players: mapRoomPlayers(joinedRoom),
|
||||
})
|
||||
quickJoinRoomId.value = joinedRoom.room_id
|
||||
successMessage.value = `已加入房间:${joinedRoom.room_id}`
|
||||
await refreshRooms()
|
||||
await router.push({
|
||||
path: `/game/chengdu/${targetRoomId}`,
|
||||
query: targetRoomName ? { roomName: targetRoomName } : undefined,
|
||||
path: `/game/chengdu/${joinedRoom.room_id}`,
|
||||
query: joinedRoom.name ? { roomName: joinedRoom.name } : undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof AuthExpiredError) {
|
||||
@@ -298,6 +331,18 @@ async function enterCreatedRoom(): Promise<void> {
|
||||
}
|
||||
|
||||
showCreatedModal.value = false
|
||||
hydrateActiveRoomFromSelection({
|
||||
roomId: createdRoom.value.room_id,
|
||||
roomName: createdRoom.value.name,
|
||||
gameType: createdRoom.value.game_type,
|
||||
ownerId: createdRoom.value.owner_id,
|
||||
maxPlayers: createdRoom.value.max_players,
|
||||
playerCount: createdRoom.value.player_count,
|
||||
status: createdRoom.value.status,
|
||||
createdAt: createdRoom.value.created_at,
|
||||
updatedAt: createdRoom.value.updated_at,
|
||||
players: mapRoomPlayers(createdRoom.value),
|
||||
})
|
||||
await router.push({
|
||||
path: `/game/chengdu/${createdRoom.value.room_id}`,
|
||||
query: { roomName: createdRoom.value.name },
|
||||
|
||||
Reference in New Issue
Block a user