- 在环境配置中更新代理目标地址 - 扩展游戏动作类型定义以支持头像URL字段 - 添加头像URL缓存计算逻辑以从多种来源获取头像 - 修改座位玩家卡片数据模型将avatar替换为avatarUrl - 实现头像图片加载并添加默认头像回退机制 - 更新CSS样式以正确显示头像图片 - 重构游戏状态管理中的玩家头像数据处理 - 优化游戏页面中的头像分配逻辑
728 lines
21 KiB
Vue
728 lines
21 KiB
Vue
<script setup lang="ts">
|
|
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
|
|
import {useRoute, useRouter} from 'vue-router'
|
|
import deskImage from '../assets/images/desk/desk_01.png'
|
|
import wanIcon from '../assets/images/flowerClolor/wan.png'
|
|
import tongIcon from '../assets/images/flowerClolor/tong.png'
|
|
import tiaoIcon from '../assets/images/flowerClolor/tiao.png'
|
|
import robotIcon from '../assets/images/icons/robot.svg'
|
|
import exitIcon from '../assets/images/icons/exit.svg'
|
|
import '../assets/styles/room.css'
|
|
import topBackImage from '../assets/images/tiles/top/tbgs_2.png'
|
|
import rightBackImage from '../assets/images/tiles/right/tbgs_1.png'
|
|
import bottomBackImage from '../assets/images/tiles/bottom/tdbgs_4.png'
|
|
import leftBackImage from '../assets/images/tiles/left/tbgs_3.png'
|
|
import TopPlayerCard from '../components/game/TopPlayerCard.vue'
|
|
import RightPlayerCard from '../components/game/RightPlayerCard.vue'
|
|
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
|
|
import LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
|
|
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
|
|
import type {SeatKey} from '../game/seat'
|
|
import type {GameAction} from '../game/actions'
|
|
import {dispatchGameAction} from '../game/dispatcher'
|
|
import {readStoredAuth} 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 {useActiveRoomState} from '../store'
|
|
import type {PlayerState} from '../types/state'
|
|
import type {Tile} from '../types/tile'
|
|
|
|
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']
|
|
|
|
interface SeatViewModel {
|
|
key: SeatKey
|
|
player?: DisplayPlayer
|
|
isSelf: boolean
|
|
isTurn: boolean
|
|
}
|
|
|
|
const now = ref(Date.now())
|
|
const wsStatus = ref<WsStatus>('idle')
|
|
const wsMessages = ref<string[]>([])
|
|
const wsError = ref('')
|
|
const selectedTile = ref<string | null>(null)
|
|
const leaveRoomPending = ref(false)
|
|
let clockTimer: number | null = null
|
|
let unsubscribe: (() => void) | null = null
|
|
|
|
const menuOpen = ref(false)
|
|
const isTrustMode = ref(false)
|
|
const menuTriggerActive = ref(false)
|
|
let menuTriggerTimer: number | null = null
|
|
let menuOpenTimer: number | null = null
|
|
|
|
const loggedInUserId = computed(() => {
|
|
const rawId = auth.value?.user?.id
|
|
if (typeof rawId === 'string') {
|
|
return rawId
|
|
}
|
|
if (typeof rawId === 'number') {
|
|
return String(rawId)
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const loggedInUserName = computed(() => {
|
|
return auth.value?.user?.nickname || auth.value?.user?.username || ''
|
|
})
|
|
|
|
const 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 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 = (player.seatIndex - selfSeatIndex + 4) % 4
|
|
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
|
return {
|
|
key: seatKey,
|
|
player,
|
|
isSelf: player.playerId === loggedInUserId.value,
|
|
isTurn: player.seatIndex === currentTurn,
|
|
}
|
|
})
|
|
})
|
|
|
|
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
|
|
|
|
const currentPhaseText = computed(() => {
|
|
const map: Record<string, string> = {
|
|
waiting: '等待中',
|
|
dealing: '发牌中',
|
|
playing: '对局中',
|
|
action: '操作中',
|
|
settlement: '已结算',
|
|
}
|
|
return map[gameStore.phase] ?? gameStore.phase
|
|
})
|
|
|
|
const roomStatusText = computed(() => {
|
|
const map: Record<string, string> = {
|
|
waiting: '等待玩家',
|
|
playing: '游戏中',
|
|
finished: '已结束',
|
|
}
|
|
const status = roomState.value.status
|
|
return map[status] ?? status ?? '--'
|
|
})
|
|
|
|
const 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,
|
|
})
|
|
})
|
|
|
|
const wallBacks = computed<Record<SeatKey, string[]>>(() => {
|
|
const wallSize = remainingTiles.value
|
|
const perSide = Math.max(6, Math.ceil((wallSize || 48) / 4 / 2))
|
|
|
|
return {
|
|
top: Array.from({length: perSide}, (_, index) => `top-${index}`),
|
|
right: Array.from({length: perSide}, (_, index) => `right-${index}`),
|
|
bottom: Array.from({length: perSide}, (_, index) => `bottom-${index}`),
|
|
left: Array.from({length: perSide}, (_, index) => `left-${index}`),
|
|
}
|
|
})
|
|
|
|
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,
|
|
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 || '')
|
|
|
|
result[seat.key] = {
|
|
avatarUrl,
|
|
name: seat.isSelf ? '你自己' : displayName,
|
|
dealer: seat.player.seatIndex === dealerIndex,
|
|
isTurn: seat.isTurn,
|
|
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
|
}
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
const floatingMissingSuit = computed(() => {
|
|
const suitMap: Record<string, string> = {
|
|
万: wanIcon,
|
|
筒: tongIcon,
|
|
条: tiaoIcon,
|
|
}
|
|
|
|
const topLabel = seatDecor.value.top?.missingSuitLabel ?? ''
|
|
const leftLabel = seatDecor.value.left?.missingSuitLabel ?? ''
|
|
const rightLabel = seatDecor.value.right?.missingSuitLabel ?? ''
|
|
|
|
return {
|
|
top: suitMap[topLabel] ?? '',
|
|
left: suitMap[leftLabel] ?? '',
|
|
right: suitMap[rightLabel] ?? '',
|
|
}
|
|
})
|
|
|
|
function missingSuitLabel(value: string | null | undefined): string {
|
|
const suitMap: Record<string, string> = {
|
|
wan: '万',
|
|
tong: '筒',
|
|
tiao: '条',
|
|
}
|
|
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
return suitMap[value] ?? value
|
|
}
|
|
|
|
function getBackImage(seat: SeatKey): string {
|
|
const imageMap: Record<SeatKey, string> = {
|
|
top: topBackImage,
|
|
right: rightBackImage,
|
|
bottom: bottomBackImage,
|
|
left: leftBackImage,
|
|
}
|
|
|
|
return imageMap[seat]
|
|
}
|
|
|
|
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 selectTile(tile: string): void {
|
|
selectedTile.value = selectedTile.value === tile ? null : tile
|
|
}
|
|
|
|
function formatTile(tile: Tile): string {
|
|
return `${tile.suit}${tile.value}`
|
|
}
|
|
|
|
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 = source.type.replace(/[-\s]/g, '_').toUpperCase()
|
|
const payload = source.payload
|
|
|
|
switch (type) {
|
|
case 'GAME_INIT':
|
|
if (payload && typeof payload === 'object') {
|
|
return {type: 'GAME_INIT', payload: payload as GameActionPayload<'GAME_INIT'>}
|
|
}
|
|
return null
|
|
case 'GAME_START':
|
|
if (payload && typeof payload === 'object') {
|
|
return {type: 'GAME_START', payload: payload as GameActionPayload<'GAME_START'>}
|
|
}
|
|
return null
|
|
case 'DRAW_TILE':
|
|
if (payload && typeof payload === 'object') {
|
|
return {type: 'DRAW_TILE', payload: payload as GameActionPayload<'DRAW_TILE'>}
|
|
}
|
|
return null
|
|
case 'PLAY_TILE':
|
|
if (payload && typeof payload === 'object') {
|
|
return {type: 'PLAY_TILE', payload: payload as GameActionPayload<'PLAY_TILE'>}
|
|
}
|
|
return null
|
|
case 'PENDING_CLAIM':
|
|
if (payload && typeof payload === 'object') {
|
|
return {type: 'PENDING_CLAIM', payload: payload as GameActionPayload<'PENDING_CLAIM'>}
|
|
}
|
|
return null
|
|
case 'CLAIM_RESOLVED':
|
|
if (payload && typeof payload === 'object') {
|
|
return {type: 'CLAIM_RESOLVED', payload: payload as GameActionPayload<'CLAIM_RESOLVED'>}
|
|
}
|
|
return null
|
|
case 'ROOM_PLAYER_UPDATE':
|
|
if (payload && typeof payload === 'object') {
|
|
return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameActionPayload<'ROOM_PLAYER_UPDATE'>}
|
|
}
|
|
return null
|
|
case 'ROOM_MEMBER_JOINED':
|
|
if (payload && typeof payload === 'object') {
|
|
return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameActionPayload<'ROOM_PLAYER_UPDATE'>}
|
|
}
|
|
return null
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function ensureWsConnected(): void {
|
|
const token = auth.value?.token
|
|
if (!token) {
|
|
wsError.value = '未找到登录凭证,无法建立连接'
|
|
return
|
|
}
|
|
|
|
wsError.value = ''
|
|
wsClient.connect(buildWsUrl(token), token)
|
|
}
|
|
|
|
function reconnectWs(): void {
|
|
const token = auth.value?.token
|
|
if (!token) {
|
|
wsError.value = '未找到登录凭证,无法建立连接'
|
|
return
|
|
}
|
|
|
|
wsError.value = ''
|
|
wsClient.reconnect(buildWsUrl(token), token)
|
|
}
|
|
|
|
function backHall(): void {
|
|
leaveRoomPending.value = true
|
|
const roomId = gameStore.roomId
|
|
sendWsMessage({
|
|
type: 'leave_room',
|
|
roomId,
|
|
payload: {
|
|
room_id: roomId,
|
|
},
|
|
})
|
|
wsClient.close()
|
|
void router.push('/hall').finally(() => {
|
|
leaveRoomPending.value = false
|
|
})
|
|
}
|
|
|
|
function handleLeaveRoom(): void {
|
|
menuOpen.value = false
|
|
backHall()
|
|
}
|
|
|
|
function handleGlobalClick(event: MouseEvent): void {
|
|
const target = event.target as HTMLElement | null
|
|
if (!target) {
|
|
return
|
|
}
|
|
|
|
if (target.closest('.menu-trigger-wrap')) {
|
|
return
|
|
}
|
|
|
|
menuOpen.value = false
|
|
}
|
|
|
|
function handleGlobalEsc(event: KeyboardEvent): void {
|
|
if (event.key === 'Escape') {
|
|
menuOpen.value = false
|
|
}
|
|
}
|
|
|
|
function hydrateFromActiveRoom(routeRoomId: string): void {
|
|
const room = activeRoom.value
|
|
if (!room) {
|
|
return
|
|
}
|
|
|
|
const targetRoomId = routeRoomId || room.roomId
|
|
if (!targetRoomId || room.roomId !== targetRoomId) {
|
|
return
|
|
}
|
|
|
|
gameStore.roomId = room.roomId
|
|
|
|
const phaseMap: Record<string, typeof gameStore.phase> = {
|
|
waiting: 'waiting',
|
|
playing: 'playing',
|
|
finished: 'settlement',
|
|
}
|
|
gameStore.phase = phaseMap[room.status] ?? gameStore.phase
|
|
|
|
const nextPlayers: Record<string, PlayerState> = {}
|
|
for (const player of room.players) {
|
|
if (!player.playerId) {
|
|
continue
|
|
}
|
|
|
|
const previous = gameStore.players[player.playerId]
|
|
nextPlayers[player.playerId] = {
|
|
playerId: player.playerId,
|
|
seatIndex: player.index,
|
|
displayName: player.displayName || player.playerId,
|
|
avatarURL: previous?.avatarURL,
|
|
missingSuit: player.missingSuit ?? previous?.missingSuit,
|
|
isReady: player.ready,
|
|
handTiles: previous?.handTiles ?? [],
|
|
melds: previous?.melds ?? [],
|
|
discardTiles: previous?.discardTiles ?? [],
|
|
score: previous?.score ?? 0,
|
|
}
|
|
}
|
|
gameStore.players = nextPlayers
|
|
}
|
|
|
|
|
|
onMounted(() => {
|
|
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
|
hydrateFromActiveRoom(routeRoomId)
|
|
if (routeRoomId) {
|
|
gameStore.roomId = routeRoomId
|
|
}
|
|
|
|
const handler = (status: WsStatus) => {
|
|
wsStatus.value = status
|
|
}
|
|
|
|
wsClient.onMessage((msg: unknown) => {
|
|
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
|
|
wsMessages.value.push(`[server] ${text}`)
|
|
const gameAction = toGameAction(msg)
|
|
if (gameAction) {
|
|
dispatchGameAction(gameAction)
|
|
}
|
|
})
|
|
wsClient.onError((message: string) => {
|
|
wsError.value = message
|
|
wsMessages.value.push(`[error] ${message}`)
|
|
})
|
|
|
|
unsubscribe = wsClient.onStatusChange(handler)
|
|
ensureWsConnected()
|
|
|
|
clockTimer = window.setInterval(() => {
|
|
now.value = Date.now()
|
|
}, 1000)
|
|
|
|
window.addEventListener('click', handleGlobalClick)
|
|
window.addEventListener('keydown', handleGlobalEsc)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (unsubscribe) {
|
|
unsubscribe()
|
|
unsubscribe = null
|
|
}
|
|
|
|
if (clockTimer !== null) {
|
|
window.clearInterval(clockTimer)
|
|
clockTimer = null
|
|
}
|
|
|
|
window.removeEventListener('click', handleGlobalClick)
|
|
window.removeEventListener('keydown', handleGlobalEsc)
|
|
|
|
if (menuTriggerTimer !== null) {
|
|
window.clearTimeout(menuTriggerTimer)
|
|
menuTriggerTimer = null
|
|
}
|
|
|
|
if (menuOpenTimer !== null) {
|
|
window.clearTimeout(menuOpenTimer)
|
|
menuOpenTimer = null
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<section class="picture-scene">
|
|
<div class="picture-layout">
|
|
<section class="table-stage">
|
|
<img class="table-desk" :src="deskImage" alt=""/>
|
|
|
|
<div class="table-felt">
|
|
<div class="table-surface"></div>
|
|
<div class="inner-outline outer"></div>
|
|
<div class="inner-outline mid"></div>
|
|
|
|
<div class="top-left-tools">
|
|
<div class="menu-trigger-wrap">
|
|
<button
|
|
class="metal-circle menu-trigger"
|
|
:class="{ 'is-feedback': menuTriggerActive }"
|
|
type="button"
|
|
:disabled="leaveRoomPending"
|
|
@click.stop="toggleMenu"
|
|
>
|
|
<span class="menu-trigger-icon">☰</span>
|
|
</button>
|
|
<transition name="menu-pop">
|
|
<div v-if="menuOpen" class="menu-popover" @click.stop>
|
|
<div class="menu-list">
|
|
<button class="menu-item menu-item-delay-1" type="button" @click="toggleTrustMode">
|
|
<span class="menu-item-icon">
|
|
<img :src="robotIcon" alt=""/>
|
|
</span>
|
|
<span>{{ isTrustMode ? '取消托管' : '托管' }}</span>
|
|
</button>
|
|
<button
|
|
class="menu-item menu-item-danger menu-item-delay-2"
|
|
type="button"
|
|
:disabled="leaveRoomPending"
|
|
@click="handleLeaveRoom"
|
|
>
|
|
<span class="menu-item-icon">
|
|
<img :src="exitIcon" alt=""/>
|
|
</span>
|
|
<span>{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
<div class="left-counter">
|
|
<span class="counter-light"></span>
|
|
<strong>{{ roomState.game?.state?.wall?.length ?? 48 }}</strong>
|
|
</div>
|
|
<span v-if="isTrustMode" class="trust-chip">托管中</span>
|
|
</div>
|
|
|
|
<div class="top-right-clock">
|
|
<div class="signal-chip">
|
|
<span class="wifi-dot" :class="`is-${wsStatus}`"></span>
|
|
<strong>{{ networkLabel }}</strong>
|
|
</div>
|
|
<span>{{ formattedClock }}</span>
|
|
</div>
|
|
|
|
|
|
<TopPlayerCard :player="seatDecor.top"/>
|
|
<RightPlayerCard :player="seatDecor.right"/>
|
|
<BottomPlayerCard :player="seatDecor.bottom"/>
|
|
<LeftPlayerCard :player="seatDecor.left"/>
|
|
|
|
<div class="wall wall-top">
|
|
<img v-for="key in wallBacks.top" :key="key" :src="getBackImage('top')" alt=""/>
|
|
</div>
|
|
<div class="wall wall-right">
|
|
<img v-for="key in wallBacks.right" :key="key" :src="getBackImage('right')" alt=""/>
|
|
</div>
|
|
<div class="wall wall-bottom">
|
|
<img v-for="key in wallBacks.bottom" :key="key" :src="getBackImage('bottom')" alt=""/>
|
|
</div>
|
|
<div class="wall wall-left">
|
|
<img v-for="key in wallBacks.left" :key="key" :src="getBackImage('left')" 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>
|
|
|
|
|
|
<div class="bottom-control-panel">
|
|
|
|
<div class="player-hand" v-if="myHandTiles.length > 0">
|
|
<button
|
|
v-for="tile in myHandTiles"
|
|
:key="tile.id"
|
|
class="tile-chip"
|
|
:class="{ selected: selectedTile === formatTile(tile) }"
|
|
type="button"
|
|
@click="selectTile(formatTile(tile))"
|
|
>
|
|
{{ formatTile(tile) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<aside class="ws-sidebar">
|
|
<div class="sidebar-head">
|
|
<div>
|
|
<p class="sidebar-title">WebSocket 消息</p>
|
|
<small>{{ networkLabel }} · {{ loggedInUserName || '未登录昵称' }}</small>
|
|
</div>
|
|
<button class="sidebar-btn" type="button" @click="reconnectWs">重连</button>
|
|
</div>
|
|
|
|
<div class="sidebar-stats">
|
|
<div class="sidebar-stat">
|
|
<span>房间</span>
|
|
<strong>{{ roomState.name || roomName || '未命名' }}</strong>
|
|
</div>
|
|
<div class="sidebar-stat">
|
|
<span>阶段</span>
|
|
<strong>{{ currentPhaseText }}</strong>
|
|
</div>
|
|
<div class="sidebar-stat">
|
|
<span>人数</span>
|
|
<strong>{{ roomState.playerCount }}/{{ roomState.maxPlayers }}</strong>
|
|
</div>
|
|
<div class="sidebar-stat">
|
|
<span>状态</span>
|
|
<strong>{{ roomStatusText }}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="wsError" class="sidebar-error">{{ wsError }}</p>
|
|
|
|
<div class="sidebar-log">
|
|
<p v-if="rightMessages.length === 0" class="sidebar-empty">等待服务器消息...</p>
|
|
<p v-for="(line, index) in rightMessages" :key="index" class="sidebar-line">{{ line }}</p>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</section>
|
|
</template>
|