Files
mahjong-web/src/views/ChengduGamePage.vue
wsy182 5c9c2a180d feat(game): 添加成都麻将打牌功能和E2E测试
- 添加discardPending状态控制丢弃牌操作
- 实现canDiscardTiles计算属性判断是否可以丢弃牌
- 新增handleRoomStateResponse函数处理房间状态响应
- 实现discardTile函数发送丢弃牌消息
- 在游戏页面添加手牌操作栏显示可丢弃的牌
- 为定缺按钮和准备按钮添加data-testid标识
- 在大厅页面为房间操作元素添加data-testid标识
- 添加手牌操作相关的CSS样式
- 配置Playwright E2E测试框架
- 创建房间流程到打牌的完整E2E测试用例
2026-03-29 16:47:56 +08:00

2173 lines
62 KiB
Vue

<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import deskImage from '../assets/images/desk/desk_01.png'
import robotIcon from '../assets/images/icons/robot.svg'
import exitIcon from '../assets/images/icons/exit.svg'
import '../assets/styles/room.css'
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 WindSquare from '../components/game/WindSquare.vue'
import eastWind from '../assets/images/direction/dong.png'
import southWind from '../assets/images/direction/nan.png'
import westWind from '../assets/images/direction/xi.png'
import northWind from '../assets/images/direction/bei.png'
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
import type {SeatKey} from '../game/seat'
import type {GameAction, RoomPlayerUpdatePayload} from '../game/actions'
import {dispatchGameAction} from '../game/dispatcher'
import {refreshAccessToken} from '../api/auth'
import {AuthExpiredError, type AuthSession} from '../api/authed-request'
import {getUserInfo} from '../api/user'
import {clearAuth, readStoredAuth, writeStoredAuth} from '../utils/auth-storage'
import type {WsStatus} from '../ws/client'
import {wsClient} from '../ws/client'
import {sendWsMessage} from '../ws/sender'
import {buildWsUrl} from '../ws/url'
import {useGameStore} from '../store/gameStore'
import {clearActiveRoom, setActiveRoom, useActiveRoomState} from '../store'
import type {MeldState, PlayerState} from '../types/state'
import type {Tile} from '../types/tile'
import {getTileImage as getBottomTileImage} from '../config/bottomTileMap.ts'
import {getTileImage as getTopTileImage} from '../config/topTileMap.ts'
import {getTileImage as getRightTileImage} from '../config/rightTileMap.ts'
import {getTileImage as getLeftTileImage} from '../config/leftTileMap.ts'
const gameStore = useGameStore()
const activeRoom = useActiveRoomState()
const route = useRoute()
const router = useRouter()
const auth = ref(readStoredAuth())
type DisplayPlayer = PlayerState & {
displayName?: string
missingSuit?: string | null
}
type GameActionPayload<TType extends GameAction['type']> = Extract<GameAction, { type: TType }>['payload']
type HandSuitLabel = '万' | '筒' | '条'
type TableTileImageType = 'hand' | 'exposed' | 'covered'
interface WallTileItem {
key: string
src: string
alt: string
imageType: TableTileImageType
suit?: Tile['suit']
}
interface WallSeatState {
tiles: WallTileItem[]
hasHu: boolean
}
interface SeatViewModel {
key: SeatKey
player?: DisplayPlayer
isSelf: boolean
isTurn: boolean
}
const now = ref(Date.now())
const wsStatus = ref<WsStatus>('idle')
const wsMessages = ref<string[]>([])
const wsError = ref('')
const leaveRoomPending = ref(false)
const readyTogglePending = ref(false)
const startGamePending = ref(false)
const dingQuePending = ref(false)
const discardPending = ref(false)
let clockTimer: number | null = null
let unsubscribe: (() => void) | null = null
let needsInitialRoomInfo = false
const menuOpen = ref(false)
const isTrustMode = ref(false)
const menuTriggerActive = ref(false)
let menuTriggerTimer: number | null = null
let menuOpenTimer: number | null = null
let refreshingWsToken = false
let lastForcedRefreshAt = 0
const loggedInUserId = computed(() => {
const source = auth.value?.user as Record<string, unknown> | undefined
const rawId =
source?.id ??
source?.userID ??
source?.user_id
if (typeof rawId === 'string' && rawId.trim()) {
return rawId
}
if (typeof rawId === 'number') {
return String(rawId)
}
return ''
})
const loggedInUserName = computed(() => {
return auth.value?.user?.nickname || auth.value?.user?.username || ''
})
const localCachedAvatarUrl = computed(() => {
const source = auth.value?.user as Record<string, unknown> | undefined
if (!source) {
return ''
}
const avatarCandidates = [
source.avatar,
source.avatar_url,
source.avatarUrl,
source.head_img,
source.headImg,
source.profile_image,
source.profileImage,
]
for (const candidate of avatarCandidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate
}
}
return ''
})
const myPlayer = computed(() => {
return gameStore.players[loggedInUserId.value]
})
const myHandTiles = computed(() => {
return myPlayer.value?.handTiles ?? []
})
const handSuitOrder: Record<Tile['suit'], number> = {
W: 0,
T: 1,
B: 2,
}
const handSuitLabelMap: Record<Tile['suit'], HandSuitLabel> = {
W: '万',
T: '筒',
B: '条',
}
const visibleHandTileGroups = computed(() => {
const grouped = new Map<HandSuitLabel, Tile[]>()
myHandTiles.value
.slice()
.sort((left, right) => {
const suitDiff = handSuitOrder[left.suit] - handSuitOrder[right.suit]
if (suitDiff !== 0) {
return suitDiff
}
const valueDiff = left.value - right.value
if (valueDiff !== 0) {
return valueDiff
}
return left.id - right.id
})
.forEach((tile) => {
const label = handSuitLabelMap[tile.suit]
const current = grouped.get(label) ?? []
current.push(tile)
grouped.set(label, current)
})
return (['万', '筒', '条'] as HandSuitLabel[])
.map((suit) => ({
suit,
tiles: grouped.get(suit) ?? [],
}))
.filter((group) => group.tiles.length > 0)
})
const sortedVisibleHandTiles = computed(() => {
return visibleHandTileGroups.value.flatMap((group) => group.tiles)
})
const remainingTiles = computed(() => {
return gameStore.remainingTiles
})
const gamePlayers = computed<DisplayPlayer[]>(() => {
return Object.values(gameStore.players).sort((a, b) => a.seatIndex - b.seatIndex) as DisplayPlayer[]
})
const roomName = computed(() => {
const queryRoomName = typeof route.query.roomName === 'string' ? route.query.roomName : ''
const activeRoomName =
activeRoom.value && activeRoom.value.roomId === gameStore.roomId
? activeRoom.value.roomName
: ''
return queryRoomName || activeRoomName || `房间 ${gameStore.roomId || '--'}`
})
const roomState = computed(() => {
const status = gameStore.phase === 'waiting' ? 'waiting' : gameStore.phase === 'settlement' ? 'finished' : 'playing'
const wall = Array.from({length: remainingTiles.value}, (_, index) => `wall-${index}`)
const maxPlayers =
activeRoom.value && activeRoom.value.roomId === gameStore.roomId
? activeRoom.value.maxPlayers
: 4
return {
roomId: gameStore.roomId,
name: roomName.value,
playerCount: gamePlayers.value.length,
maxPlayers,
status,
game: {
state: {
wall,
dealerIndex: gameStore.dealerIndex,
currentTurn: gameStore.currentTurn,
phase: gameStore.phase,
},
},
}
})
const seatViews = computed<SeatViewModel[]>(() => {
const players = gamePlayers.value
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
const selfSeatIndex = myPlayer.value?.seatIndex ?? players.find((player) => player.playerId === loggedInUserId.value)?.seatIndex ?? 0
const currentTurn = gameStore.currentTurn
return players.slice(0, 4).map((player) => {
const relativeIndex = (selfSeatIndex - player.seatIndex + 4) % 4
const seatKey = tableOrder[relativeIndex] ?? 'top'
return {
key: seatKey,
player,
isSelf: player.playerId === loggedInUserId.value,
isTurn: player.seatIndex === currentTurn,
}
})
})
const seatWinds = computed<Record<SeatKey, string>>(() => {
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
const players = gamePlayers.value
const selfSeatIndex = myPlayer.value?.seatIndex ?? players.find((player) => player.playerId === loggedInUserId.value)?.seatIndex ?? 0
const directionBySeatIndex = [eastWind, southWind, westWind, northWind]
const result: Record<SeatKey, string> = {
top: northWind,
right: eastWind,
bottom: southWind,
left: westWind,
}
for (let absoluteSeat = 0; absoluteSeat < 4; absoluteSeat += 1) {
const relativeIndex = (selfSeatIndex - absoluteSeat + 4) % 4
const seatKey = tableOrder[relativeIndex] ?? 'top'
result[seatKey] = directionBySeatIndex[absoluteSeat] ?? northWind
}
return result
})
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
const currentPhaseText = computed(() => {
const map: Record<string, string> = {
waiting: '等待中',
dealing: '发牌中',
playing: '对局中',
action: '操作中',
settlement: '已结算',
}
return map[gameStore.phase] ?? gameStore.phase
})
const roomStatusText = computed(() => {
const map: Record<string, string> = {
waiting: '等待玩家',
playing: '游戏中',
finished: '已结束',
}
const status = roomState.value.status
return map[status] ?? status ?? '--'
})
const myReadyState = computed(() => {
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(() => {
if (gameStore.phase !== 'waiting' || !gameStore.roomId) {
return false
}
if (showStartGameButton.value) {
return !isRoomOwner.value
}
return true
})
const showDingQueChooser = computed(() => {
const player = myPlayer.value
if (!player) {
return false
}
if (gameStore.phase === 'settlement') {
return false
}
return player.handTiles.length > 0 && !player.missingSuit
})
const canDiscardTiles = computed(() => {
const player = myPlayer.value
if (!player || !gameStore.roomId) {
return false
}
if (gameStore.phase !== 'playing') {
return false
}
if (!player.missingSuit || player.handTiles.length === 0) {
return false
}
return player.seatIndex === gameStore.currentTurn
})
function applyPlayerReadyState(playerId: string, ready: boolean): void {
const player = gameStore.players[playerId]
if (player) {
player.isReady = ready
}
const room = activeRoom.value
if (!room || room.roomId !== gameStore.roomId) {
return
}
const roomPlayer = room.players.find((item) => item.playerId === playerId)
if (roomPlayer) {
roomPlayer.ready = ready
}
}
function syncReadyStatesFromRoomUpdate(payload: RoomPlayerUpdatePayload): void {
if (!Array.isArray(payload.players)) {
return
}
for (const item of payload.players) {
const playerId =
(typeof item.PlayerID === 'string' && item.PlayerID) ||
(typeof item.player_id === 'string' && item.player_id) ||
''
const ready =
typeof item.Ready === 'boolean'
? item.Ready
: typeof item.ready === 'boolean'
? item.ready
: undefined
if (!playerId || typeof ready !== 'boolean') {
continue
}
applyPlayerReadyState(playerId, ready)
}
}
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 readMissingSuit(source: Record<string, unknown> | null | undefined): string | null {
if (!source) {
return null
}
return readString(source, 'missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit') || 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 normalizeMeldType(value: unknown, concealed = false): MeldState['type'] | null {
if (typeof value !== 'string') {
return concealed ? 'an_gang' : null
}
const normalized = value.replace(/[-\s]/g, '_').toLowerCase()
if (normalized === 'peng') {
return 'peng'
}
if (normalized === 'ming_gang' || normalized === 'gang' || normalized === 'gang_open') {
return concealed ? 'an_gang' : 'ming_gang'
}
if (normalized === 'an_gang' || normalized === 'angang' || normalized === 'concealed_gang') {
return 'an_gang'
}
return concealed ? 'an_gang' : null
}
function normalizeMelds(value: unknown): PlayerState['melds'] {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => {
const source = asRecord(item)
if (!source) {
return null
}
const tiles = normalizeTiles(
source.tiles ??
source.meld_tiles ??
source.meldTiles ??
source.cards ??
source.card_list,
)
if (tiles.length === 0) {
return null
}
const concealed =
readBoolean(source, 'concealed', 'is_concealed', 'isConcealed', 'hidden', 'is_hidden') ?? false
const type = normalizeMeldType(
source.type ?? source.meld_type ?? source.meldType ?? source.kind,
concealed,
)
if (type === 'peng') {
return {
type,
tiles,
fromPlayerId: readString(source, 'from_player_id', 'fromPlayerId'),
} satisfies MeldState
}
if (type === 'ming_gang') {
return {
type,
tiles,
fromPlayerId: readString(source, 'from_player_id', 'fromPlayerId'),
} satisfies MeldState
}
if (type === 'an_gang') {
return {
type,
tiles,
} satisfies MeldState
}
return null
})
.filter((item): item is MeldState => 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) {
return
}
if (wsStatus.value !== 'connected') {
return
}
needsInitialRoomInfo = false
wsMessages.value.push(`[client] get_room_info ${roomId}`)
sendWsMessage({
type: 'get_room_info',
roomId,
payload: {
room_id: roomId,
},
})
}
function handleRoomStateResponse(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string') {
return
}
if (normalizeWsType(source.type) !== 'ROOM_STATE') {
return
}
const payload = asRecord(source.payload)
if (!payload) {
return
}
const roomId =
readString(payload, 'room_id', 'roomId') ||
readString(source, 'roomId') ||
gameStore.roomId
if (!roomId) {
return
}
if (gameStore.roomId && roomId !== gameStore.roomId) {
return
}
const previousPlayers = gameStore.players
const nextPlayers: typeof gameStore.players = {}
const gamePlayers = Array.isArray(payload.players) ? payload.players : []
gamePlayers.forEach((item, fallbackIndex) => {
const player = asRecord(item)
if (!player) {
return
}
const playerId = readString(player, 'player_id', 'PlayerID')
if (!playerId) {
return
}
const previous = previousPlayers[playerId]
const seatIndex = previous?.seatIndex ?? fallbackIndex
const handCount = readNumber(player, 'hand_count', 'handCount') ?? previous?.handCount ?? 0
const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles)
const melds = normalizeMelds(
player.melds ??
player.exposed_melds ??
player.exposedMelds ??
player.claims,
)
const hasHu = Boolean(player.has_hu ?? player.hasHu)
const dingQue = readString(player, 'ding_que', 'dingQue')
const scores = asRecord(payload.scores)
const score = scores?.[playerId]
nextPlayers[playerId] = {
playerId,
seatIndex,
displayName: previous?.displayName ?? playerId,
avatarURL: previous?.avatarURL,
missingSuit: dingQue || previous?.missingSuit || null,
handTiles: previous?.handTiles ?? [],
handCount,
melds,
discardTiles: outTiles,
hasHu,
score: typeof score === 'number' ? score : previous?.score ?? 0,
isReady: previous?.isReady ?? false,
}
})
if (Object.keys(nextPlayers).length > 0) {
gameStore.players = nextPlayers
}
gameStore.roomId = roomId
const phase =
readString(payload, 'phase') ||
readString(payload, 'status') ||
'waiting'
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
const wallCount = readNumber(payload, 'wall_count', 'wallCount')
if (typeof wallCount === 'number') {
gameStore.remainingTiles = wallCount
}
const currentTurnSeat = readNumber(payload, 'current_turn', 'currentTurn')
const currentTurnPlayerId = readString(payload, 'current_turn_player', 'currentTurnPlayer')
const currentTurn =
currentTurnSeat ??
(currentTurnPlayerId && gameStore.players[currentTurnPlayerId]
? gameStore.players[currentTurnPlayerId].seatIndex
: null)
if (typeof currentTurn === 'number') {
gameStore.currentTurn = currentTurn
}
const scores = asRecord(payload.scores)
if (scores) {
gameStore.scores = Object.fromEntries(
Object.entries(scores).filter(([, value]) => typeof value === 'number'),
) as Record<string, number>
}
gameStore.winners = readStringArray(payload, 'winners')
gameStore.pendingClaim = undefined
const previousRoom = activeRoom.value
const roomPlayers = Object.values(gameStore.players)
.sort((left, right) => left.seatIndex - right.seatIndex)
.map((player) => {
const previousPlayer = previousRoom?.players.find((item) => item.playerId === player.playerId)
return {
index: player.seatIndex,
playerId: player.playerId,
displayName: player.displayName,
missingSuit: player.missingSuit,
ready: previousPlayer?.ready ?? player.isReady,
hand: player.playerId === loggedInUserId.value
? player.handTiles.map((tile) => tileToText(tile))
: Array.from({length: player.handCount}, () => ''),
melds: player.melds.map((meld) => meld.type),
outTiles: player.discardTiles.map((tile) => tileToText(tile)),
hasHu: player.hasHu,
}
})
setActiveRoom({
roomId,
roomName: previousRoom?.roomName || roomName.value,
gameType: previousRoom?.gameType || 'chengdu',
ownerId: previousRoom?.ownerId || '',
maxPlayers: previousRoom?.maxPlayers ?? 4,
playerCount: roomPlayers.length,
status: phase === 'settlement' ? 'finished' : phase === 'waiting' ? 'waiting' : 'playing',
createdAt: previousRoom?.createdAt || '',
updatedAt: previousRoom?.updatedAt || '',
players: roomPlayers,
myHand: myHandTiles.value.map((tile) => tileToText(tile)),
game: {
state: {
wall: Array.from({length: wallCount ?? 0}, (_, index) => `wall-${index}`),
scores: gameStore.scores,
dealerIndex: previousRoom?.game?.state?.dealerIndex ?? gameStore.dealerIndex,
currentTurn: typeof currentTurn === 'number' ? currentTurn : previousRoom?.game?.state?.currentTurn ?? -1,
phase,
},
},
})
if (phase !== 'waiting') {
startGamePending.value = false
}
if (currentTurnPlayerId && currentTurnPlayerId !== loggedInUserId.value) {
discardPending.value = false
}
}
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 room = asRecord(payload.room)
const gameState = asRecord(payload.game_state)
const playerView = asRecord(payload.player_view)
if (!room && !gameState && !playerView) {
clearActiveRoom()
gameStore.resetGame()
wsClient.close()
void router.push('/hall')
return
}
const roomId =
readString(room ?? {}, 'room_id', 'roomId') ||
readString(gameState ?? {}, 'room_id', 'roomId') ||
readString(playerView ?? {}, 'room_id', 'roomId') ||
readString(payload, 'room_id', 'roomId') ||
readString(source, 'roomId')
if (!roomId) {
return
}
const roomPlayers = Array.isArray(room?.players) ? room.players : []
const gamePlayers = Array.isArray(gameState?.players) ? gameState.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[]
handCount: number
melds: PlayerState['melds']
discardTiles: Tile[]
hasHu: boolean
score: number
}
}>()
roomPlayers.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 = readMissingSuit(player)
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: [],
handCount: 0,
melds: [],
discardTiles: [],
hasHu: false,
score: 0,
},
})
})
gamePlayers.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 = readMissingSuit(player) || existing?.gamePlayer.missingSuit || null
const handCount = readNumber(player, 'hand_count', 'handCount') ?? 0
const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles)
const melds = normalizeMelds(
player.melds ??
player.exposed_melds ??
player.exposedMelds ??
player.claims,
)
const hasHu = Boolean(player.has_hu ?? player.hasHu)
playerMap.set(playerId, {
roomPlayer: {
index: seatIndex,
playerId,
displayName: displayName || undefined,
missingSuit,
ready: existing?.roomPlayer.ready ?? false,
hand: Array.from({length: handCount}, () => ''),
melds: melds.map((meld) => meld.type),
outTiles: outTiles.map((tile) => tileToText(tile)),
hasHu,
},
gamePlayer: {
playerId,
seatIndex,
displayName: displayName || undefined,
avatarURL: existing?.gamePlayer.avatarURL,
missingSuit,
isReady: existing?.gamePlayer.isReady ?? false,
handTiles: existing?.gamePlayer.handTiles ?? [],
handCount,
melds: melds.length > 0 ? melds : existing?.gamePlayer.melds ?? [],
discardTiles: outTiles,
hasHu,
score: existing?.gamePlayer.score ?? 0,
},
})
})
const privateHand = normalizeTiles(playerView?.hand)
if (loggedInUserId.value && playerMap.has(loggedInUserId.value)) {
const current = playerMap.get(loggedInUserId.value)
if (current) {
const selfMissingSuit = readMissingSuit(playerView)
current.roomPlayer.hand = privateHand.map((tile) => tileToText(tile))
current.roomPlayer.missingSuit = selfMissingSuit || current.roomPlayer.missingSuit
current.gamePlayer.handTiles = privateHand
current.gamePlayer.handCount = privateHand.length
current.gamePlayer.missingSuit = selfMissingSuit || current.gamePlayer.missingSuit
}
}
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 = (gameState?.scores && typeof gameState.scores === 'object'
? (gameState.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 ?? [],
handCount: gamePlayer.handCount > 0
? gamePlayer.handCount
: gamePlayer.handTiles.length > 0
? gamePlayer.handTiles.length
: (previous?.handCount ?? 0),
melds: gamePlayer.melds.length > 0 ? gamePlayer.melds : previous?.melds ?? [],
discardTiles: gamePlayer.discardTiles.length > 0 ? gamePlayer.discardTiles : previous?.discardTiles ?? [],
hasHu: gamePlayer.hasHu || previous?.hasHu || false,
score: typeof score === 'number' ? score : previous?.score ?? gamePlayer.score ?? 0,
isReady: gamePlayer.isReady,
}
})
const status =
readString(gameState ?? {}, 'status') ||
readString(room ?? {}, 'status') ||
readString(gameState ?? {}, 'phase') ||
'waiting'
const phase =
readString(gameState ?? {}, 'phase') ||
readString(room ?? {}, 'status') ||
'waiting'
const wallCount = readNumber(gameState ?? {}, 'wall_count', 'wallCount')
const dealerIndex = readNumber(gameState ?? {}, 'dealer_index', 'dealerIndex')
const currentTurnSeat = readNumber(gameState ?? {}, 'current_turn', 'currentTurn')
const currentTurnPlayerId = readString(gameState ?? {}, '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
dingQuePending.value = false
}
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(gameState?.scores)
if (scores) {
gameStore.scores = Object.fromEntries(
Object.entries(scores).filter(([, value]) => typeof value === 'number'),
) as Record<string, number>
}
gameStore.winners = readStringArray(gameState ?? {}, 'winners')
setActiveRoom({
roomId,
roomName: readString(room ?? {}, 'name', 'room_name') || activeRoom.value?.roomName || roomName.value,
gameType: readString(room ?? {}, 'game_type') || activeRoom.value?.gameType || 'chengdu',
ownerId: readString(room ?? {}, 'owner_id') || activeRoom.value?.ownerId || '',
maxPlayers: readNumber(room ?? {}, 'max_players') ?? activeRoom.value?.maxPlayers ?? 4,
playerCount: readNumber(room ?? {}, 'player_count') ?? players.length,
status,
createdAt: readString(room ?? {}, 'created_at') || activeRoom.value?.createdAt || '',
updatedAt: readString(room ?? {}, 'updated_at') || 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 map: Record<WsStatus, string> = {
connected: '已连接',
connecting: '连接中',
error: '连接异常',
idle: '未连接',
closed: '未连接',
}
return map[wsStatus.value] ?? '未连接'
})
const formattedClock = computed(() => {
return new Date(now.value).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
})
function buildWallTileImage(
seat: SeatKey,
tile: Tile | undefined,
imageType: TableTileImageType,
): string {
switch (seat) {
case 'top':
return getTopTileImage(tile, imageType, 'top')
case 'right':
return getRightTileImage(tile, imageType, 'right')
case 'left':
return getLeftTileImage(tile, imageType, 'left')
case 'bottom':
default:
if (!tile) {
return ''
}
return getBottomTileImage(tile, imageType, 'bottom')
}
}
function emptyWallSeat(): WallSeatState {
return {
tiles: [],
hasHu: false,
}
}
const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
const emptyState: Record<SeatKey, WallSeatState> = {
top: emptyWallSeat(),
right: emptyWallSeat(),
bottom: emptyWallSeat(),
left: emptyWallSeat(),
}
if (gameStore.phase === 'waiting' && myHandTiles.value.length === 0) {
return emptyState
}
for (const seat of seatViews.value) {
if (!seat.player) {
continue
}
const seatTiles: WallTileItem[] = []
const targetSeat = seat.key
if (seat.isSelf) {
sortedVisibleHandTiles.value.forEach((tile, index) => {
const src = buildWallTileImage(targetSeat, tile, 'hand')
if (!src) {
return
}
seatTiles.push({
key: `hand-${tile.id}-${index}`,
src,
alt: formatTile(tile),
imageType: 'hand',
suit: tile.suit,
})
})
} else {
for (let index = 0; index < seat.player.handCount; index += 1) {
const src = buildWallTileImage(targetSeat, undefined, 'hand')
if (!src) {
continue
}
seatTiles.push({
key: `concealed-${index}`,
src,
alt: '手牌背面',
imageType: 'hand',
})
}
}
seat.player.melds.forEach((meld, meldIndex) => {
meld.tiles.forEach((tile, tileIndex) => {
const imageType: TableTileImageType = meld.type === 'an_gang' ? 'covered' : 'exposed'
const src = buildWallTileImage(targetSeat, tile, imageType)
if (!src) {
return
}
seatTiles.push({
key: `${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`,
src,
alt: formatTile(tile),
imageType,
suit: tile.suit,
})
})
})
emptyState[targetSeat] = {
tiles: seatTiles,
hasHu: seat.player.hasHu,
}
}
return emptyState
})
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
const defaultMissingSuitLabel = missingSuitLabel(null)
const emptySeat = (): SeatPlayerCardModel => ({
avatarUrl: '',
name: '空位',
dealer: false,
isTurn: false,
isReady: false,
missingSuitLabel: defaultMissingSuitLabel,
})
const result: Record<SeatKey, SeatPlayerCardModel> = {
top: emptySeat(),
right: emptySeat(),
bottom: emptySeat(),
left: emptySeat(),
}
for (const seat of seatViews.value) {
if (!seat.player) {
continue
}
const displayName = seat.player.displayName || `玩家${seat.player.seatIndex + 1}`
const avatarUrl = seat.isSelf
? (localCachedAvatarUrl.value || seat.player.avatarURL || '')
: (seat.player.avatarURL || '')
const selfDisplayName = seat.player.displayName || loggedInUserName.value || '你自己'
result[seat.key] = {
avatarUrl,
name: Array.from(seat.isSelf ? selfDisplayName : displayName).slice(0, 4).join(''),
dealer: seat.player.seatIndex === dealerIndex,
isTurn: seat.isTurn,
isReady: Boolean(seat.player.isReady),
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
}
}
return result
})
function missingSuitLabel(value: string | null | undefined): string {
const suitMap: Record<string, string> = {
w: '万',
t: '筒',
b: '条',
wan: '万',
tong: '筒',
tiao: '条',
}
if (!value) {
return ''
}
const normalized = value.trim().toLowerCase()
return suitMap[normalized] ?? value
}
function toggleMenu(): void {
menuTriggerActive.value = true
if (menuTriggerTimer !== null) {
window.clearTimeout(menuTriggerTimer)
}
menuTriggerTimer = window.setTimeout(() => {
menuTriggerActive.value = false
menuTriggerTimer = null
}, 180)
if (menuOpen.value) {
menuOpen.value = false
return
}
if (menuOpenTimer !== null) {
window.clearTimeout(menuOpenTimer)
}
menuOpenTimer = window.setTimeout(() => {
menuOpen.value = true
menuOpenTimer = null
}, 85)
}
function toggleTrustMode(): void {
isTrustMode.value = !isTrustMode.value
menuOpen.value = false
}
function formatTile(tile: Tile): string {
return `${tile.suit}${tile.value}`
}
function handlePlayerHandResponse(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string') {
return
}
if (normalizeWsType(source.type) !== 'PLAYER_HAND') {
return
}
const payload = asRecord(source.payload)
if (!payload) {
return
}
const roomId =
readString(payload, 'room_id', 'roomId') ||
readString(source, 'roomId')
if (roomId && gameStore.roomId && roomId !== gameStore.roomId) {
return
}
const handTiles = normalizeTiles(payload.hand)
if (!loggedInUserId.value || handTiles.length === 0) {
return
}
const existingPlayer = gameStore.players[loggedInUserId.value]
if (existingPlayer) {
existingPlayer.handTiles = handTiles
existingPlayer.handCount = handTiles.length
}
dingQuePending.value = false
const room = activeRoom.value
if (room && room.roomId === (roomId || gameStore.roomId)) {
room.myHand = handTiles.map((tile) => tileToText(tile))
const roomPlayer = room.players.find((item) => item.playerId === loggedInUserId.value)
if (roomPlayer) {
roomPlayer.hand = room.myHand
}
}
discardPending.value = false
if (gameStore.phase !== 'waiting') {
startGamePending.value = false
}
}
function toGameAction(message: unknown): GameAction | null {
if (!message || typeof message !== 'object') {
return null
}
const source = message as Record<string, unknown>
if (typeof source.type !== 'string') {
return null
}
const type = normalizeWsType(source.type)
const payload = source.payload
switch (type) {
case 'GAME_INIT':
if (payload && typeof payload === 'object') {
return {type: 'GAME_INIT', payload: payload as GameActionPayload<'GAME_INIT'>}
}
return null
case 'GAME_START':
if (payload && typeof payload === 'object') {
return {type: 'GAME_START', payload: payload as GameActionPayload<'GAME_START'>}
}
return null
case 'DRAW_TILE':
if (payload && typeof payload === 'object') {
return {type: 'DRAW_TILE', payload: payload as GameActionPayload<'DRAW_TILE'>}
}
return null
case 'PLAY_TILE':
if (payload && typeof payload === 'object') {
return {type: 'PLAY_TILE', payload: payload as GameActionPayload<'PLAY_TILE'>}
}
return null
case 'PENDING_CLAIM':
if (payload && typeof payload === 'object') {
return {type: 'PENDING_CLAIM', payload: payload as GameActionPayload<'PENDING_CLAIM'>}
}
return null
case 'CLAIM_RESOLVED':
if (payload && typeof payload === 'object') {
return {type: 'CLAIM_RESOLVED', payload: payload as GameActionPayload<'CLAIM_RESOLVED'>}
}
return null
case 'ROOM_PLAYER_UPDATE':
if (payload && typeof payload === 'object') {
return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameActionPayload<'ROOM_PLAYER_UPDATE'>}
}
return null
case 'ROOM_MEMBER_JOINED':
if (payload && typeof payload === 'object') {
return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameActionPayload<'ROOM_PLAYER_UPDATE'>}
}
return null
default:
return null
}
}
function handleReadyStateResponse(message: unknown): void {
if (!message || typeof message !== 'object') {
return
}
const source = message as Record<string, unknown>
if (typeof source.type !== 'string') {
return
}
const type = normalizeWsType(source.type)
if (type !== 'SET_READY') {
return
}
const payload = source.payload
if (!payload || typeof payload !== 'object') {
return
}
const readyPayload = payload as Record<string, unknown>
const roomId =
typeof readyPayload.room_id === 'string'
? readyPayload.room_id
: typeof source.roomId === 'string'
? source.roomId
: ''
const userId =
typeof readyPayload.user_id === 'string'
? readyPayload.user_id
: typeof source.target === 'string'
? source.target
: ''
const ready = readyPayload.ready
if (roomId && roomId !== gameStore.roomId) {
return
}
if (typeof ready === 'boolean' && userId) {
applyPlayerReadyState(userId, ready)
}
if (userId && userId === loggedInUserId.value) {
readyTogglePending.value = false
}
}
function handlePlayerDingQueResponse(message: unknown): void {
if (!message || typeof message !== 'object') {
return
}
const source = message as Record<string, unknown>
if (typeof source.type !== 'string') {
return
}
if (normalizeWsType(source.type) !== 'PLAYER_DING_QUE') {
return
}
const payload = asRecord(source.payload)
if (!payload) {
return
}
const roomId =
readString(payload, 'room_id', 'roomId') ||
readString(source, 'roomId')
if (roomId && roomId !== gameStore.roomId) {
return
}
const userId =
readString(payload, 'user_id', 'userId', 'player_id', 'playerId') ||
readString(source, 'target')
const suit = readString(payload, 'suit', 'Suit')
if (!userId || !suit) {
return
}
const player = gameStore.players[userId]
if (player) {
player.missingSuit = suit
}
const room = activeRoom.value
if (room && room.roomId === (roomId || gameStore.roomId)) {
const roomPlayer = room.players.find((item) => item.playerId === userId)
if (roomPlayer) {
roomPlayer.missingSuit = suit
}
}
if (userId === loggedInUserId.value) {
dingQuePending.value = false
}
}
function logoutToLogin(): void {
clearAuth()
auth.value = null
wsClient.close()
void router.replace('/login')
}
function currentSession(): AuthSession | null {
const current = auth.value
if (!current?.token) {
return null
}
return {
token: current.token,
tokenType: current.tokenType,
refreshToken: current.refreshToken,
expiresIn: current.expiresIn,
}
}
function syncAuthSession(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 ensureCurrentUserLoaded(): Promise<void> {
if (loggedInUserId.value) {
return
}
const currentAuth = auth.value
const session = currentSession()
if (!session) {
return
}
try {
const userInfo = await getUserInfo(session, syncAuthSession)
const resolvedId = userInfo.userID ?? userInfo.user_id ?? userInfo.id ?? currentAuth?.user?.id
const nextUser = {
...(currentAuth?.user ?? {}),
...userInfo,
id:
typeof resolvedId === 'string' || typeof resolvedId === 'number'
? resolvedId
: undefined,
}
if (!currentAuth) {
return
}
auth.value = {
...currentAuth,
user: nextUser,
}
writeStoredAuth(auth.value)
} catch (error) {
if (error instanceof AuthExpiredError) {
logoutToLogin()
}
}
}
function decodeJwtExpMs(token: string): number | null {
const parts = token.split('.')
const payloadPart = parts[1]
if (!payloadPart) {
return null
}
try {
const normalized = payloadPart.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
const payload = JSON.parse(window.atob(padded)) as { exp?: number }
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
} catch {
return null
}
}
function shouldRefreshWsToken(token: string): boolean {
const expMs = decodeJwtExpMs(token)
if (!expMs) {
return false
}
return expMs <= Date.now() + 30_000
}
async function resolveWsToken(forceRefresh = false, logoutOnRefreshFail = false): Promise<string | null> {
const current = auth.value
if (!current?.token) {
return null
}
if (!forceRefresh && !shouldRefreshWsToken(current.token)) {
return current.token
}
if (!current.refreshToken || refreshingWsToken) {
return current.token
}
refreshingWsToken = true
try {
const refreshed = await refreshAccessToken({
token: current.token,
tokenType: current.tokenType,
refreshToken: current.refreshToken,
})
const nextAuth = {
...current,
token: refreshed.token,
tokenType: refreshed.tokenType ?? current.tokenType,
refreshToken: refreshed.refreshToken ?? current.refreshToken,
expiresIn: refreshed.expiresIn,
}
auth.value = nextAuth
writeStoredAuth(nextAuth)
return nextAuth.token
} catch {
if (logoutOnRefreshFail) {
logoutToLogin()
}
return null
} finally {
refreshingWsToken = false
}
}
async function ensureWsConnected(forceRefresh = false): Promise<void> {
const token = await resolveWsToken(forceRefresh, false)
if (!token) {
wsError.value = '未找到登录凭证,无法建立连接'
return
}
wsError.value = ''
wsClient.connect(buildWsUrl(), token)
}
async function reconnectWsInternal(forceRefresh = false): Promise<boolean> {
const token = await resolveWsToken(forceRefresh, false)
if (!token) {
wsError.value = '未找到登录凭证,无法建立连接'
return false
}
wsError.value = ''
wsClient.reconnect(buildWsUrl(), token)
return true
}
function reconnectWs(): void {
void reconnectWsInternal()
}
function backHall(): void {
leaveRoomPending.value = true
const roomId = gameStore.roomId
sendWsMessage({
type: 'leave_room',
roomId,
payload: {
room_id: roomId,
},
})
wsClient.close()
void router.push('/hall').finally(() => {
leaveRoomPending.value = false
})
}
function toggleReadyState(): void {
if (readyTogglePending.value) {
return
}
const nextReady = !myReadyState.value
readyTogglePending.value = true
console.log('[ready-toggle]', {
loggedInUserId: loggedInUserId.value,
myReadyState: myReadyState.value,
nextReady,
})
sendWsMessage({
type: 'set_ready',
roomId: gameStore.roomId,
payload: {
ready: nextReady,
},
})
}
function startGame(): void {
if (!canStartGame.value) {
return
}
startGamePending.value = true
sendWsMessage({
type: 'start_game',
roomId: gameStore.roomId,
payload: {
room_id: gameStore.roomId,
},
})
}
function chooseDingQue(suit: Tile['suit']): void {
if (dingQuePending.value || !showDingQueChooser.value) {
return
}
dingQuePending.value = true
sendWsMessage({
type: 'ding_que',
roomId: gameStore.roomId,
payload: {
suit,
},
})
}
function discardTile(tile: Tile): void {
if (discardPending.value || !canDiscardTiles.value) {
return
}
discardPending.value = true
sendWsMessage({
type: 'discard',
roomId: gameStore.roomId,
payload: {
room_id: gameStore.roomId,
tile,
},
})
}
function handleLeaveRoom(): void {
menuOpen.value = false
backHall()
}
function handleGlobalClick(event: MouseEvent): void {
const target = event.target as HTMLElement | null
if (!target) {
return
}
if (target.closest('.menu-trigger-wrap')) {
return
}
menuOpen.value = false
}
function handleGlobalEsc(event: KeyboardEvent): void {
if (event.key === 'Escape') {
menuOpen.value = false
}
}
function hydrateFromActiveRoom(routeRoomId: string): void {
const room = activeRoom.value
if (!room) {
return
}
const targetRoomId = routeRoomId || room.roomId
if (!targetRoomId || room.roomId !== targetRoomId) {
return
}
gameStore.roomId = room.roomId
const phaseMap: Record<string, typeof gameStore.phase> = {
waiting: 'waiting',
playing: 'playing',
finished: 'settlement',
}
gameStore.phase = phaseMap[room.status] ?? gameStore.phase
const nextPlayers: Record<string, PlayerState> = {}
for (const player of room.players) {
if (!player.playerId) {
continue
}
const previous = gameStore.players[player.playerId]
nextPlayers[player.playerId] = {
playerId: player.playerId,
seatIndex: player.index,
displayName: player.displayName || player.playerId,
avatarURL: previous?.avatarURL,
missingSuit: player.missingSuit ?? previous?.missingSuit,
isReady: player.ready,
handTiles: previous?.handTiles ?? [],
handCount: previous?.handCount ?? 0,
melds: previous?.melds ?? [],
discardTiles: previous?.discardTiles ?? [],
hasHu: previous?.hasHu ?? false,
score: previous?.score ?? 0,
}
}
gameStore.players = nextPlayers
}
onMounted(() => {
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
needsInitialRoomInfo = true
void ensureCurrentUserLoaded().finally(() => {
hydrateFromActiveRoom(routeRoomId)
if (routeRoomId) {
gameStore.roomId = routeRoomId
}
if (wsStatus.value === 'connected' && needsInitialRoomInfo) {
requestRoomInfo()
}
})
const handler = (status: WsStatus) => {
wsStatus.value = status
if (status === 'connected' && needsInitialRoomInfo) {
requestRoomInfo()
}
}
wsClient.onMessage((msg: unknown) => {
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
wsMessages.value.push(`[server] ${text}`)
handleRoomInfoResponse(msg)
handleRoomStateResponse(msg)
handlePlayerHandResponse(msg)
handleReadyStateResponse(msg)
handlePlayerDingQueResponse(msg)
const gameAction = toGameAction(msg)
if (gameAction) {
dispatchGameAction(gameAction)
if (gameAction.type === 'GAME_START') {
startGamePending.value = false
}
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === loggedInUserId.value) {
discardPending.value = false
}
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
syncReadyStatesFromRoomUpdate(gameAction.payload)
readyTogglePending.value = false
}
}
})
wsClient.onError((message: string) => {
wsError.value = message
wsMessages.value.push(`[error] ${message}`)
// WebSocket 握手失败时浏览器拿不到 401 状态码,统一按需强制刷新 token 后重连一次
const nowMs = Date.now()
if (nowMs - lastForcedRefreshAt > 5000) {
lastForcedRefreshAt = nowMs
void resolveWsToken(true, true).then((refreshedToken) => {
if (!refreshedToken) {
return
}
wsError.value = ''
wsClient.reconnect(buildWsUrl(), refreshedToken)
}).catch(() => {
logoutToLogin()
})
}
})
unsubscribe = wsClient.onStatusChange(handler)
void ensureWsConnected()
clockTimer = window.setInterval(() => {
now.value = Date.now()
}, 1000)
window.addEventListener('click', handleGlobalClick)
window.addEventListener('keydown', handleGlobalEsc)
})
onBeforeUnmount(() => {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
if (clockTimer !== null) {
window.clearInterval(clockTimer)
clockTimer = null
}
window.removeEventListener('click', handleGlobalClick)
window.removeEventListener('keydown', handleGlobalEsc)
if (menuTriggerTimer !== null) {
window.clearTimeout(menuTriggerTimer)
menuTriggerTimer = null
}
if (menuOpenTimer !== null) {
window.clearTimeout(menuOpenTimer)
menuOpenTimer = null
}
})
</script>
<template>
<section class="picture-scene">
<div class="picture-layout">
<section class="table-stage">
<img class="table-desk" :src="deskImage" alt=""/>
<div class="table-felt">
<div class="table-surface"></div>
<div class="inner-outline outer"></div>
<div class="inner-outline mid"></div>
<div class="top-left-tools">
<div class="menu-trigger-wrap">
<button
class="metal-circle menu-trigger"
:class="{ 'is-feedback': menuTriggerActive }"
type="button"
:disabled="leaveRoomPending"
@click.stop="toggleMenu"
>
<span class="menu-trigger-icon"></span>
</button>
<transition name="menu-pop">
<div v-if="menuOpen" class="menu-popover" @click.stop>
<div class="menu-list">
<button class="menu-item menu-item-delay-1" type="button" @click="toggleTrustMode">
<span class="menu-item-icon">
<img :src="robotIcon" alt=""/>
</span>
<span>{{ isTrustMode ? '取消托管' : '托管' }}</span>
</button>
<button
class="menu-item menu-item-danger menu-item-delay-2"
type="button"
:disabled="leaveRoomPending"
@click="handleLeaveRoom"
>
<span class="menu-item-icon">
<img :src="exitIcon" alt=""/>
</span>
<span>{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
</button>
</div>
</div>
</transition>
</div>
<div class="left-counter">
<span class="counter-light"></span>
<strong>{{ roomState.game?.state?.wall?.length ?? 48 }}</strong>
</div>
<span v-if="isTrustMode" class="trust-chip">托管中</span>
</div>
<div class="top-right-clock">
<div class="signal-chip">
<span class="wifi-dot" :class="`is-${wsStatus}`"></span>
<strong>{{ networkLabel }}</strong>
</div>
<span>{{ formattedClock }}</span>
</div>
<TopPlayerCard :player="seatDecor.top"/>
<RightPlayerCard :player="seatDecor.right"/>
<BottomPlayerCard :player="seatDecor.bottom"/>
<LeftPlayerCard :player="seatDecor.left"/>
<div v-if="wallSeats.top.tiles.length > 0 || wallSeats.top.hasHu" class="wall wall-top wall-live">
<img
v-for="(tile, index) in wallSeats.top.tiles"
:key="tile.key"
class="wall-live-tile"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.top.tiles[index - 1]?.suit !== tile.suit,
'is-exposed': tile.imageType !== 'hand',
}"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.top.hasHu" class="wall-hu-flag"></span>
</div>
<div v-if="wallSeats.right.tiles.length > 0 || wallSeats.right.hasHu" class="wall wall-right wall-live">
<img
v-for="(tile, index) in wallSeats.right.tiles"
:key="tile.key"
class="wall-live-tile"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.right.tiles[index - 1]?.suit !== tile.suit,
'is-exposed': tile.imageType !== 'hand',
}"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.right.hasHu" class="wall-hu-flag"></span>
</div>
<div v-if="wallSeats.bottom.tiles.length > 0 || wallSeats.bottom.hasHu" class="wall wall-bottom wall-live">
<img
v-for="(tile, index) in wallSeats.bottom.tiles"
:key="tile.key"
class="wall-live-tile"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
'is-exposed': tile.imageType !== 'hand',
}"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.bottom.hasHu" class="wall-hu-flag"></span>
</div>
<div v-if="wallSeats.left.tiles.length > 0 || wallSeats.left.hasHu" class="wall wall-left wall-live">
<img
v-for="(tile, index) in wallSeats.left.tiles"
:key="tile.key"
class="wall-live-tile"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.left.tiles[index - 1]?.suit !== tile.suit,
'is-exposed': tile.imageType !== 'hand',
}"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.left.hasHu" class="wall-hu-flag"></span>
</div>
<!-- <div class="floating-status top">-->
<!-- <img v-if="floatingMissingSuit.top" :src="floatingMissingSuit.top" alt=""/>-->
<!-- <span>{{ seatDecor.top.missingSuitLabel }}</span>-->
<!-- </div>-->
<!-- <div class="floating-status left">-->
<!-- <img v-if="floatingMissingSuit.left" :src="floatingMissingSuit.left" alt=""/>-->
<!-- <span>{{ seatDecor.left.missingSuitLabel }}</span>-->
<!-- </div>-->
<!-- <div class="floating-status right">-->
<!-- <img v-if="floatingMissingSuit.right" :src="floatingMissingSuit.right" alt=""/>-->
<!-- <span>{{ seatDecor.right.missingSuitLabel }}</span>-->
<!-- </div>-->
<WindSquare class="center-wind-square" :seat-winds="seatWinds"/>
<div v-if="showWaitingOwnerTip" class="waiting-owner-tip">
<span>等待房主开始游戏</span>
</div>
<div class="bottom-control-panel">
<div v-if="showDingQueChooser || showReadyToggle || showStartGameButton" class="bottom-action-bar">
<div v-if="showDingQueChooser" class="ding-que-bar">
<button
class="ding-que-button"
data-testid="ding-que-w"
type="button"
:disabled="dingQuePending"
@click="chooseDingQue('W')"
>
</button>
<button
class="ding-que-button"
data-testid="ding-que-t"
type="button"
:disabled="dingQuePending"
@click="chooseDingQue('T')"
>
</button>
<button
class="ding-que-button"
data-testid="ding-que-b"
type="button"
:disabled="dingQuePending"
@click="chooseDingQue('B')"
>
</button>
</div>
<button
v-if="showReadyToggle"
class="ready-toggle ready-toggle-inline"
data-testid="ready-toggle"
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"
data-testid="start-game"
type="button"
:disabled="!canStartGame"
@click="startGame"
>
<span class="ready-toggle-label">开始游戏</span>
</button>
</div>
<div v-if="sortedVisibleHandTiles.length > 0" class="hand-action-bar" data-testid="hand-action-bar">
<button
v-for="tile in sortedVisibleHandTiles"
:key="tile.id"
class="hand-action-tile"
:data-testid="`hand-tile-${tile.id}`"
type="button"
:disabled="!canDiscardTiles || discardPending"
@click="discardTile(tile)"
>
{{ formatTile(tile) }}
</button>
</div>
</div>
</div>
</section>
<aside class="ws-sidebar">
<div class="sidebar-head">
<div>
<p class="sidebar-title">WebSocket 消息</p>
<small>{{ networkLabel }} · {{ loggedInUserName || '未登录昵称' }}</small>
</div>
<button class="sidebar-btn" type="button" @click="reconnectWs">重连</button>
</div>
<div class="sidebar-stats">
<div class="sidebar-stat">
<span>房间</span>
<strong>{{ roomState.name || roomName || '未命名' }}</strong>
</div>
<div class="sidebar-stat">
<span>阶段</span>
<strong>{{ currentPhaseText }}</strong>
</div>
<div class="sidebar-stat">
<span>人数</span>
<strong>{{ roomState.playerCount }}/{{ roomState.maxPlayers }}</strong>
</div>
<div class="sidebar-stat">
<span>状态</span>
<strong>{{ roomStatusText }}</strong>
</div>
</div>
<p v-if="wsError" class="sidebar-error">{{ wsError }}</p>
<div class="sidebar-log">
<p v-if="rightMessages.length === 0" class="sidebar-empty">等待服务器消息...</p>
<p v-for="(line, index) in rightMessages" :key="index" class="sidebar-line">{{ line }}</p>
</div>
</aside>
</div>
</section>
</template>