- 在actions.ts中增加available_actions相关字段支持 - 移除未使用的watch导入和相关逻辑 - 新增selfTurnAllowActions响应式变量存储当前回合可执行动作 - 实现settlementOverlayDismissed控制结算弹窗显示状态 - 修改showSettlementOverlay计算属性加入弹窗已关闭条件判断 - 使用canSelfGang计算属性替代原有的concealedGangCandidates逻辑 - 新增readPlayerTurnAllowActions函数解析玩家回合允许的动作列表 - 实现readMissingSuitWithPresence函数增强缺门花色字段检测逻辑 - 更新玩家数据处理逻辑以兼容新的字段结构变化 - 调整游戏阶段映射增加ding_que到playing的转换支持 - 实现resetRoundStateForNextTurn函数重置回合状态 - 更新handlePlayerTurn消息处理逻辑 - 优化nextRound函数逻辑并设置结算弹窗为已关闭状态 - 简化submitSelfGang函数移除传入参数依赖 - 调整UI渲染逻辑适配新的动作权限控制模式
3274 lines
95 KiB
Vue
3274 lines
95 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_1920_945.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, PlayerTurnPayload, RoomPlayerUpdatePayload, RoomTrusteePayload} 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 {ClaimOptionState, MeldState, PendingClaimState, 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
|
|
isGroupStart?: boolean
|
|
showLackTag?: boolean
|
|
suit?: Tile['suit']
|
|
tile?: Tile
|
|
}
|
|
|
|
interface WallSeatState {
|
|
tiles: WallTileItem[]
|
|
}
|
|
|
|
interface DeskSeatState {
|
|
tiles: WallTileItem[]
|
|
hasHu: boolean
|
|
}
|
|
|
|
interface SeatViewModel {
|
|
key: SeatKey
|
|
player?: DisplayPlayer
|
|
isSelf: boolean
|
|
isTurn: boolean
|
|
}
|
|
|
|
interface PlayerActionTimer {
|
|
playerIds: string[]
|
|
actionDeadlineAt?: string | null
|
|
countdownSeconds: number
|
|
duration: number
|
|
remaining: number
|
|
}
|
|
|
|
const now = ref(Date.now())
|
|
const wsStatus = ref<WsStatus>('idle')
|
|
const wsMessages = ref<string[]>([])
|
|
const wsError = ref('')
|
|
const roomCountdown = ref<PlayerActionTimer | null>(null)
|
|
const leaveRoomPending = ref(false)
|
|
const readyTogglePending = ref(false)
|
|
const startGamePending = ref(false)
|
|
const dingQuePending = ref(false)
|
|
const discardPending = ref(false)
|
|
const claimActionPending = ref(false)
|
|
const turnActionPending = ref(false)
|
|
const selfTurnAllowActions = ref<string[]>([])
|
|
const nextRoundPending = ref(false)
|
|
const settlementOverlayDismissed = ref(false)
|
|
const settlementDeadlineMs = ref<number | null>(null)
|
|
const selectedDiscardTileId = ref<number | null>(null)
|
|
let clockTimer: number | null = null
|
|
let discardPendingTimer: number | null = null
|
|
let turnActionPendingTimer: 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 currentTurnSeat = computed<SeatKey | ''>(() => {
|
|
const turnSeat = seatViews.value.find((seat) => seat.isTurn)
|
|
return turnSeat?.key ?? ''
|
|
})
|
|
|
|
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 roundText = computed(() => {
|
|
if (gameStore.totalRounds > 0) {
|
|
return `${gameStore.currentRound}/${gameStore.totalRounds}`
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const showSettlementOverlay = computed(() => {
|
|
return gameStore.phase === 'settlement' && !settlementOverlayDismissed.value
|
|
})
|
|
|
|
const isLastRound = computed(() => {
|
|
return gameStore.currentRound >= gameStore.totalRounds && gameStore.totalRounds > 0
|
|
})
|
|
|
|
const settlementPlayers = computed(() => {
|
|
const players = Object.values(gameStore.players)
|
|
const winnerSet = new Set(gameStore.winners)
|
|
return players
|
|
.map((player) => ({
|
|
playerId: player.playerId,
|
|
displayName: player.displayName || `玩家${player.seatIndex + 1}`,
|
|
score: gameStore.scores[player.playerId] ?? 0,
|
|
isWinner: winnerSet.has(player.playerId),
|
|
seatIndex: player.seatIndex,
|
|
}))
|
|
.sort((a, b) => b.score - a.score)
|
|
})
|
|
|
|
const settlementCountdown = computed(() => {
|
|
if (!showSettlementOverlay.value || !settlementDeadlineMs.value) {
|
|
return null
|
|
}
|
|
const remaining = Math.max(0, Math.ceil((settlementDeadlineMs.value - now.value) / 1000))
|
|
return remaining
|
|
})
|
|
|
|
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 hasRoundStarted = computed(() => {
|
|
return gamePlayers.value.some((player) => {
|
|
return (
|
|
player.handCount > 0 ||
|
|
player.handTiles.length > 0 ||
|
|
player.melds.length > 0 ||
|
|
player.discardTiles.length > 0
|
|
)
|
|
})
|
|
})
|
|
|
|
const showStartGameButton = computed(() => {
|
|
return gameStore.phase === 'waiting' && allPlayersReady.value && !hasRoundStarted.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 || hasRoundStarted.value) {
|
|
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 selectedDiscardTile = computed(() => {
|
|
const player = myPlayer.value
|
|
if (!player || selectedDiscardTileId.value === null) {
|
|
return null
|
|
}
|
|
|
|
return player.handTiles.find((tile) => tile.id === selectedDiscardTileId.value) ?? null
|
|
})
|
|
|
|
const hasMissingSuitTiles = computed(() => {
|
|
const player = myPlayer.value
|
|
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
|
|
if (!player || !missingSuit) {
|
|
return false
|
|
}
|
|
|
|
return player.handTiles.some((tile) => tile.suit === missingSuit)
|
|
})
|
|
|
|
const discardBlockedReason = computed(() => {
|
|
const player = myPlayer.value
|
|
if (!player || !gameStore.roomId) {
|
|
return '未进入房间'
|
|
}
|
|
|
|
if (wsStatus.value !== 'connected') {
|
|
return 'WebSocket 未连接'
|
|
}
|
|
|
|
if (showDingQueChooser.value) {
|
|
return '请先完成定缺'
|
|
}
|
|
|
|
if (gameStore.phase !== 'playing') {
|
|
return '当前不是出牌阶段'
|
|
}
|
|
|
|
if (player.seatIndex !== gameStore.currentTurn) {
|
|
return '未轮到你出牌'
|
|
}
|
|
|
|
if (gameStore.needDraw) {
|
|
return '请先摸牌'
|
|
}
|
|
|
|
if (gameStore.pendingClaim) {
|
|
return '等待当前操作结算'
|
|
}
|
|
|
|
if (player.handTiles.length === 0) {
|
|
return '当前没有可出的手牌'
|
|
}
|
|
|
|
if (discardPending.value) {
|
|
return '正在提交出牌'
|
|
}
|
|
|
|
return ''
|
|
})
|
|
|
|
function discardTileBlockedReason(tile: Tile): string {
|
|
if (discardBlockedReason.value) {
|
|
return discardBlockedReason.value
|
|
}
|
|
|
|
const player = myPlayer.value
|
|
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
|
|
if (player && missingSuit && hasMissingSuitTiles.value && tile.suit !== missingSuit) {
|
|
return `当前必须先打${missingSuitLabel(missingSuit)}牌`
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
const canConfirmDiscard = computed(() => {
|
|
const tile = selectedDiscardTile.value
|
|
if (!tile) {
|
|
return false
|
|
}
|
|
return !discardTileBlockedReason(tile)
|
|
})
|
|
|
|
const confirmDiscardLabel = computed(() => {
|
|
const tile = selectedDiscardTile.value
|
|
return tile ? `出牌 ${formatTile(tile)}` : '出牌'
|
|
})
|
|
|
|
const canDrawTile = computed(() => {
|
|
const player = myPlayer.value
|
|
if (!player || !gameStore.roomId) {
|
|
return false
|
|
}
|
|
|
|
if (gameStore.phase !== 'playing') {
|
|
return false
|
|
}
|
|
|
|
if (!gameStore.needDraw) {
|
|
return false
|
|
}
|
|
|
|
return player.seatIndex === gameStore.currentTurn
|
|
})
|
|
|
|
const myClaimState = computed<PendingClaimState | undefined>(() => {
|
|
return gameStore.pendingClaim
|
|
})
|
|
|
|
const visibleClaimOptions = computed<ClaimOptionState[]>(() => {
|
|
const options = myClaimState.value?.options ?? []
|
|
const order: ClaimOptionState[] = ['hu', 'gang', 'peng', 'pass']
|
|
return order.filter((option) => options.includes(option))
|
|
})
|
|
|
|
const showClaimActions = computed(() => {
|
|
return visibleClaimOptions.value.length > 0
|
|
})
|
|
|
|
const canSelfHu = computed(() => {
|
|
const player = myPlayer.value
|
|
if (!player || !gameStore.roomId || wsStatus.value !== 'connected') {
|
|
return false
|
|
}
|
|
|
|
if (showDingQueChooser.value || gameStore.phase !== 'playing' || gameStore.needDraw || gameStore.pendingClaim) {
|
|
return false
|
|
}
|
|
|
|
if (player.seatIndex !== gameStore.currentTurn || turnActionPending.value) {
|
|
return false
|
|
}
|
|
|
|
return selfTurnAllowActions.value.includes('hu')
|
|
})
|
|
|
|
const canSelfGang = computed(() => {
|
|
const player = myPlayer.value
|
|
if (!player || !gameStore.roomId || wsStatus.value !== 'connected') {
|
|
return false
|
|
}
|
|
|
|
if (showDingQueChooser.value || gameStore.phase !== 'playing' || gameStore.needDraw || gameStore.pendingClaim) {
|
|
return false
|
|
}
|
|
|
|
if (player.seatIndex !== gameStore.currentTurn || turnActionPending.value) {
|
|
return false
|
|
}
|
|
|
|
return selfTurnAllowActions.value.includes('gang')
|
|
})
|
|
|
|
const actionCountdown = computed(() => {
|
|
const countdown = roomCountdown.value
|
|
if (!countdown) {
|
|
return null
|
|
}
|
|
|
|
const deadlineAt = countdown.actionDeadlineAt ? Date.parse(countdown.actionDeadlineAt) : Number.NaN
|
|
const fallbackRemaining = countdown.remaining > 0 ? countdown.remaining : countdown.countdownSeconds
|
|
const derivedRemaining = Number.isFinite(deadlineAt)
|
|
? Math.ceil((deadlineAt - now.value) / 1000)
|
|
: fallbackRemaining
|
|
const remaining = Math.max(0, derivedRemaining)
|
|
|
|
if (remaining <= 0) {
|
|
return null
|
|
}
|
|
|
|
const targetPlayerIds = countdown.playerIds.filter((playerId) => typeof playerId === 'string' && playerId.trim())
|
|
if (targetPlayerIds.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const playerLabel = targetPlayerIds
|
|
.map((playerId) => {
|
|
if (playerId === loggedInUserId.value) {
|
|
return '你'
|
|
}
|
|
|
|
const targetPlayer = gameStore.players[playerId]
|
|
if (targetPlayer?.displayName) {
|
|
return targetPlayer.displayName
|
|
}
|
|
if (targetPlayer) {
|
|
return `玩家${targetPlayer.seatIndex + 1}`
|
|
}
|
|
return '玩家'
|
|
})
|
|
.join('、')
|
|
const duration = countdown.duration > 0 ? countdown.duration : Math.max(remaining, fallbackRemaining, 1)
|
|
const includesSelf = targetPlayerIds.includes(loggedInUserId.value)
|
|
|
|
return {
|
|
playerLabel,
|
|
remaining,
|
|
duration,
|
|
isSelf: includesSelf,
|
|
progress: Math.max(0, Math.min(100, (remaining / duration) * 100)),
|
|
}
|
|
})
|
|
|
|
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 normalizeTimestampMs(value: number | null): number | null {
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
return null
|
|
}
|
|
|
|
return value >= 1_000_000_000_000 ? value : value * 1000
|
|
}
|
|
|
|
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
|
|
}
|
|
if (typeof value === 'number') {
|
|
if (value === 1) {
|
|
return true
|
|
}
|
|
if (value === 0) {
|
|
return false
|
|
}
|
|
}
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().toLowerCase()
|
|
if (normalized === 'true' || normalized === '1') {
|
|
return true
|
|
}
|
|
if (normalized === 'false' || normalized === '0') {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function readPlayerTurnPlayerId(payload: PlayerTurnPayload): string {
|
|
return (
|
|
(typeof payload.player_id === 'string' && payload.player_id) ||
|
|
(typeof payload.playerId === 'string' && payload.playerId) ||
|
|
(typeof payload.PlayerID === 'string' && payload.PlayerID) ||
|
|
''
|
|
)
|
|
}
|
|
|
|
function readPlayerTurnAllowActions(payload: PlayerTurnPayload): string[] {
|
|
const source =
|
|
payload.allow_actions ??
|
|
payload.allowActions ??
|
|
payload.AllowActions ??
|
|
payload.available_actions ??
|
|
payload.availableActions ??
|
|
payload.AvailableActions
|
|
if (!Array.isArray(source)) {
|
|
return []
|
|
}
|
|
|
|
const actions = source
|
|
.filter((item): item is string => typeof item === 'string')
|
|
.map((item) => item.trim().toLowerCase())
|
|
.filter((item) => item.length > 0)
|
|
return Array.from(new Set(actions))
|
|
}
|
|
|
|
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 readMissingSuitWithPresence(
|
|
source: Record<string, unknown> | null | undefined,
|
|
): { present: boolean, value: string | null } {
|
|
if (!source) {
|
|
return { present: false, value: null }
|
|
}
|
|
|
|
const keys = ['missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit']
|
|
const hasMissingSuitField = keys.some((key) => Object.prototype.hasOwnProperty.call(source, key))
|
|
if (!hasMissingSuitField) {
|
|
return { present: false, value: null }
|
|
}
|
|
|
|
return { present: true, value: readMissingSuit(source) }
|
|
}
|
|
|
|
function tileToText(tile: Tile): string {
|
|
return `${tile.suit}${tile.value}`
|
|
}
|
|
|
|
function clearTurnActionPending(): void {
|
|
turnActionPending.value = false
|
|
if (turnActionPendingTimer !== null) {
|
|
window.clearTimeout(turnActionPendingTimer)
|
|
turnActionPendingTimer = null
|
|
}
|
|
}
|
|
|
|
function markTurnActionPending(kind: 'gang' | 'hu'): void {
|
|
clearTurnActionPending()
|
|
turnActionPending.value = true
|
|
turnActionPendingTimer = window.setTimeout(() => {
|
|
turnActionPending.value = false
|
|
turnActionPendingTimer = null
|
|
wsError.value = `${kind === 'gang' ? '杠牌' : '胡牌'}未收到服务器确认`
|
|
}, 2500)
|
|
}
|
|
|
|
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 normalizePendingClaim(gameState: Record<string, unknown> | null | undefined): PendingClaimState | undefined {
|
|
if (!gameState || !loggedInUserId.value) {
|
|
return undefined
|
|
}
|
|
|
|
const pendingClaim = asRecord(gameState.pending_claim ?? gameState.pendingClaim)
|
|
if (!pendingClaim) {
|
|
return undefined
|
|
}
|
|
|
|
const selfOptions = asRecord(pendingClaim[loggedInUserId.value])
|
|
if (!selfOptions) {
|
|
return undefined
|
|
}
|
|
|
|
const options: ClaimOptionState[] = []
|
|
if (readBoolean(selfOptions, 'hu')) {
|
|
options.push('hu')
|
|
}
|
|
if (readBoolean(selfOptions, 'gang')) {
|
|
options.push('gang')
|
|
}
|
|
if (readBoolean(selfOptions, 'peng')) {
|
|
options.push('peng')
|
|
}
|
|
if (options.length === 0) {
|
|
return undefined
|
|
}
|
|
|
|
options.push('pass')
|
|
|
|
return {
|
|
tile: normalizeTile(gameState.last_discard_tile ?? gameState.lastDiscardTile) ?? undefined,
|
|
fromPlayerId: readString(gameState, 'last_discard_by', 'lastDiscardBy') || undefined,
|
|
options,
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
if (Array.isArray(item)) {
|
|
const tiles = normalizeTiles(item)
|
|
if (tiles.length === 3) {
|
|
return {
|
|
type: 'peng',
|
|
tiles,
|
|
fromPlayerId: '',
|
|
} satisfies MeldState
|
|
}
|
|
if (tiles.length === 4) {
|
|
return {
|
|
type: 'ming_gang',
|
|
tiles,
|
|
fromPlayerId: '',
|
|
} satisfies MeldState
|
|
}
|
|
return null
|
|
}
|
|
|
|
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 explicitType = normalizeMeldType(
|
|
source.type ?? source.meld_type ?? source.meldType ?? source.kind,
|
|
concealed,
|
|
)
|
|
const type = explicitType ?? (tiles.length === 4
|
|
? (concealed ? 'an_gang' : 'ming_gang')
|
|
: tiles.length === 3
|
|
? 'peng'
|
|
: null)
|
|
|
|
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 = readMissingSuitWithPresence(player)
|
|
const scores = asRecord(payload.scores)
|
|
const score = scores?.[playerId]
|
|
|
|
nextPlayers[playerId] = {
|
|
playerId,
|
|
seatIndex,
|
|
displayName: previous?.displayName ?? playerId,
|
|
avatarURL: previous?.avatarURL,
|
|
isTrustee: previous?.isTrustee ?? false,
|
|
missingSuit: dingQue.present ? dingQue.value : (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',
|
|
ding_que: 'playing',
|
|
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
|
|
}
|
|
gameStore.needDraw = readBoolean(payload, 'need_draw', 'needDraw') ?? false
|
|
|
|
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')
|
|
const currentRound = readNumber(payload, 'current_round', 'currentRound')
|
|
const totalRounds = readNumber(payload, 'total_rounds', 'totalRounds')
|
|
if (typeof currentRound === 'number') {
|
|
gameStore.currentRound = currentRound
|
|
}
|
|
if (typeof totalRounds === 'number') {
|
|
gameStore.totalRounds = totalRounds
|
|
}
|
|
const sdMs = readNumber(payload, 'settlement_deadline_ms', 'settlementDeadlineMs')
|
|
if (typeof sdMs === 'number' && sdMs > 0) {
|
|
settlementDeadlineMs.value = sdMs
|
|
} else if (phase !== 'settlement') {
|
|
settlementDeadlineMs.value = null
|
|
}
|
|
gameStore.pendingClaim = normalizePendingClaim(payload)
|
|
if (!gameStore.pendingClaim) {
|
|
claimActionPending.value = false
|
|
}
|
|
clearTurnActionPending()
|
|
|
|
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 (phase !== 'settlement') {
|
|
nextRoundPending.value = false
|
|
settlementOverlayDismissed.value = false
|
|
settlementDeadlineMs.value = null
|
|
}
|
|
if (phase !== 'playing' || currentTurnPlayerId !== loggedInUserId.value) {
|
|
selfTurnAllowActions.value = []
|
|
}
|
|
if (currentTurnPlayerId && currentTurnPlayerId !== loggedInUserId.value) {
|
|
markDiscardCompleted()
|
|
}
|
|
}
|
|
|
|
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
|
|
syncCurrentUserID(readString(source, 'target'))
|
|
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
|
|
trustee: boolean
|
|
hand: string[]
|
|
melds: string[]
|
|
outTiles: string[]
|
|
hasHu: boolean
|
|
}
|
|
gamePlayer: {
|
|
playerId: string
|
|
seatIndex: number
|
|
displayName?: string
|
|
avatarURL?: string
|
|
missingSuit?: string | null
|
|
isReady: boolean
|
|
isTrustee: 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,
|
|
trustee: false,
|
|
hand: [],
|
|
melds: [],
|
|
outTiles: [],
|
|
hasHu: false,
|
|
},
|
|
gamePlayer: {
|
|
playerId,
|
|
seatIndex,
|
|
displayName: displayName || undefined,
|
|
avatarURL: readString(player, 'avatar_url', 'AvatarUrl', 'avatar', 'avatarUrl') || undefined,
|
|
missingSuit,
|
|
isReady: ready,
|
|
isTrustee: false,
|
|
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 = readMissingSuitWithPresence(player)
|
|
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: missingSuit.present ? missingSuit.value : (existing?.gamePlayer.missingSuit ?? null),
|
|
ready: existing?.roomPlayer.ready ?? false,
|
|
trustee: existing?.roomPlayer.trustee ?? 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: missingSuit.present ? missingSuit.value : (existing?.gamePlayer.missingSuit ?? null),
|
|
isReady: existing?.gamePlayer.isReady ?? false,
|
|
isTrustee: existing?.gamePlayer.isTrustee ?? 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 = readMissingSuitWithPresence(playerView)
|
|
current.roomPlayer.hand = privateHand.map((tile) => tileToText(tile))
|
|
if (selfMissingSuit.present) {
|
|
current.roomPlayer.missingSuit = selfMissingSuit.value
|
|
}
|
|
current.gamePlayer.handTiles = privateHand
|
|
current.gamePlayer.handCount = privateHand.length
|
|
if (selfMissingSuit.present) {
|
|
current.gamePlayer.missingSuit = selfMissingSuit.value
|
|
}
|
|
}
|
|
}
|
|
|
|
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: typeof gamePlayer.missingSuit === 'undefined' ? (previous?.missingSuit ?? null) : gamePlayer.missingSuit,
|
|
isTrustee: previous?.isTrustee ?? gamePlayer.isTrustee,
|
|
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',
|
|
ding_que: 'playing',
|
|
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
|
|
}
|
|
gameStore.needDraw = readBoolean(gameState ?? {}, 'need_draw', 'needDraw') ?? false
|
|
gameStore.pendingClaim = normalizePendingClaim(gameState)
|
|
if (!gameStore.pendingClaim) {
|
|
claimActionPending.value = false
|
|
}
|
|
clearTurnActionPending()
|
|
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')
|
|
const infoCurrentRound = readNumber(gameState ?? {}, 'current_round', 'currentRound')
|
|
const infoTotalRounds = readNumber(gameState ?? {}, 'total_rounds', 'totalRounds')
|
|
if (typeof infoCurrentRound === 'number') {
|
|
gameStore.currentRound = infoCurrentRound
|
|
}
|
|
if (typeof infoTotalRounds === 'number') {
|
|
gameStore.totalRounds = infoTotalRounds
|
|
}
|
|
const infoSdMs = readNumber(gameState ?? {}, 'settlement_deadline_ms', 'settlementDeadlineMs')
|
|
if (typeof infoSdMs === 'number' && infoSdMs > 0) {
|
|
settlementDeadlineMs.value = infoSdMs
|
|
}
|
|
if (gameStore.phase !== 'playing' || currentTurnPlayerId !== loggedInUserId.value) {
|
|
selfTurnAllowActions.value = []
|
|
}
|
|
|
|
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: [],
|
|
}
|
|
}
|
|
|
|
function emptyDeskSeat(): DeskSeatState {
|
|
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) {
|
|
const missingSuit = seat.player.missingSuit as Tile['suit'] | null | undefined
|
|
sortedVisibleHandTiles.value.forEach((tile, index) => {
|
|
const src = buildWallTileImage(targetSeat, tile, 'hand')
|
|
if (!src) {
|
|
return
|
|
}
|
|
const previousTile = index > 0 ? sortedVisibleHandTiles.value[index - 1] : undefined
|
|
const isMissingSuitGroupStart = Boolean(
|
|
missingSuit &&
|
|
tile.suit === missingSuit &&
|
|
(!previousTile || previousTile.suit !== tile.suit),
|
|
)
|
|
|
|
seatTiles.push({
|
|
key: `hand-${tile.id}-${index}`,
|
|
src,
|
|
alt: formatTile(tile),
|
|
imageType: 'hand',
|
|
showLackTag: isMissingSuitGroupStart,
|
|
suit: tile.suit,
|
|
tile,
|
|
})
|
|
})
|
|
} 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',
|
|
})
|
|
}
|
|
}
|
|
|
|
emptyState[targetSeat] = {
|
|
tiles: seatTiles,
|
|
}
|
|
}
|
|
|
|
return emptyState
|
|
})
|
|
|
|
const deskSeats = computed<Record<SeatKey, DeskSeatState>>(() => {
|
|
const emptyState: Record<SeatKey, DeskSeatState> = {
|
|
top: emptyDeskSeat(),
|
|
right: emptyDeskSeat(),
|
|
bottom: emptyDeskSeat(),
|
|
left: emptyDeskSeat(),
|
|
}
|
|
|
|
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
|
|
|
|
seat.player.discardTiles.forEach((tile, index) => {
|
|
const src = buildWallTileImage(targetSeat, tile, 'exposed')
|
|
if (!src) {
|
|
return
|
|
}
|
|
|
|
seatTiles.push({
|
|
key: `discard-${tile.id}-${index}`,
|
|
src,
|
|
alt: formatTile(tile),
|
|
imageType: 'exposed',
|
|
suit: tile.suit,
|
|
})
|
|
})
|
|
|
|
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: `desk-${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`,
|
|
src,
|
|
alt: formatTile(tile),
|
|
imageType,
|
|
isGroupStart: tileIndex === 0,
|
|
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,
|
|
isTrustee: 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),
|
|
isTrustee: Boolean(seat.player.isTrustee),
|
|
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
|
|
}
|
|
|
|
syncCurrentUserID(readString(source, 'target'))
|
|
|
|
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
|
|
}
|
|
|
|
clearTurnActionPending()
|
|
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
|
|
}
|
|
}
|
|
|
|
markDiscardCompleted()
|
|
if (gameStore.phase !== 'waiting') {
|
|
startGamePending.value = false
|
|
}
|
|
}
|
|
|
|
function handleDingQueCountdown(message: unknown): void {
|
|
const source = asRecord(message)
|
|
if (!source || typeof source.type !== 'string') {
|
|
return
|
|
}
|
|
|
|
if (normalizeWsType(source.type) !== 'DING_QUE_COUNTDOWN') {
|
|
return
|
|
}
|
|
|
|
const payload = asRecord(source.payload) ?? source
|
|
const roomId =
|
|
readString(payload, 'room_id', 'roomId') ||
|
|
readString(source, 'roomId')
|
|
if (roomId && gameStore.roomId && roomId !== gameStore.roomId) {
|
|
return
|
|
}
|
|
|
|
const playerIds = readStringArray(payload, 'player_ids', 'playerIds', 'PlayerIDs')
|
|
const fallbackPlayerId = readString(payload, 'player_id', 'playerId', 'PlayerID')
|
|
const normalizedPlayerIds = playerIds.length > 0 ? playerIds : (fallbackPlayerId ? [fallbackPlayerId] : [])
|
|
if (normalizedPlayerIds.length === 0) {
|
|
roomCountdown.value = null
|
|
return
|
|
}
|
|
|
|
const countdownSeconds = readNumber(payload, 'countdown_seconds', 'CountdownSeconds') ?? 0
|
|
const duration = readNumber(payload, 'duration', 'Duration') ?? countdownSeconds
|
|
const remaining = readNumber(payload, 'remaining', 'Remaining') ?? countdownSeconds
|
|
const actionDeadlineAt = readString(payload, 'action_deadline_at', 'ActionDeadlineAt') || null
|
|
|
|
if (countdownSeconds <= 0 && remaining <= 0 && !actionDeadlineAt) {
|
|
roomCountdown.value = null
|
|
return
|
|
}
|
|
|
|
roomCountdown.value = {
|
|
playerIds: normalizedPlayerIds,
|
|
actionDeadlineAt,
|
|
countdownSeconds,
|
|
duration,
|
|
remaining,
|
|
}
|
|
}
|
|
|
|
function applyPlayerTurnCountdown(payload: PlayerTurnPayload): void {
|
|
const playerId = readPlayerTurnPlayerId(payload)
|
|
const timeout =
|
|
(typeof payload.timeout === 'number' && Number.isFinite(payload.timeout) ? payload.timeout : null) ??
|
|
(typeof payload.Timeout === 'number' && Number.isFinite(payload.Timeout) ? payload.Timeout : null) ??
|
|
0
|
|
const startAtRaw =
|
|
(typeof payload.start_at === 'number' && Number.isFinite(payload.start_at) ? payload.start_at : null) ??
|
|
(typeof payload.startAt === 'number' && Number.isFinite(payload.startAt) ? payload.startAt : null) ??
|
|
(typeof payload.StartAt === 'number' && Number.isFinite(payload.StartAt) ? payload.StartAt : null)
|
|
|
|
if (!playerId || timeout <= 0) {
|
|
roomCountdown.value = null
|
|
return
|
|
}
|
|
|
|
const startAtMs = normalizeTimestampMs(startAtRaw)
|
|
const deadlineAtMs = startAtMs !== null ? startAtMs + timeout * 1000 : null
|
|
const remaining = deadlineAtMs !== null
|
|
? Math.max(0, Math.ceil((deadlineAtMs - now.value) / 1000))
|
|
: timeout
|
|
|
|
roomCountdown.value = {
|
|
playerIds: [playerId],
|
|
actionDeadlineAt: deadlineAtMs !== null ? new Date(deadlineAtMs).toISOString() : null,
|
|
countdownSeconds: timeout,
|
|
duration: timeout,
|
|
remaining,
|
|
}
|
|
}
|
|
|
|
function resetRoundStateForNextTurn(payload: Record<string, unknown>): void {
|
|
const nextRound = readNumber(payload, 'current_round', 'currentRound')
|
|
const totalRounds = readNumber(payload, 'total_rounds', 'totalRounds')
|
|
if (typeof nextRound !== 'number' && typeof totalRounds !== 'number') {
|
|
return
|
|
}
|
|
|
|
if (typeof nextRound === 'number') {
|
|
gameStore.currentRound = nextRound
|
|
}
|
|
if (typeof totalRounds === 'number') {
|
|
gameStore.totalRounds = totalRounds
|
|
}
|
|
|
|
nextRoundPending.value = false
|
|
settlementOverlayDismissed.value = false
|
|
settlementDeadlineMs.value = null
|
|
dingQuePending.value = false
|
|
roomCountdown.value = null
|
|
claimActionPending.value = false
|
|
selfTurnAllowActions.value = []
|
|
gameStore.pendingClaim = undefined
|
|
gameStore.winners = []
|
|
|
|
markDiscardCompleted()
|
|
clearTurnActionPending()
|
|
|
|
Object.values(gameStore.players).forEach((player) => {
|
|
player.missingSuit = null
|
|
player.hasHu = false
|
|
})
|
|
|
|
const room = activeRoom.value
|
|
if (room && room.roomId === gameStore.roomId) {
|
|
room.players.forEach((player) => {
|
|
player.missingSuit = null
|
|
player.hasHu = false
|
|
})
|
|
}
|
|
}
|
|
|
|
function handlePlayerTurn(message: unknown): void {
|
|
const source = asRecord(message)
|
|
if (!source || typeof source.type !== 'string') {
|
|
return
|
|
}
|
|
|
|
const normalizedType = normalizeWsType(source.type)
|
|
if (normalizedType !== 'PLAYER_TURN' && normalizedType !== 'NEXT_TURN') {
|
|
return
|
|
}
|
|
|
|
const payload = asRecord(source.payload) ?? source
|
|
const roomId =
|
|
readString(payload, 'room_id', 'roomId') ||
|
|
readString(source, 'roomId')
|
|
if (roomId && gameStore.roomId && roomId !== gameStore.roomId) {
|
|
return
|
|
}
|
|
|
|
resetRoundStateForNextTurn(payload)
|
|
const turnPayload = payload as PlayerTurnPayload
|
|
const turnPlayerID = readPlayerTurnPlayerId(turnPayload)
|
|
if (turnPlayerID && turnPlayerID === loggedInUserId.value) {
|
|
selfTurnAllowActions.value = readPlayerTurnAllowActions(turnPayload)
|
|
} else {
|
|
selfTurnAllowActions.value = []
|
|
}
|
|
if (normalizedType === 'PLAYER_TURN') {
|
|
applyPlayerTurnCountdown(turnPayload)
|
|
}
|
|
}
|
|
|
|
function handleActionError(message: unknown): void {
|
|
const source = asRecord(message)
|
|
if (!source || typeof source.type !== 'string') {
|
|
return
|
|
}
|
|
|
|
if (normalizeWsType(source.type) !== 'ACTION_ERROR') {
|
|
return
|
|
}
|
|
|
|
const payload = asRecord(source.payload)
|
|
const roomId =
|
|
readString(payload ?? {}, 'room_id', 'roomId') ||
|
|
readString(source, 'roomId')
|
|
if (roomId && gameStore.roomId && roomId !== gameStore.roomId) {
|
|
return
|
|
}
|
|
|
|
const action = readString(payload ?? {}, 'action') || 'unknown'
|
|
const messageText = readString(payload ?? {}, 'message') || '操作失败'
|
|
claimActionPending.value = false
|
|
clearTurnActionPending()
|
|
wsError.value = messageText
|
|
wsMessages.value.push(`[action-error] ${action}: ${messageText}`)
|
|
}
|
|
|
|
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 'PENG':
|
|
case 'GANG':
|
|
case 'HU':
|
|
case 'PASS': {
|
|
const resolvedPayload = asRecord(payload)
|
|
const playerId =
|
|
readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') ||
|
|
readString(source, 'target')
|
|
const action = type.toLowerCase()
|
|
if (!playerId || (action !== 'peng' && action !== 'gang' && action !== 'hu' && action !== 'pass')) {
|
|
return null
|
|
}
|
|
return {
|
|
type: 'CLAIM_RESOLVED',
|
|
payload: {
|
|
playerId,
|
|
action,
|
|
},
|
|
}
|
|
}
|
|
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
|
|
case 'ROOM_TRUSTEE':
|
|
if (payload && typeof payload === 'object') {
|
|
return {type: 'ROOM_TRUSTEE', payload: payload as GameActionPayload<'ROOM_TRUSTEE'>}
|
|
}
|
|
return {
|
|
type: 'ROOM_TRUSTEE',
|
|
payload: source as unknown as GameActionPayload<'ROOM_TRUSTEE'>,
|
|
}
|
|
case 'PLAYER_TURN':
|
|
case 'NEXT_TURN':
|
|
if (payload && typeof payload === 'object') {
|
|
return {type: 'PLAYER_TURN', payload: payload as GameActionPayload<'PLAYER_TURN'>}
|
|
}
|
|
return {
|
|
type: 'PLAYER_TURN',
|
|
payload: source as unknown as GameActionPayload<'PLAYER_TURN'>,
|
|
}
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function syncTrusteeState(payload: RoomTrusteePayload): void {
|
|
const playerId =
|
|
(typeof payload.player_id === 'string' && payload.player_id) ||
|
|
(typeof payload.playerId === 'string' && payload.playerId) ||
|
|
''
|
|
if (!playerId) {
|
|
return
|
|
}
|
|
|
|
const trustee = typeof payload.trustee === 'boolean' ? payload.trustee : true
|
|
if (playerId === loggedInUserId.value) {
|
|
isTrustMode.value = trustee
|
|
}
|
|
|
|
const room = activeRoom.value
|
|
if (!room || room.roomId !== gameStore.roomId) {
|
|
return
|
|
}
|
|
|
|
const roomPlayer = room.players.find((item) => item.playerId === playerId)
|
|
if (roomPlayer) {
|
|
roomPlayer.trustee = trustee
|
|
}
|
|
}
|
|
|
|
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 = readBoolean(readyPayload, 'ready', 'Ready')
|
|
|
|
if (roomId && roomId !== gameStore.roomId) {
|
|
return
|
|
}
|
|
|
|
if (ready !== null && 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)
|
|
}
|
|
|
|
function syncCurrentUserID(userID: string): void {
|
|
if (!userID || loggedInUserId.value) {
|
|
return
|
|
}
|
|
|
|
const currentAuth = auth.value
|
|
if (!currentAuth) {
|
|
return
|
|
}
|
|
|
|
auth.value = {
|
|
...currentAuth,
|
|
user: {
|
|
...(currentAuth.user ?? {}),
|
|
id: userID,
|
|
},
|
|
}
|
|
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)
|
|
}
|
|
|
|
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,
|
|
isReady: nextReady,
|
|
},
|
|
})
|
|
}
|
|
|
|
function startGame(): void {
|
|
if (!canStartGame.value) {
|
|
return
|
|
}
|
|
|
|
startGamePending.value = true
|
|
sendWsMessage({
|
|
type: 'start_game',
|
|
roomId: gameStore.roomId,
|
|
payload: {
|
|
room_id: gameStore.roomId,
|
|
},
|
|
})
|
|
}
|
|
|
|
function nextRound(): void {
|
|
if (nextRoundPending.value || !gameStore.roomId || gameStore.phase !== 'settlement') {
|
|
return
|
|
}
|
|
|
|
settlementOverlayDismissed.value = true
|
|
nextRoundPending.value = true
|
|
sendWsMessage({
|
|
type: 'next_round',
|
|
roomId: gameStore.roomId,
|
|
payload: {},
|
|
})
|
|
}
|
|
|
|
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 clearDiscardPendingTimer(): void {
|
|
if (discardPendingTimer !== null) {
|
|
window.clearTimeout(discardPendingTimer)
|
|
discardPendingTimer = null
|
|
}
|
|
}
|
|
|
|
function markDiscardCompleted(): void {
|
|
clearDiscardPendingTimer()
|
|
discardPending.value = false
|
|
selectedDiscardTileId.value = null
|
|
}
|
|
|
|
function markDiscardPendingWithFallback(): void {
|
|
clearDiscardPendingTimer()
|
|
discardPending.value = true
|
|
discardPendingTimer = window.setTimeout(() => {
|
|
discardPending.value = false
|
|
selectedDiscardTileId.value = null
|
|
discardPendingTimer = null
|
|
}, 2000)
|
|
}
|
|
|
|
function selectDiscardTile(tile: Tile): void {
|
|
const blockedReason = discardTileBlockedReason(tile)
|
|
if (blockedReason) {
|
|
wsError.value = blockedReason
|
|
wsMessages.value.push(`[client-blocked] select ${formatTile(tile)}: ${blockedReason}`)
|
|
selectedDiscardTileId.value = null
|
|
return
|
|
}
|
|
|
|
wsError.value = ''
|
|
selectedDiscardTileId.value = selectedDiscardTileId.value === tile.id ? null : tile.id
|
|
}
|
|
|
|
function confirmDiscard(): void {
|
|
const tile = selectedDiscardTile.value
|
|
if (!tile) {
|
|
return
|
|
}
|
|
|
|
const blockedReason = discardTileBlockedReason(tile)
|
|
if (blockedReason || !gameStore.roomId) {
|
|
if (blockedReason) {
|
|
wsError.value = blockedReason
|
|
wsMessages.value.push(`[client-blocked] discard ${formatTile(tile)}: ${blockedReason}`)
|
|
}
|
|
return
|
|
}
|
|
|
|
wsError.value = ''
|
|
markDiscardPendingWithFallback()
|
|
sendWsMessage({
|
|
type: 'discard',
|
|
roomId: gameStore.roomId,
|
|
payload: {
|
|
tile: {
|
|
id: tile.id,
|
|
suit: tile.suit,
|
|
value: tile.value,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
function drawTile(): void {
|
|
if (!canDrawTile.value) {
|
|
return
|
|
}
|
|
|
|
sendWsMessage({
|
|
type: 'draw',
|
|
roomId: gameStore.roomId,
|
|
payload: {
|
|
room_id: gameStore.roomId,
|
|
},
|
|
})
|
|
}
|
|
|
|
function submitSelfGang(): void {
|
|
if (!gameStore.roomId || !canSelfGang.value || turnActionPending.value) {
|
|
return
|
|
}
|
|
|
|
markTurnActionPending('gang')
|
|
sendWsMessage({
|
|
type: 'gang',
|
|
roomId: gameStore.roomId,
|
|
payload: {
|
|
room_id: gameStore.roomId,
|
|
},
|
|
})
|
|
}
|
|
|
|
function submitSelfHu(): void {
|
|
if (!gameStore.roomId || !canSelfHu.value || turnActionPending.value) {
|
|
return
|
|
}
|
|
|
|
markTurnActionPending('hu')
|
|
sendWsMessage({
|
|
type: 'hu',
|
|
roomId: gameStore.roomId,
|
|
payload: {
|
|
room_id: gameStore.roomId,
|
|
},
|
|
})
|
|
}
|
|
|
|
function submitClaim(action: ClaimOptionState): void {
|
|
if (claimActionPending.value || !gameStore.roomId || !visibleClaimOptions.value.includes(action)) {
|
|
return
|
|
}
|
|
|
|
const claimTile = gameStore.pendingClaim?.tile
|
|
claimActionPending.value = true
|
|
sendWsMessage({
|
|
type: action,
|
|
roomId: gameStore.roomId,
|
|
payload: {
|
|
room_id: gameStore.roomId,
|
|
...(action !== 'pass' && claimTile
|
|
? {
|
|
tile: {
|
|
id: claimTile.id,
|
|
suit: claimTile.suit,
|
|
value: claimTile.value,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
})
|
|
}
|
|
|
|
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: typeof player.missingSuit === 'undefined' ? (previous?.missingSuit ?? null) : player.missingSuit,
|
|
isTrustee: player.trustee ?? previous?.isTrustee ?? false,
|
|
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)
|
|
handlePlayerTurn(msg)
|
|
handleActionError(msg)
|
|
handleDingQueCountdown(msg)
|
|
handleReadyStateResponse(msg)
|
|
handlePlayerDingQueResponse(msg)
|
|
const gameAction = toGameAction(msg)
|
|
if (gameAction) {
|
|
dispatchGameAction(gameAction)
|
|
if (gameAction.type === 'GAME_START') {
|
|
startGamePending.value = false
|
|
roomCountdown.value = null
|
|
}
|
|
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === loggedInUserId.value) {
|
|
markDiscardCompleted()
|
|
}
|
|
if (gameAction.type === 'PLAY_TILE' || gameAction.type === 'PENDING_CLAIM' || gameAction.type === 'CLAIM_RESOLVED') {
|
|
roomCountdown.value = null
|
|
}
|
|
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
|
|
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
|
readyTogglePending.value = false
|
|
}
|
|
if (gameAction.type === 'CLAIM_RESOLVED') {
|
|
claimActionPending.value = false
|
|
clearTurnActionPending()
|
|
}
|
|
if (gameAction.type === 'ROOM_TRUSTEE') {
|
|
syncTrusteeState(gameAction.payload)
|
|
}
|
|
if (gameAction.type === 'PLAYER_TURN' && readPlayerTurnPlayerId(gameAction.payload) !== loggedInUserId.value) {
|
|
selectedDiscardTileId.value = null
|
|
}
|
|
}
|
|
})
|
|
wsClient.onError((message: string) => {
|
|
markDiscardCompleted()
|
|
clearTurnActionPending()
|
|
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
|
|
}
|
|
clearDiscardPendingTimer()
|
|
clearTurnActionPending()
|
|
|
|
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>
|
|
|
|
<div class="room-status-panel">
|
|
<div class="room-status-grid">
|
|
<div class="room-status-item">
|
|
<span>房间</span>
|
|
<strong>{{ roomState.name || roomName || '未命名' }}</strong>
|
|
</div>
|
|
<div class="room-status-item">
|
|
<span>阶段</span>
|
|
<strong>{{ currentPhaseText }}</strong>
|
|
</div>
|
|
<div class="room-status-item">
|
|
<span>人数</span>
|
|
<strong>{{ roomState.playerCount }}/{{ roomState.maxPlayers }}</strong>
|
|
</div>
|
|
<div v-if="roundText" class="room-status-item">
|
|
<span>局数</span>
|
|
<strong>{{ roundText }}</strong>
|
|
</div>
|
|
<div class="room-status-item">
|
|
<span>状态</span>
|
|
<strong>{{ roomStatusText }}</strong>
|
|
</div>
|
|
</div>
|
|
<p v-if="wsError" class="room-status-error">{{ wsError }}</p>
|
|
</div>
|
|
|
|
<div v-if="actionCountdown" class="action-countdown" :class="{ 'is-self': actionCountdown.isSelf }">
|
|
<div class="action-countdown-head">
|
|
<span>{{ actionCountdown.playerLabel }}操作倒计时</span>
|
|
<strong>{{ actionCountdown.remaining }}s</strong>
|
|
</div>
|
|
<div class="action-countdown-track">
|
|
<span class="action-countdown-fill" :style="{ width: `${actionCountdown.progress}%` }"></span>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<TopPlayerCard :player="seatDecor.top"/>
|
|
<RightPlayerCard :player="seatDecor.right"/>
|
|
<BottomPlayerCard :player="seatDecor.bottom"/>
|
|
<LeftPlayerCard :player="seatDecor.left"/>
|
|
|
|
<div v-if="deskSeats.top.tiles.length > 0 || deskSeats.top.hasHu" class="desk-zone desk-zone-top">
|
|
<img
|
|
v-for="tile in deskSeats.top.tiles"
|
|
:key="tile.key"
|
|
class="desk-tile"
|
|
:class="{
|
|
'is-group-start': tile.isGroupStart,
|
|
'is-covered': tile.imageType === 'covered',
|
|
}"
|
|
:src="tile.src"
|
|
:alt="tile.alt"
|
|
/>
|
|
<span v-if="deskSeats.top.hasHu" class="desk-hu-flag">胡</span>
|
|
</div>
|
|
<div v-if="deskSeats.right.tiles.length > 0 || deskSeats.right.hasHu" class="desk-zone desk-zone-right">
|
|
<img
|
|
v-for="tile in deskSeats.right.tiles"
|
|
:key="tile.key"
|
|
class="desk-tile"
|
|
:class="{
|
|
'is-group-start': tile.isGroupStart,
|
|
'is-covered': tile.imageType === 'covered',
|
|
}"
|
|
:src="tile.src"
|
|
:alt="tile.alt"
|
|
/>
|
|
<span v-if="deskSeats.right.hasHu" class="desk-hu-flag">胡</span>
|
|
</div>
|
|
<div v-if="deskSeats.bottom.tiles.length > 0 || deskSeats.bottom.hasHu" class="desk-zone desk-zone-bottom">
|
|
<img
|
|
v-for="tile in deskSeats.bottom.tiles"
|
|
:key="tile.key"
|
|
class="desk-tile"
|
|
:class="{
|
|
'is-group-start': tile.isGroupStart,
|
|
'is-covered': tile.imageType === 'covered',
|
|
}"
|
|
:src="tile.src"
|
|
:alt="tile.alt"
|
|
/>
|
|
<span v-if="deskSeats.bottom.hasHu" class="desk-hu-flag">胡</span>
|
|
</div>
|
|
<div v-if="deskSeats.left.tiles.length > 0 || deskSeats.left.hasHu" class="desk-zone desk-zone-left">
|
|
<img
|
|
v-for="tile in deskSeats.left.tiles"
|
|
:key="tile.key"
|
|
class="desk-tile"
|
|
:class="{
|
|
'is-group-start': tile.isGroupStart,
|
|
'is-covered': tile.imageType === 'covered',
|
|
}"
|
|
:src="tile.src"
|
|
:alt="tile.alt"
|
|
/>
|
|
<span v-if="deskSeats.left.hasHu" class="desk-hu-flag">胡</span>
|
|
</div>
|
|
|
|
<div v-if="wallSeats.top.tiles.length > 0" 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"
|
|
/>
|
|
</div>
|
|
<div v-if="wallSeats.right.tiles.length > 0" 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"
|
|
/>
|
|
</div>
|
|
<div v-if="wallSeats.bottom.tiles.length > 0" class="wall wall-bottom wall-live">
|
|
<template v-for="(tile, index) in wallSeats.bottom.tiles" :key="tile.key">
|
|
<button
|
|
v-if="tile.tile && tile.imageType === 'hand'"
|
|
class="wall-live-tile-button"
|
|
:class="{
|
|
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
|
'is-lack-tagged': tile.showLackTag,
|
|
'is-selected': selectedDiscardTileId === tile.tile.id,
|
|
}"
|
|
:data-testid="`hand-tile-${tile.tile.id}`"
|
|
type="button"
|
|
:disabled="Boolean(discardBlockedReason)"
|
|
:title="discardTileBlockedReason(tile.tile) || formatTile(tile.tile)"
|
|
@click="selectDiscardTile(tile.tile)"
|
|
>
|
|
<span v-if="tile.showLackTag" class="wall-live-tile-lack-tag">缺</span>
|
|
<img
|
|
class="wall-live-tile"
|
|
:src="tile.src"
|
|
:alt="tile.alt"
|
|
/>
|
|
</button>
|
|
<img
|
|
v-else
|
|
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"
|
|
/>
|
|
</template>
|
|
</div>
|
|
<div v-if="wallSeats.left.tiles.length > 0" 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"
|
|
/>
|
|
</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" :active-position="currentTurnSeat"/>
|
|
|
|
<div v-if="showWaitingOwnerTip" class="waiting-owner-tip">
|
|
<span>等待房主开始游戏</span>
|
|
</div>
|
|
|
|
<div v-if="showSettlementOverlay" class="settlement-overlay">
|
|
<div class="settlement-panel">
|
|
<h2 class="settlement-title">
|
|
{{ isLastRound ? '最终结算' : `第 ${gameStore.currentRound} 局结算` }}
|
|
</h2>
|
|
<p v-if="gameStore.totalRounds > 0" class="settlement-round-info">
|
|
{{ gameStore.currentRound }} / {{ gameStore.totalRounds }} 局
|
|
</p>
|
|
<div class="settlement-list">
|
|
<div
|
|
v-for="(item, index) in settlementPlayers"
|
|
:key="item.playerId"
|
|
class="settlement-row"
|
|
:class="{ 'is-winner': item.isWinner, 'is-self': item.playerId === loggedInUserId }"
|
|
>
|
|
<span class="settlement-rank">{{ index + 1 }}</span>
|
|
<span class="settlement-name">
|
|
{{ item.displayName }}
|
|
<span v-if="item.isWinner" class="settlement-winner-badge">胡</span>
|
|
</span>
|
|
<span class="settlement-score" :class="{ 'is-positive': item.score > 0, 'is-negative': item.score < 0 }">
|
|
{{ item.score > 0 ? '+' : '' }}{{ item.score }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="settlement-actions">
|
|
<button
|
|
v-if="!isLastRound"
|
|
class="ready-toggle ready-toggle-inline settlement-btn"
|
|
type="button"
|
|
:disabled="nextRoundPending"
|
|
@click="nextRound"
|
|
>
|
|
<span class="ready-toggle-label">
|
|
{{ nextRoundPending ? '准备中...' : settlementCountdown != null && settlementCountdown > 0 ? `下一局 (${settlementCountdown}s)` : '下一局' }}
|
|
</span>
|
|
</button>
|
|
<button
|
|
v-else
|
|
class="ready-toggle ready-toggle-inline settlement-btn"
|
|
type="button"
|
|
@click="backHall"
|
|
>
|
|
<span class="ready-toggle-label">返回大厅</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bottom-control-panel">
|
|
<div v-if="showDingQueChooser || showReadyToggle || showStartGameButton || selectedDiscardTile" 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="selectedDiscardTile"
|
|
class="ready-toggle ready-toggle-inline discard-confirm-button"
|
|
data-testid="confirm-discard"
|
|
type="button"
|
|
:disabled="!canConfirmDiscard || discardPending"
|
|
@click="confirmDiscard"
|
|
>
|
|
<span class="ready-toggle-label">{{ confirmDiscardLabel }}</span>
|
|
</button>
|
|
|
|
<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="canDrawTile"
|
|
class="ready-toggle ready-toggle-inline"
|
|
data-testid="draw-tile"
|
|
type="button"
|
|
@click="drawTile"
|
|
>
|
|
<span class="ready-toggle-label">摸牌</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="canSelfGang" class="hand-action-bar">
|
|
<button
|
|
class="hand-action-tile"
|
|
data-testid="hand-gang"
|
|
type="button"
|
|
:disabled="turnActionPending"
|
|
@click="submitSelfGang"
|
|
>
|
|
杠
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="canSelfHu || showClaimActions" class="claim-action-bar" data-testid="claim-action-bar">
|
|
<button
|
|
v-if="canSelfHu"
|
|
class="ready-toggle ready-toggle-inline"
|
|
data-testid="claim-self-hu"
|
|
type="button"
|
|
:disabled="turnActionPending"
|
|
@click="submitSelfHu"
|
|
>
|
|
<span class="ready-toggle-label">胡</span>
|
|
</button>
|
|
<button
|
|
v-for="option in visibleClaimOptions"
|
|
:key="option"
|
|
class="ready-toggle ready-toggle-inline"
|
|
:data-testid="`claim-${option}`"
|
|
type="button"
|
|
:disabled="claimActionPending"
|
|
@click="submitClaim(option)"
|
|
>
|
|
<span class="ready-toggle-label">{{ option === 'peng' ? '碰' : option === 'gang' ? '杠' : option === 'hu' ? '胡' : '过' }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
</section>
|
|
</template>
|