2354 lines
56 KiB
Vue
2354 lines
56 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import deskImage from '../assets/images/desk/desk_01.png'
|
||
import { readStoredAuth } from '../utils/auth-storage'
|
||
import type { AuthSession } from '../api/authed-request'
|
||
import { getUserInfo } from '../api/user'
|
||
import {
|
||
DEFAULT_MAX_PLAYERS,
|
||
activeRoomState,
|
||
destroyActiveRoomState,
|
||
mergeActiveRoomState,
|
||
resetActiveRoomState,
|
||
type GameState,
|
||
type RoomPlayerState,
|
||
type RoomState,
|
||
} from '../state/active-room'
|
||
import { readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
|
||
const auth = ref(readStoredAuth())
|
||
const ws = ref<WebSocket | null>(null)
|
||
const wsStatus = ref<'disconnected' | 'connecting' | 'connected'>('disconnected')
|
||
const wsError = ref('')
|
||
const wsMessages = ref<string[]>([])
|
||
const startGamePending = ref(false)
|
||
const lastStartRequestId = ref('')
|
||
const now = ref(new Date())
|
||
const selectedHandIndex = ref(10)
|
||
const chosenMissingSuit = ref<'wan' | 'tiao' | 'tong'>('tiao')
|
||
const overlayState = ref<'missing' | 'action' | 'settlement' | null>('missing')
|
||
|
||
let clockTimer: number | undefined
|
||
const leaveRoomPending = ref(false)
|
||
const lastLeaveRoomRequestId = ref('')
|
||
const leaveHallAfterAck = ref(false)
|
||
|
||
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? 'ws://127.0.0.1:8080/ws'
|
||
|
||
type SeatKey = 'top' | 'right' | 'bottom' | 'left'
|
||
type TileSuit = 'wan' | 'tong' | 'tiao' | 'honor'
|
||
type MissingSuit = 'wan' | 'tiao' | 'tong'
|
||
type TileDirection = 'bottom' | 'top' | 'left' | 'right'
|
||
|
||
interface ActionEventLike {
|
||
type?: unknown
|
||
status?: unknown
|
||
requestId?: unknown
|
||
request_id?: unknown
|
||
roomId?: unknown
|
||
room_id?: unknown
|
||
payload?: unknown
|
||
data?: unknown
|
||
}
|
||
|
||
interface TileModel {
|
||
key: string
|
||
suit: TileSuit
|
||
rank: number
|
||
glow?: boolean
|
||
}
|
||
|
||
interface SeatRenderItem {
|
||
key: SeatKey
|
||
player: RoomPlayerState | null
|
||
isSelf: boolean
|
||
isTurn: boolean
|
||
label: string
|
||
subLabel: string
|
||
}
|
||
|
||
const bottomTileModules = import.meta.glob('../assets/images/tiles/bottom/*.png', {
|
||
eager: true,
|
||
import: 'default',
|
||
}) as Record<string, string>
|
||
|
||
const topTileModules = import.meta.glob('../assets/images/tiles/top/*.png', {
|
||
eager: true,
|
||
import: 'default',
|
||
}) as Record<string, string>
|
||
|
||
const leftTileModules = import.meta.glob('../assets/images/tiles/left/*.png', {
|
||
eager: true,
|
||
import: 'default',
|
||
}) as Record<string, string>
|
||
|
||
const rightTileModules = import.meta.glob('../assets/images/tiles/right/*.png', {
|
||
eager: true,
|
||
import: 'default',
|
||
}) as Record<string, string>
|
||
|
||
const roomId = computed(() => {
|
||
return typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
||
})
|
||
|
||
const roomName = computed(() => {
|
||
return typeof route.query.roomName === 'string' ? route.query.roomName : ''
|
||
})
|
||
|
||
const currentUserId = computed(() => {
|
||
const user = auth.value?.user as Record<string, unknown> | undefined
|
||
const candidate = user?.id ?? user?.userID ?? user?.user_id
|
||
if (typeof candidate === 'string') {
|
||
return candidate
|
||
}
|
||
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
|
||
return String(candidate)
|
||
}
|
||
return ''
|
||
})
|
||
|
||
const loggedInUserName = computed(() => {
|
||
if (!auth.value?.user) {
|
||
return ''
|
||
}
|
||
|
||
return auth.value.user.nickname ?? auth.value.user.username ?? ''
|
||
})
|
||
|
||
const roomState = activeRoomState
|
||
|
||
const isRoomFull = computed(() => {
|
||
return (
|
||
roomState.value.maxPlayers > 0 &&
|
||
roomState.value.playerCount === roomState.value.maxPlayers
|
||
)
|
||
})
|
||
|
||
const canStartGame = computed(() => {
|
||
return (
|
||
Boolean(roomState.value.id) &&
|
||
roomState.value.status === 'waiting' &&
|
||
isRoomFull.value &&
|
||
Boolean(currentUserId.value) &&
|
||
roomState.value.ownerId === currentUserId.value
|
||
)
|
||
})
|
||
|
||
const seatViews = computed<SeatRenderItem[]>(() => {
|
||
const seats: Record<SeatKey, RoomPlayerState | null> = {
|
||
top: null,
|
||
right: null,
|
||
bottom: null,
|
||
left: null,
|
||
}
|
||
|
||
const players = [...roomState.value.players].sort((a, b) => a.index - b.index)
|
||
const hasSelf = players.some((player) => player.playerId === currentUserId.value)
|
||
if (currentUserId.value && roomState.value.id && !hasSelf) {
|
||
// Fallback before WS full player list arrives: keep current player at bottom.
|
||
players.unshift({
|
||
index: 0,
|
||
playerId: currentUserId.value,
|
||
ready: false,
|
||
})
|
||
}
|
||
|
||
const me = players.find((player) => player.playerId === currentUserId.value) ?? null
|
||
const anchorIndex = me?.index ?? players[0]?.index ?? 0
|
||
const clockwiseSeatByDelta: SeatKey[] = ['bottom', 'left', 'top', 'right']
|
||
|
||
for (const player of players) {
|
||
const normalizedDelta = ((player.index - anchorIndex) % 4 + 4) % 4
|
||
const seat = clockwiseSeatByDelta[normalizedDelta] ?? 'top'
|
||
seats[seat] = player
|
||
}
|
||
|
||
const turnSeat =
|
||
roomState.value.currentTurnIndex === null
|
||
? null
|
||
: clockwiseSeatByDelta[
|
||
((roomState.value.currentTurnIndex - anchorIndex) % 4 + 4) % 4
|
||
] ?? null
|
||
|
||
const order: SeatKey[] = ['top', 'right', 'bottom', 'left']
|
||
return order.map((seat) => {
|
||
const player = seats[seat]
|
||
const isSelf = Boolean(player) && player?.playerId === currentUserId.value
|
||
const fallbackLabels: Record<SeatKey, string> = {
|
||
top: '对家',
|
||
right: '上家',
|
||
bottom: '我',
|
||
left: '下家',
|
||
}
|
||
|
||
return {
|
||
key: seat,
|
||
player,
|
||
isSelf,
|
||
isTurn: turnSeat === seat,
|
||
label: player
|
||
? isSelf
|
||
? loggedInUserName.value || '我'
|
||
: `玩家 ${player.index + 1}`
|
||
: fallbackLabels[seat],
|
||
subLabel: player ? `座位 ${player.index + 1}` : '等待入座',
|
||
}
|
||
})
|
||
})
|
||
|
||
const seatDecor = computed(() => {
|
||
const missingBySeat: Record<SeatKey, MissingSuit> = {
|
||
top: 'wan',
|
||
right: 'wan',
|
||
bottom: chosenMissingSuit.value,
|
||
left: 'tiao',
|
||
}
|
||
|
||
const richPlayers = [
|
||
{
|
||
seat: 'top',
|
||
avatar: '云',
|
||
money: '136.1亿',
|
||
dealer: false,
|
||
},
|
||
{
|
||
seat: 'right',
|
||
avatar: '熊',
|
||
money: '159亿',
|
||
dealer: false,
|
||
},
|
||
{
|
||
seat: 'bottom',
|
||
avatar: '我',
|
||
money: '89.2亿',
|
||
dealer: false,
|
||
},
|
||
{
|
||
seat: 'left',
|
||
avatar: '宝',
|
||
money: '87.8亿',
|
||
dealer: true,
|
||
},
|
||
] as const
|
||
|
||
return richPlayers.map((item) => {
|
||
const seatInfo = seatViews.value.find((seat) => seat.key === item.seat)
|
||
return {
|
||
...item,
|
||
name: seatInfo?.label ?? '玩家',
|
||
missingSuit: missingBySeat[item.seat],
|
||
isTurn: seatInfo?.isTurn ?? false,
|
||
isOnline: Boolean(seatInfo?.player) || item.seat === 'bottom',
|
||
}
|
||
})
|
||
})
|
||
|
||
const roomStatusText = computed(() => {
|
||
if (roomState.value.status === 'playing') {
|
||
return '对局中'
|
||
}
|
||
if (roomState.value.status === 'finished') {
|
||
return '已结算'
|
||
}
|
||
return '等待开局'
|
||
})
|
||
|
||
const wallCount = computed(() => {
|
||
if (roomState.value.status === 'finished') {
|
||
return 27
|
||
}
|
||
if (roomState.value.status === 'playing') {
|
||
return 30
|
||
}
|
||
return 55
|
||
})
|
||
|
||
const centerTimer = computed(() => {
|
||
if (roomState.value.status === 'finished') {
|
||
return '09'
|
||
}
|
||
if (roomState.value.status === 'playing') {
|
||
return overlayState.value === 'action' ? '11' : '12'
|
||
}
|
||
return '00'
|
||
})
|
||
|
||
const currentPhaseText = computed(() => {
|
||
if (overlayState.value === 'missing') {
|
||
return '请选择定缺花色,没有该花色才能胡牌'
|
||
}
|
||
if (overlayState.value === 'action') {
|
||
return '检测到可操作,选择碰牌或过牌'
|
||
}
|
||
if (overlayState.value === 'settlement') {
|
||
return '本局结束,准备进入下一局'
|
||
}
|
||
return roomState.value.status === 'playing' ? '轮到你出牌' : '等待玩家准备'
|
||
})
|
||
|
||
const statusRibbon = computed(() => {
|
||
if (overlayState.value === 'settlement') {
|
||
return '+6亿 胡'
|
||
}
|
||
if (overlayState.value === 'action') {
|
||
return '可操作'
|
||
}
|
||
if (overlayState.value === 'missing') {
|
||
return '已定缺'
|
||
}
|
||
return '四川麻将'
|
||
})
|
||
|
||
const networkLabel = computed(() => {
|
||
if (wsStatus.value === 'connected') {
|
||
return '22ms'
|
||
}
|
||
if (wsStatus.value === 'connecting') {
|
||
return '连接中'
|
||
}
|
||
return '离线'
|
||
})
|
||
|
||
const formattedClock = computed(() => {
|
||
const month = String(now.value.getMonth() + 1).padStart(2, '0')
|
||
const day = String(now.value.getDate()).padStart(2, '0')
|
||
const hours = String(now.value.getHours()).padStart(2, '0')
|
||
const minutes = String(now.value.getMinutes()).padStart(2, '0')
|
||
return `${month}-${day} ${hours}:${minutes}`
|
||
})
|
||
|
||
const bottomHandTiles = computed<TileModel[]>(() => {
|
||
if (roomState.value.status === 'finished') {
|
||
return [
|
||
{ key: 'wan-6a', suit: 'wan', rank: 6 },
|
||
{ key: 'wan-6b', suit: 'wan', rank: 6 },
|
||
{ key: 'tong-4a', suit: 'tong', rank: 4 },
|
||
{ key: 'tong-4b', suit: 'tong', rank: 4 },
|
||
{ key: 'tong-6', suit: 'tong', rank: 6 },
|
||
{ key: 'tong-8', suit: 'tong', rank: 8 },
|
||
{ key: 'tong-9', suit: 'tong', rank: 9 },
|
||
]
|
||
}
|
||
|
||
if (roomState.value.status === 'playing') {
|
||
return [
|
||
{ key: 'wan-2', suit: 'wan', rank: 2 },
|
||
{ key: 'wan-5', suit: 'wan', rank: 5 },
|
||
{ key: 'wan-6a', suit: 'wan', rank: 6 },
|
||
{ key: 'wan-6b', suit: 'wan', rank: 6 },
|
||
{ key: 'wan-7', suit: 'wan', rank: 7 },
|
||
{ key: 'wan-8', suit: 'wan', rank: 8 },
|
||
{ key: 'tong-4', suit: 'tong', rank: 4 },
|
||
{ key: 'tong-8a', suit: 'tong', rank: 8 },
|
||
{ key: 'tong-8b', suit: 'tong', rank: 8, glow: true },
|
||
{ key: 'tong-9', suit: 'tong', rank: 9 },
|
||
{ key: 'tiao-7', suit: 'tiao', rank: 7, glow: true },
|
||
{ key: 'tiao-8', suit: 'tiao', rank: 8, glow: true },
|
||
]
|
||
}
|
||
|
||
return [
|
||
{ key: 'wan-2', suit: 'wan', rank: 2 },
|
||
{ key: 'wan-5', suit: 'wan', rank: 5 },
|
||
{ key: 'wan-6a', suit: 'wan', rank: 6 },
|
||
{ key: 'wan-6b', suit: 'wan', rank: 6 },
|
||
{ key: 'wan-7', suit: 'wan', rank: 7 },
|
||
{ key: 'wan-8', suit: 'wan', rank: 8 },
|
||
{ key: 'tiao-2', suit: 'tiao', rank: 2 },
|
||
{ key: 'tiao-7', suit: 'tiao', rank: 7 },
|
||
{ key: 'tiao-8', suit: 'tiao', rank: 8 },
|
||
{ key: 'tiao-9', suit: 'tiao', rank: 9 },
|
||
{ key: 'tong-4', suit: 'tong', rank: 4 },
|
||
{ key: 'tong-8', suit: 'tong', rank: 8 },
|
||
{ key: 'tong-9', suit: 'tong', rank: 9 },
|
||
]
|
||
})
|
||
|
||
const discardsBySeat = computed<Record<SeatKey, TileModel[]>>(() => {
|
||
if (roomState.value.status === 'finished') {
|
||
return {
|
||
top: [
|
||
{ key: 't1', suit: 'tiao', rank: 9 },
|
||
{ key: 't2', suit: 'wan', rank: 4 },
|
||
{ key: 't3', suit: 'wan', rank: 4 },
|
||
{ key: 't4', suit: 'wan', rank: 4 },
|
||
{ key: 't5', suit: 'wan', rank: 4 },
|
||
],
|
||
right: [
|
||
{ key: 'r1', suit: 'tong', rank: 9 },
|
||
{ key: 'r2', suit: 'tiao', rank: 7 },
|
||
{ key: 'r3', suit: 'tiao', rank: 8 },
|
||
{ key: 'r4', suit: 'honor', rank: 5 },
|
||
{ key: 'r5', suit: 'honor', rank: 5 },
|
||
],
|
||
bottom: [
|
||
{ key: 'b1', suit: 'tiao', rank: 7 },
|
||
{ key: 'b2', suit: 'tiao', rank: 8 },
|
||
{ key: 'b3', suit: 'tiao', rank: 9 },
|
||
{ key: 'b4', suit: 'tiao', rank: 1 },
|
||
{ key: 'b5', suit: 'wan', rank: 3 },
|
||
{ key: 'b6', suit: 'wan', rank: 5 },
|
||
],
|
||
left: [
|
||
{ key: 'l1', suit: 'tiao', rank: 4 },
|
||
{ key: 'l2', suit: 'tiao', rank: 5 },
|
||
{ key: 'l3', suit: 'tiao', rank: 7 },
|
||
{ key: 'l4', suit: 'honor', rank: 5 },
|
||
],
|
||
}
|
||
}
|
||
|
||
if (roomState.value.status === 'playing') {
|
||
return {
|
||
top: [
|
||
{ key: 't1', suit: 'tiao', rank: 9 },
|
||
{ key: 't2', suit: 'wan', rank: 4 },
|
||
{ key: 't3', suit: 'wan', rank: 4 },
|
||
{ key: 't4', suit: 'wan', rank: 4 },
|
||
{ key: 't5', suit: 'wan', rank: 4 },
|
||
],
|
||
right: [
|
||
{ key: 'r1', suit: 'tong', rank: 9 },
|
||
{ key: 'r2', suit: 'tiao', rank: 7 },
|
||
{ key: 'r3', suit: 'tiao', rank: 8 },
|
||
{ key: 'r4', suit: 'honor', rank: 5 },
|
||
],
|
||
bottom: [
|
||
{ key: 'b1', suit: 'tiao', rank: 7 },
|
||
{ key: 'b2', suit: 'tiao', rank: 8 },
|
||
{ key: 'b3', suit: 'tiao', rank: 9 },
|
||
{ key: 'b4', suit: 'wan', rank: 3 },
|
||
{ key: 'b5', suit: 'wan', rank: 5 },
|
||
],
|
||
left: [
|
||
{ key: 'l1', suit: 'tiao', rank: 4 },
|
||
{ key: 'l2', suit: 'tiao', rank: 5 },
|
||
{ key: 'l3', suit: 'tiao', rank: 7 },
|
||
{ key: 'l4', suit: 'honor', rank: 5 },
|
||
],
|
||
}
|
||
}
|
||
|
||
return {
|
||
top: [],
|
||
right: [],
|
||
bottom: [],
|
||
left: [],
|
||
}
|
||
})
|
||
|
||
const meldsBySeat = computed<Record<SeatKey, TileModel[][]>>(() => {
|
||
if (roomState.value.status === 'finished') {
|
||
return {
|
||
top: [[{ key: 'mt1', suit: 'tiao', rank: 6 }, { key: 'mt2', suit: 'tiao', rank: 7 }, { key: 'mt3', suit: 'tiao', rank: 8 }]],
|
||
right: [[{ key: 'mr1', suit: 'tiao', rank: 8 }, { key: 'mr2', suit: 'tiao', rank: 8 }, { key: 'mr3', suit: 'tiao', rank: 8 }]],
|
||
bottom: [
|
||
[{ key: 'mb1', suit: 'wan', rank: 7 }, { key: 'mb2', suit: 'wan', rank: 7 }, { key: 'mb3', suit: 'wan', rank: 7 }],
|
||
[{ key: 'mb4', suit: 'tong', rank: 8 }, { key: 'mb5', suit: 'tong', rank: 8 }, { key: 'mb6', suit: 'tong', rank: 8 }],
|
||
],
|
||
left: [],
|
||
}
|
||
}
|
||
|
||
if (roomState.value.status === 'playing') {
|
||
return {
|
||
top: [],
|
||
right: [],
|
||
bottom: [[{ key: 'mb1', suit: 'tong', rank: 8 }, { key: 'mb2', suit: 'tong', rank: 8 }, { key: 'mb3', suit: 'tong', rank: 8 }]],
|
||
left: [],
|
||
}
|
||
}
|
||
|
||
return {
|
||
top: [],
|
||
right: [],
|
||
bottom: [],
|
||
left: [],
|
||
}
|
||
})
|
||
|
||
const wallBacks = computed(() => {
|
||
return {
|
||
top: Array.from({ length: 14 }, (_, index) => `top-wall-${index}`),
|
||
right: Array.from({ length: 11 }, (_, index) => `right-wall-${index}`),
|
||
bottom: Array.from({ length: 13 }, (_, index) => `bottom-wall-${index}`),
|
||
left: Array.from({ length: 11 }, (_, index) => `left-wall-${index}`),
|
||
}
|
||
})
|
||
|
||
function getTileModules(direction: TileDirection): Record<string, string> {
|
||
if (direction === 'top') {
|
||
return topTileModules
|
||
}
|
||
if (direction === 'left') {
|
||
return leftTileModules
|
||
}
|
||
if (direction === 'right') {
|
||
return rightTileModules
|
||
}
|
||
return bottomTileModules
|
||
}
|
||
|
||
function tilePath(direction: TileDirection, suit: TileSuit, rank: number): string {
|
||
const prefixByDirection: Record<TileDirection, string> = {
|
||
bottom: 'p4b',
|
||
top: 'p2s',
|
||
left: 'p3s',
|
||
right: 'p1s',
|
||
}
|
||
const suitIndexBySuit: Record<TileSuit, number> = {
|
||
wan: 1,
|
||
tong: 2,
|
||
tiao: 3,
|
||
honor: 4,
|
||
}
|
||
const prefix = prefixByDirection[direction]
|
||
const suitIndex = suitIndexBySuit[suit]
|
||
return `../assets/images/tiles/${direction}/${prefix}${suitIndex}_${rank}.png`
|
||
}
|
||
|
||
function getTileImage(direction: TileDirection, tile: TileModel): string {
|
||
const modules = getTileModules(direction)
|
||
return modules[tilePath(direction, tile.suit, tile.rank)] ?? ''
|
||
}
|
||
|
||
function getBackImage(direction: TileDirection): string {
|
||
const modules = getTileModules(direction)
|
||
const nameByDirection: Record<TileDirection, string> = {
|
||
bottom: '../assets/images/tiles/bottom/tdbgs_4.png',
|
||
top: '../assets/images/tiles/top/tdbgs_2.png',
|
||
left: '../assets/images/tiles/left/tdbgs_3.png',
|
||
right: '../assets/images/tiles/right/tdbgs_1.png',
|
||
}
|
||
return modules[nameByDirection[direction]] ?? ''
|
||
}
|
||
|
||
function missingSuitLabel(suit: MissingSuit): string {
|
||
if (suit === 'wan') {
|
||
return '万'
|
||
}
|
||
if (suit === 'tiao') {
|
||
return '条'
|
||
}
|
||
return '筒'
|
||
}
|
||
|
||
function backHall(): void {
|
||
if (leaveRoomPending.value) {
|
||
return
|
||
}
|
||
|
||
leaveHallAfterAck.value = true
|
||
const sent = sendLeaveRoom()
|
||
if (!sent) {
|
||
leaveHallAfterAck.value = false
|
||
pushWsMessage('[client] 退出房间失败:未发送请求')
|
||
}
|
||
}
|
||
|
||
function pushWsMessage(text: string): void {
|
||
const line = `[${new Date().toLocaleTimeString()}] ${text}`
|
||
wsMessages.value.unshift(line)
|
||
if (wsMessages.value.length > 60) {
|
||
wsMessages.value.length = 60
|
||
}
|
||
}
|
||
|
||
function logWsSend(message: unknown): void {
|
||
console.log('[WS][client] send', message)
|
||
}
|
||
|
||
function logWsReceive(kind: string, payload?: unknown): void {
|
||
const timestamp = new Date().toLocaleTimeString()
|
||
if (payload === undefined) {
|
||
console.log(`[WS][${timestamp}] ${kind}`)
|
||
return
|
||
}
|
||
console.log(`[WS][${timestamp}] ${kind}:`, payload)
|
||
}
|
||
|
||
function disconnectWs(): void {
|
||
if (ws.value) {
|
||
ws.value.close()
|
||
ws.value = null
|
||
}
|
||
wsStatus.value = 'disconnected'
|
||
}
|
||
|
||
function toRecord(value: unknown): Record<string, unknown> | null {
|
||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
|
||
}
|
||
|
||
function toStringOrEmpty(value: unknown): string {
|
||
if (typeof value === 'string') {
|
||
return value
|
||
}
|
||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||
return String(value)
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function toSession(source: NonNullable<typeof auth.value>): AuthSession {
|
||
return {
|
||
token: source.token,
|
||
tokenType: source.tokenType,
|
||
refreshToken: source.refreshToken,
|
||
expiresIn: source.expiresIn,
|
||
}
|
||
}
|
||
|
||
function syncAuth(next: AuthSession): void {
|
||
if (!auth.value) {
|
||
return
|
||
}
|
||
|
||
auth.value = {
|
||
...auth.value,
|
||
token: next.token,
|
||
tokenType: next.tokenType ?? auth.value.tokenType,
|
||
refreshToken: next.refreshToken ?? auth.value.refreshToken,
|
||
expiresIn: next.expiresIn,
|
||
}
|
||
writeStoredAuth(auth.value)
|
||
}
|
||
|
||
async function ensureCurrentUserId(): Promise<void> {
|
||
if (currentUserId.value || !auth.value) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const userInfo = await getUserInfo(toSession(auth.value), syncAuth)
|
||
const payload = userInfo as Record<string, unknown>
|
||
const resolvedId = toStringOrEmpty(payload.userID ?? payload.user_id ?? payload.id)
|
||
if (!resolvedId) {
|
||
return
|
||
}
|
||
|
||
auth.value = {
|
||
...auth.value,
|
||
user: {
|
||
...(auth.value.user ?? {}),
|
||
id: resolvedId,
|
||
},
|
||
}
|
||
writeStoredAuth(auth.value)
|
||
} catch {
|
||
wsError.value = '获取当前用户ID失败,部分操作可能不可用'
|
||
}
|
||
}
|
||
|
||
function toFiniteNumber(value: unknown): number | null {
|
||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||
return value
|
||
}
|
||
if (typeof value === 'string' && value.trim()) {
|
||
const parsed = Number(value)
|
||
return Number.isFinite(parsed) ? parsed : null
|
||
}
|
||
return null
|
||
}
|
||
|
||
function toBoolean(value: unknown): boolean {
|
||
if (typeof value === 'boolean') {
|
||
return value
|
||
}
|
||
if (typeof value === 'number') {
|
||
return value !== 0
|
||
}
|
||
if (typeof value === 'string') {
|
||
const normalized = value.trim().toLowerCase()
|
||
return normalized === '1' || normalized === 'true' || normalized === 'yes'
|
||
}
|
||
return false
|
||
}
|
||
|
||
function normalizeScores(value: unknown): Record<string, number> {
|
||
const record = toRecord(value)
|
||
if (!record) {
|
||
return {}
|
||
}
|
||
|
||
const scores: Record<string, number> = {}
|
||
for (const [key, score] of Object.entries(record)) {
|
||
const parsed = toFiniteNumber(score)
|
||
if (parsed !== null) {
|
||
scores[key] = parsed
|
||
}
|
||
}
|
||
return scores
|
||
}
|
||
|
||
function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null {
|
||
const player = toRecord(input)
|
||
if (!player) {
|
||
return null
|
||
}
|
||
|
||
const playerId = toStringOrEmpty(player.playerId ?? player.player_id ?? player.user_id ?? player.id)
|
||
if (!playerId) {
|
||
return null
|
||
}
|
||
|
||
const seatIndex = toFiniteNumber(player.index ?? player.seat ?? player.position ?? player.player_index)
|
||
return {
|
||
index: seatIndex ?? fallbackIndex,
|
||
playerId,
|
||
ready: Boolean(player.ready),
|
||
}
|
||
}
|
||
|
||
function extractCurrentTurnIndex(value: Record<string, unknown>): number | null {
|
||
const game = toRecord(value.game)
|
||
const gameState = toRecord(game?.state)
|
||
const keys = [
|
||
gameState?.currentTurn,
|
||
gameState?.current_turn,
|
||
gameState?.currentTurnIndex,
|
||
gameState?.current_turn_index,
|
||
value.currentTurnIndex,
|
||
value.current_turn_index,
|
||
value.currentPlayerIndex,
|
||
value.current_player_index,
|
||
value.turnIndex,
|
||
value.turn_index,
|
||
value.activePlayerIndex,
|
||
value.active_player_index,
|
||
]
|
||
for (const key of keys) {
|
||
const parsed = toFiniteNumber(key)
|
||
if (parsed !== null) {
|
||
return parsed
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function normalizeGame(input: unknown): GameState | null {
|
||
const game = toRecord(input)
|
||
if (!game) {
|
||
return null
|
||
}
|
||
|
||
const rule = toRecord(game.rule)
|
||
const rawState = toRecord(game.state)
|
||
const playersRaw =
|
||
(Array.isArray(rawState?.players) ? rawState?.players : null) ??
|
||
(Array.isArray(rawState?.playerStates) ? rawState?.playerStates : null) ??
|
||
[]
|
||
|
||
const normalizedPlayers = playersRaw
|
||
.map((item, index) => normalizePlayer(item, index))
|
||
.filter((item): item is RoomPlayerState => Boolean(item))
|
||
|
||
return {
|
||
rule: rule
|
||
? {
|
||
name: toStringOrEmpty(rule.name),
|
||
isBloodFlow: toBoolean(rule.isBloodFlow ?? rule.is_blood_flow),
|
||
hasHongZhong: toBoolean(rule.hasHongZhong ?? rule.has_hong_zhong),
|
||
}
|
||
: null,
|
||
state: rawState
|
||
? {
|
||
phase: toStringOrEmpty(rawState.phase),
|
||
dealerIndex: toFiniteNumber(rawState.dealerIndex ?? rawState.dealer_index) ?? 0,
|
||
currentTurn: toFiniteNumber(rawState.currentTurn ?? rawState.current_turn) ?? 0,
|
||
needDraw: toBoolean(rawState.needDraw ?? rawState.need_draw),
|
||
players: normalizedPlayers,
|
||
wall: Array.isArray(rawState.wall) ? rawState.wall.map((item) => toStringOrEmpty(item)).filter(Boolean) : [],
|
||
lastDiscardTile: toStringOrEmpty(rawState.lastDiscardTile ?? rawState.last_discard_tile) || null,
|
||
lastDiscardBy: toStringOrEmpty(rawState.lastDiscardBy ?? rawState.last_discard_by),
|
||
pendingClaim: toRecord(rawState.pendingClaim ?? rawState.pending_claim),
|
||
winners: Array.isArray(rawState.winners)
|
||
? rawState.winners.map((item) => toStringOrEmpty(item)).filter(Boolean)
|
||
: [],
|
||
scores: normalizeScores(rawState.scores),
|
||
lastDrawPlayerId: toStringOrEmpty(rawState.lastDrawPlayerID ?? rawState.last_draw_player_id),
|
||
lastDrawFromGang: toBoolean(rawState.lastDrawFromGang ?? rawState.last_draw_from_gang),
|
||
lastDrawIsLastTile: toBoolean(rawState.lastDrawIsLastTile ?? rawState.last_draw_is_last_tile),
|
||
huWay: toStringOrEmpty(rawState.huWay ?? rawState.hu_way),
|
||
}
|
||
: null,
|
||
}
|
||
}
|
||
|
||
function normalizeRoom(input: unknown): RoomState | null {
|
||
const source = toRecord(input)
|
||
if (!source) {
|
||
return null
|
||
}
|
||
|
||
let room = source
|
||
let id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
|
||
if (!id) {
|
||
const nestedRoom = toRecord(room.data)
|
||
if (nestedRoom) {
|
||
room = nestedRoom
|
||
id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
|
||
}
|
||
}
|
||
if (!id) {
|
||
return null
|
||
}
|
||
|
||
const maxPlayers =
|
||
toFiniteNumber(room.maxPlayers ?? room.max_players) ?? roomState.value.maxPlayers ?? DEFAULT_MAX_PLAYERS
|
||
const playersRaw =
|
||
(Array.isArray(room.players) ? room.players : null) ??
|
||
(Array.isArray(room.playerList) ? room.playerList : null) ??
|
||
(Array.isArray(room.player_list) ? room.player_list : null) ??
|
||
[]
|
||
const playerIdsRaw =
|
||
(Array.isArray(room.player_ids) ? room.player_ids : null) ??
|
||
(Array.isArray(room.playerIds) ? room.playerIds : null) ??
|
||
[]
|
||
|
||
const players = playersRaw
|
||
.map((item, index) => normalizePlayer(item, index))
|
||
.filter((item): item is RoomPlayerState => Boolean(item))
|
||
.sort((a, b) => a.index - b.index)
|
||
const playersFromIds = playerIdsRaw
|
||
.map((item, index) => ({
|
||
index,
|
||
playerId: toStringOrEmpty(item),
|
||
ready: false,
|
||
}))
|
||
.filter((item) => Boolean(item.playerId))
|
||
const resolvedPlayers = players.length > 0 ? players : playersFromIds
|
||
const parsedPlayerCount = toFiniteNumber(room.player_count ?? room.playerCount)
|
||
const game = normalizeGame(room.game)
|
||
|
||
return {
|
||
id,
|
||
name: toStringOrEmpty(room.name) || roomState.value.name,
|
||
gameType: toStringOrEmpty(room.gameType ?? room.game_type) || roomState.value.gameType || 'chengdu',
|
||
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id),
|
||
maxPlayers,
|
||
playerCount: parsedPlayerCount ?? resolvedPlayers.length,
|
||
status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting',
|
||
createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || roomState.value.createdAt,
|
||
updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || roomState.value.updatedAt,
|
||
game: game ?? roomState.value.game,
|
||
players: resolvedPlayers,
|
||
currentTurnIndex: extractCurrentTurnIndex(room),
|
||
}
|
||
}
|
||
|
||
function mergeRoomState(next: RoomState): void {
|
||
if (roomId.value && next.id !== roomId.value) {
|
||
return
|
||
}
|
||
mergeActiveRoomState(next)
|
||
}
|
||
|
||
function consumeGameEvent(raw: string): void {
|
||
let parsed: unknown = null
|
||
try {
|
||
parsed = JSON.parse(raw)
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
const event = toRecord(parsed) as ActionEventLike | null
|
||
if (!event) {
|
||
return
|
||
}
|
||
|
||
const payload = toRecord(event.payload)
|
||
const data = toRecord(event.data)
|
||
const eventType = toStringOrEmpty(event.type)
|
||
const eventStatus = toStringOrEmpty(event.status)
|
||
const eventRoomId = toStringOrEmpty(event.roomId ?? event.room_id ?? payload?.roomId ?? payload?.room_id)
|
||
const eventRequestId = toStringOrEmpty(
|
||
event.requestId ?? event.request_id ?? payload?.requestId ?? payload?.request_id ?? data?.requestId ?? data?.request_id,
|
||
)
|
||
const payloadPlayerIds = Array.isArray(payload?.player_ids)
|
||
? payload.player_ids.map((item) => toStringOrEmpty(item)).filter(Boolean)
|
||
: Array.isArray(payload?.playerIds)
|
||
? payload.playerIds.map((item) => toStringOrEmpty(item)).filter(Boolean)
|
||
: null
|
||
const leaveByRequestIdMatched = Boolean(eventRequestId && eventRequestId === lastLeaveRoomRequestId.value)
|
||
const leaveByPlayerUpdateMatched =
|
||
leaveRoomPending.value &&
|
||
eventType === 'room_player_update' &&
|
||
eventStatus === 'ok' &&
|
||
eventRoomId === (roomState.value.id || roomId.value) &&
|
||
Array.isArray(payloadPlayerIds) &&
|
||
Boolean(currentUserId.value) &&
|
||
!payloadPlayerIds.includes(currentUserId.value)
|
||
|
||
if (leaveByRequestIdMatched || leaveByPlayerUpdateMatched) {
|
||
leaveRoomPending.value = false
|
||
lastLeaveRoomRequestId.value = ''
|
||
if (event.status === 'error') {
|
||
leaveHallAfterAck.value = false
|
||
wsError.value = '退出房间失败,请稍后重试'
|
||
pushWsMessage(`[client] 退出房间失败 requestId=${eventRequestId}`)
|
||
} else {
|
||
if (leaveByPlayerUpdateMatched) {
|
||
pushWsMessage('[client] 已确认退出房间 player_update')
|
||
} else {
|
||
pushWsMessage(`[client] 已确认退出房间 requestId=${eventRequestId}`)
|
||
}
|
||
if (leaveHallAfterAck.value) {
|
||
leaveHallAfterAck.value = false
|
||
void router.push('/hall')
|
||
}
|
||
}
|
||
}
|
||
|
||
const candidates: unknown[] = [event.payload, event.data]
|
||
if (payload) {
|
||
candidates.push(payload.room, payload.state, payload.roomState, payload.data)
|
||
}
|
||
if (data) {
|
||
candidates.push(data.room, data.state, data.roomState, data.data)
|
||
}
|
||
candidates.push(event)
|
||
|
||
for (const candidate of candidates) {
|
||
const normalized = normalizeRoom(candidate)
|
||
if (normalized) {
|
||
mergeRoomState(normalized)
|
||
break
|
||
}
|
||
}
|
||
|
||
if (
|
||
event.status === 'error' &&
|
||
typeof event.requestId === 'string' &&
|
||
event.requestId === lastStartRequestId.value
|
||
) {
|
||
startGamePending.value = false
|
||
}
|
||
}
|
||
|
||
function createRequestId(prefix: string): string {
|
||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||
}
|
||
|
||
function sendStartGame(): void {
|
||
if (!ws.value || ws.value.readyState !== WebSocket.OPEN || !canStartGame.value || startGamePending.value) {
|
||
return
|
||
}
|
||
|
||
const sender = currentUserId.value
|
||
|
||
if (!sender) {
|
||
return
|
||
}
|
||
|
||
const requestId = createRequestId('start-game')
|
||
lastStartRequestId.value = requestId
|
||
startGamePending.value = true
|
||
|
||
const message = {
|
||
type: 'start_game',
|
||
sender,
|
||
target: 'room',
|
||
roomId: roomState.value.id || roomId.value,
|
||
seq: Date.now(),
|
||
requestId,
|
||
trace_id: createRequestId('trace'),
|
||
payload: {},
|
||
}
|
||
|
||
logWsSend(message)
|
||
ws.value.send(JSON.stringify(message))
|
||
pushWsMessage(`[client] 请求开始游戏 requestId=${requestId}`)
|
||
}
|
||
|
||
function sendLeaveRoom(): boolean {
|
||
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
|
||
wsError.value = 'WebSocket 未连接,无法退出房间'
|
||
return false
|
||
}
|
||
|
||
const sender = currentUserId.value
|
||
const targetRoomId = roomState.value.id || roomId.value
|
||
if (!sender) {
|
||
wsError.value = '缺少当前用户ID,无法退出房间'
|
||
return false
|
||
}
|
||
if (!targetRoomId) {
|
||
wsError.value = '缺少房间ID,无法退出房间'
|
||
return false
|
||
}
|
||
|
||
const requestId = createRequestId('leave-room')
|
||
leaveRoomPending.value = true
|
||
lastLeaveRoomRequestId.value = requestId
|
||
const message = {
|
||
type: 'leave_room',
|
||
sender,
|
||
target: 'room',
|
||
roomId: targetRoomId,
|
||
seq: Date.now(),
|
||
requestId,
|
||
trace_id: createRequestId('trace'),
|
||
payload: {},
|
||
}
|
||
|
||
logWsSend(message)
|
||
ws.value.send(JSON.stringify(message))
|
||
pushWsMessage(`[client] 请求离开房间 requestId=${requestId}`)
|
||
pushWsMessage(`[client] 请求退出房间 requestId=${requestId}`)
|
||
return true
|
||
}
|
||
|
||
function connectWs(): void {
|
||
wsError.value = ''
|
||
const token = auth.value?.token
|
||
if (!token) {
|
||
wsError.value = '缺少 token,无法建立 WebSocket 连接'
|
||
return
|
||
}
|
||
|
||
disconnectWs()
|
||
wsStatus.value = 'connecting'
|
||
|
||
const url = `${WS_BASE_URL}?token=${encodeURIComponent(token)}`
|
||
const socket = new WebSocket(url)
|
||
ws.value = socket
|
||
|
||
socket.onopen = () => {
|
||
wsStatus.value = 'connected'
|
||
pushWsMessage('WebSocket 已连接')
|
||
}
|
||
|
||
socket.onmessage = (event) => {
|
||
if (typeof event.data === 'string') {
|
||
logWsReceive('收到文本消息', event.data)
|
||
try {
|
||
const parsed = JSON.parse(event.data)
|
||
logWsReceive('收到 JSON 消息', parsed)
|
||
pushWsMessage(`[server] ${JSON.stringify(parsed)}`)
|
||
} catch {
|
||
pushWsMessage(`[server] ${event.data}`)
|
||
}
|
||
|
||
consumeGameEvent(event.data)
|
||
return
|
||
}
|
||
|
||
logWsReceive('收到二进制消息')
|
||
pushWsMessage('[binary] 收到二进制消息')
|
||
}
|
||
|
||
socket.onerror = () => {
|
||
wsError.value = 'WebSocket 连接异常'
|
||
}
|
||
|
||
socket.onclose = () => {
|
||
wsStatus.value = 'disconnected'
|
||
startGamePending.value = false
|
||
if (leaveRoomPending.value) {
|
||
leaveRoomPending.value = false
|
||
lastLeaveRoomRequestId.value = ''
|
||
leaveHallAfterAck.value = false
|
||
wsError.value = '连接已断开,未收到退出房间确认'
|
||
pushWsMessage('[client] 连接断开:退出房间请求未确认')
|
||
}
|
||
pushWsMessage('WebSocket 已断开')
|
||
}
|
||
}
|
||
|
||
function chooseSuit(suit: MissingSuit): void {
|
||
chosenMissingSuit.value = suit
|
||
overlayState.value = null
|
||
pushWsMessage(`[ui] 已选择定缺 ${missingSuitLabel(suit)}`)
|
||
}
|
||
|
||
function dismissActionOverlay(action: 'peng' | 'pass'): void {
|
||
overlayState.value = null
|
||
pushWsMessage(`[ui] 选择${action === 'peng' ? '碰牌' : '过牌'}`)
|
||
}
|
||
|
||
function startNextRound(): void {
|
||
overlayState.value = null
|
||
roomState.value = {
|
||
...roomState.value,
|
||
status: 'playing',
|
||
}
|
||
pushWsMessage('[ui] 准备下一局')
|
||
}
|
||
|
||
watch(
|
||
roomId,
|
||
(nextRoomId) => {
|
||
const currentRoom = roomState.value
|
||
if (!nextRoomId) {
|
||
destroyActiveRoomState()
|
||
} else if (currentRoom.id !== nextRoomId) {
|
||
resetActiveRoomState({
|
||
id: nextRoomId,
|
||
name: roomName.value,
|
||
})
|
||
} else if (!currentRoom.name && roomName.value) {
|
||
roomState.value = { ...currentRoom, name: roomName.value }
|
||
}
|
||
overlayState.value = 'missing'
|
||
startGamePending.value = false
|
||
lastStartRequestId.value = ''
|
||
leaveRoomPending.value = false
|
||
lastLeaveRoomRequestId.value = ''
|
||
leaveHallAfterAck.value = false
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
watch(roomName, (next) => {
|
||
roomState.value = { ...roomState.value, name: next || roomState.value.name }
|
||
})
|
||
|
||
watch(
|
||
[canStartGame, wsStatus],
|
||
([canStart, status]) => {
|
||
if (!canStart || status !== 'connected') {
|
||
return
|
||
}
|
||
sendStartGame()
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
watch(
|
||
() => roomState.value.status,
|
||
(status) => {
|
||
if (status === 'playing' || status === 'finished') {
|
||
startGamePending.value = false
|
||
}
|
||
|
||
if (status === 'waiting') {
|
||
overlayState.value = 'missing'
|
||
return
|
||
}
|
||
|
||
if (status === 'playing' && overlayState.value === 'settlement') {
|
||
overlayState.value = 'action'
|
||
return
|
||
}
|
||
|
||
if (status === 'finished') {
|
||
overlayState.value = 'settlement'
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
onMounted(async () => {
|
||
await ensureCurrentUserId()
|
||
connectWs()
|
||
clockTimer = window.setInterval(() => {
|
||
now.value = new Date()
|
||
}, 1000)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
disconnectWs()
|
||
destroyActiveRoomState()
|
||
if (clockTimer !== undefined) {
|
||
window.clearInterval(clockTimer)
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<section class="game-page mahjong-stage">
|
||
<header class="game-topbar">
|
||
<div class="topbar-left">
|
||
<button class="chrome-btn" type="button" @click="backHall">☰</button>
|
||
<div class="status-chip wall-chip">
|
||
<span class="signal-block"></span>
|
||
<strong>{{ wallCount }}</strong>
|
||
</div>
|
||
<div class="status-chip room-chip">
|
||
<span class="room-chip-label">房间</span>
|
||
<strong>{{ roomState.name || roomName || '成都麻将' }}</strong>
|
||
</div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<button class="ghost-btn" type="button" :disabled="leaveRoomPending" @click="backHall">
|
||
{{ leaveRoomPending ? '退出中...' : '返回大厅' }}
|
||
</button>
|
||
|
||
<div class="topbar-center">
|
||
<div class="title-stack">
|
||
<p class="game-title">成都麻将实战桌</p>
|
||
<p class="game-subtitle">{{ roomStatusText }} · {{ currentPhaseText }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="topbar-right">
|
||
<div class="status-chip net-chip">
|
||
<span class="wifi-dot" :class="`is-${wsStatus}`"></span>
|
||
<strong>{{ networkLabel }}</strong>
|
||
</div>
|
||
<div class="status-chip clock-chip">{{ formattedClock }}</div>
|
||
<button class="header-btn ghost-btn" type="button" @click="connectWs">重连</button>
|
||
<section class="table-panel game-table-panel">
|
||
<div class="room-brief">
|
||
<span class="room-brief-title">当前房间</span>
|
||
<span class="room-brief-item">
|
||
<em>房间名:</em>
|
||
<strong>{{ roomState.name || roomName || '未命名房间' }}</strong>
|
||
</span>
|
||
<span class="room-brief-item room-brief-id">
|
||
<em>room_id:</em>
|
||
<strong>{{ roomId || '未选择房间' }}</strong>
|
||
</span>
|
||
<span class="room-brief-item">
|
||
<em>状态:</em>
|
||
<strong>{{ roomStatusText }}</strong>
|
||
</span>
|
||
<span class="room-brief-item">
|
||
<em>人数:</em>
|
||
<strong>{{ roomState.playerCount }}/{{ roomState.maxPlayers }}</strong>
|
||
</span>
|
||
<button
|
||
class="header-btn primary-btn"
|
||
type="button"
|
||
:disabled="!canStartGame || startGamePending"
|
||
@click="sendStartGame"
|
||
>
|
||
{{ startGamePending ? '开局中...' : '开始' }}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="table-shell">
|
||
<img class="table-desk" :src="deskImage" alt="" />
|
||
<div class="table-felt">
|
||
<div class="felt-frame outer"></div>
|
||
<div class="felt-frame inner"></div>
|
||
<div class="table-watermark">
|
||
<span>{{ statusRibbon }}</span>
|
||
<strong>指尖四川麻将</strong>
|
||
<small>底注 6亿 · 封顶 32倍</small>
|
||
</div>
|
||
|
||
<article
|
||
v-for="player in seatDecor"
|
||
:key="player.seat"
|
||
class="player-badge"
|
||
:class="[`seat-${player.seat}`, { 'is-turn': player.isTurn, offline: !player.isOnline }]"
|
||
>
|
||
<div class="avatar-card">{{ player.avatar }}</div>
|
||
<div class="player-meta">
|
||
<p>{{ player.name }}</p>
|
||
<strong>{{ player.money }}</strong>
|
||
</div>
|
||
<span v-if="player.dealer" class="dealer-mark">庄</span>
|
||
<span class="missing-mark">{{ missingSuitLabel(player.missingSuit) }}</span>
|
||
</article>
|
||
|
||
<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="center-deck" :class="`state-${roomState.status}`">
|
||
<span class="wind north">北</span>
|
||
<span class="wind west">西</span>
|
||
<span class="wind south">南</span>
|
||
<span class="wind east">东</span>
|
||
<strong>{{ centerTimer }}</strong>
|
||
</div>
|
||
|
||
<div
|
||
v-for="seat in (['top', 'right', 'bottom', 'left'] as SeatKey[])"
|
||
:key="`${seat}-melds`"
|
||
class="meld-lane"
|
||
:class="`lane-${seat}`"
|
||
>
|
||
<div v-for="(meld, meldIndex) in meldsBySeat[seat]" :key="`${seat}-meld-${meldIndex}`" class="meld-group">
|
||
<img
|
||
v-for="tile in meld"
|
||
:key="tile.key"
|
||
:src="getTileImage(seat === 'bottom' ? 'bottom' : seat, tile)"
|
||
alt=""
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-for="seat in (['top', 'right', 'bottom', 'left'] as SeatKey[])"
|
||
:key="`${seat}-discards`"
|
||
class="discard-lane"
|
||
:class="`lane-${seat}`"
|
||
>
|
||
<img
|
||
v-for="tile in discardsBySeat[seat]"
|
||
:key="tile.key"
|
||
:src="getTileImage(seat === 'bottom' ? 'bottom' : seat, tile)"
|
||
alt=""
|
||
/>
|
||
</div>
|
||
|
||
<div class="phase-banner" :class="`phase-${overlayState ?? 'idle'}`">
|
||
{{ currentPhaseText }}
|
||
</div>
|
||
|
||
<div v-if="overlayState === 'missing'" class="overlay-panel choose-panel">
|
||
<h2>请选择定缺的花色</h2>
|
||
<p>你选择后,缺门信息会展示在头像侧边。</p>
|
||
<div class="choose-actions">
|
||
<button class="suit-btn suit-wan" type="button" @click="chooseSuit('wan')">万</button>
|
||
<button class="suit-btn suit-tiao" type="button" @click="chooseSuit('tiao')">条</button>
|
||
<button class="suit-btn suit-tong" type="button" @click="chooseSuit('tong')">筒</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="overlayState === 'action'" class="overlay-panel action-panel">
|
||
<p>右侧玩家刚打出一张牌,你可以选择响应。</p>
|
||
<div class="action-burst">
|
||
<button class="burst-btn peng" type="button" @click="dismissActionOverlay('peng')">碰</button>
|
||
<button class="burst-btn pass" type="button" @click="dismissActionOverlay('pass')">过</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="overlayState === 'settlement'" class="overlay-panel result-panel">
|
||
<div class="settlement-score">
|
||
<span class="loss-score">-6亿</span>
|
||
<span class="win-score">+6亿</span>
|
||
</div>
|
||
<h2>胡</h2>
|
||
<p>清晰展示了结束态、得分和下一局入口。</p>
|
||
<button class="next-round-btn" type="button" @click="startNextRound">下一局</button>
|
||
</div>
|
||
|
||
<div class="bottom-hand">
|
||
<div class="hand-row">
|
||
<button
|
||
v-for="(tile, index) in bottomHandTiles"
|
||
:key="tile.key"
|
||
class="hand-tile"
|
||
:class="{ selected: index === selectedHandIndex, glow: tile.glow }"
|
||
type="button"
|
||
@click="selectedHandIndex = index"
|
||
>
|
||
<img :src="getTileImage('bottom', tile)" alt="" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="game-footer">
|
||
<div class="room-summary">
|
||
<span>玩家:{{ loggedInUserName || '未登录玩家' }}</span>
|
||
<span>Room ID:{{ roomState.id || roomId || '未选择房间' }}</span>
|
||
<span>人数:{{ roomState.players.length }}/{{ roomState.maxPlayers }}</span>
|
||
<span>WebSocket:{{ wsStatus }}</span>
|
||
</div>
|
||
|
||
<div class="ws-console">
|
||
<div class="console-head">
|
||
<strong>实时消息</strong>
|
||
<span v-if="wsError" class="console-error">{{ wsError }}</span>
|
||
</div>
|
||
<div class="console-body">
|
||
<p v-if="wsMessages.length === 0" class="console-empty">等待服务器消息...</p>
|
||
<p v-for="(line, idx) in wsMessages" :key="idx" class="console-line">{{ line }}</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.mahjong-stage {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
padding: 14px;
|
||
background:
|
||
radial-gradient(circle at top, rgba(143, 91, 63, 0.1), transparent 35%),
|
||
linear-gradient(180deg, #341d1c 0%, #201211 48%, #130b0b 100%);
|
||
}
|
||
|
||
.game-topbar {
|
||
display: grid;
|
||
grid-template-columns: 1fr auto 1fr;
|
||
gap: 16px;
|
||
align-items: center;
|
||
padding: 10px 14px;
|
||
border: 1px solid rgba(255, 223, 148, 0.16);
|
||
border-radius: 18px;
|
||
background:
|
||
linear-gradient(180deg, rgba(44, 22, 24, 0.96), rgba(24, 13, 14, 0.98)),
|
||
repeating-linear-gradient(
|
||
90deg,
|
||
rgba(255, 255, 255, 0.02) 0,
|
||
rgba(255, 255, 255, 0.02) 1px,
|
||
rgba(0, 0, 0, 0.02) 1px,
|
||
rgba(0, 0, 0, 0.02) 6px
|
||
);
|
||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
|
||
}
|
||
|
||
.topbar-left,
|
||
.topbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.topbar-right {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.topbar-center {
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.title-stack {
|
||
text-align: center;
|
||
}
|
||
|
||
.game-title {
|
||
font-size: 24px;
|
||
font-weight: 800;
|
||
color: #f8e9ae;
|
||
letter-spacing: 2px;
|
||
}
|
||
|
||
.game-subtitle {
|
||
margin-top: 2px;
|
||
font-size: 13px;
|
||
color: #cfbfa2;
|
||
}
|
||
|
||
.chrome-btn {
|
||
width: 48px;
|
||
height: 48px;
|
||
border: 0;
|
||
border-radius: 50%;
|
||
color: #dceaf2;
|
||
background: radial-gradient(circle at 35% 30%, #7b8388, #364147 68%, #232b2f 100%);
|
||
box-shadow: inset 0 2px 4px rgba(255, 255, 255, 0.18), 0 8px 18px rgba(0, 0, 0, 0.26);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.status-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-height: 42px;
|
||
padding: 0 14px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
background: rgba(8, 12, 15, 0.58);
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||
}
|
||
|
||
.signal-block {
|
||
width: 22px;
|
||
height: 18px;
|
||
border-radius: 4px;
|
||
background: linear-gradient(180deg, #1ce948, #148f2c);
|
||
box-shadow: 0 0 10px rgba(28, 233, 72, 0.35);
|
||
}
|
||
|
||
.room-chip-label {
|
||
font-size: 12px;
|
||
color: #bfcecc;
|
||
}
|
||
|
||
.wifi-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
background: #999;
|
||
}
|
||
|
||
.wifi-dot.is-connected {
|
||
background: #29de58;
|
||
box-shadow: 0 0 12px rgba(41, 222, 88, 0.35);
|
||
}
|
||
|
||
.wifi-dot.is-connecting {
|
||
background: #e0b244;
|
||
}
|
||
|
||
.wifi-dot.is-disconnected {
|
||
background: #d56363;
|
||
}
|
||
|
||
.header-btn {
|
||
height: 40px;
|
||
padding: 0 16px;
|
||
}
|
||
|
||
.table-shell {
|
||
position: relative;
|
||
flex: 1;
|
||
min-height: 760px;
|
||
padding: 14px;
|
||
border-radius: 26px;
|
||
background:
|
||
linear-gradient(145deg, rgba(57, 37, 27, 0.95), rgba(24, 15, 12, 0.95)),
|
||
radial-gradient(circle at top left, rgba(255, 230, 149, 0.08), transparent 28%);
|
||
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.35);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.table-desk {
|
||
position: absolute;
|
||
inset: 14px;
|
||
width: calc(100% - 28px);
|
||
height: calc(100% - 28px);
|
||
object-fit: cover;
|
||
opacity: 0.2;
|
||
mix-blend-mode: screen;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.table-felt {
|
||
position: relative;
|
||
height: 100%;
|
||
border-radius: 22px;
|
||
background:
|
||
radial-gradient(circle at 50% 35%, rgba(143, 187, 248, 0.32), transparent 28%),
|
||
linear-gradient(180deg, #4d6d98 0%, #3a5f8e 52%, #315480 100%);
|
||
border: 2px solid rgba(255, 211, 128, 0.42);
|
||
box-shadow:
|
||
inset 0 0 0 3px rgba(0, 0, 0, 0.18),
|
||
inset 0 0 40px rgba(255, 255, 255, 0.04);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.felt-frame {
|
||
position: absolute;
|
||
inset: 54px 112px;
|
||
border: 2px solid rgba(202, 221, 255, 0.14);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.felt-frame.inner {
|
||
inset: 118px 244px 168px;
|
||
}
|
||
|
||
.table-watermark {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 136px;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: rgba(0, 0, 0, 0.2);
|
||
text-align: center;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.table-watermark span {
|
||
color: rgba(255, 244, 184, 0.68);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.table-watermark strong {
|
||
font-size: 54px;
|
||
font-weight: 900;
|
||
letter-spacing: 4px;
|
||
opacity: 0.38;
|
||
}
|
||
|
||
.table-watermark small {
|
||
font-size: 28px;
|
||
opacity: 0.36;
|
||
}
|
||
|
||
.player-badge {
|
||
position: absolute;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
z-index: 2;
|
||
}
|
||
|
||
.player-badge.is-turn .avatar-card {
|
||
box-shadow: 0 0 0 2px rgba(255, 242, 184, 0.72), 0 10px 24px rgba(0, 0, 0, 0.26);
|
||
}
|
||
|
||
.player-badge.offline {
|
||
opacity: 0.65;
|
||
}
|
||
|
||
.avatar-card {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 74px;
|
||
height: 74px;
|
||
border: 2px solid rgba(188, 242, 223, 0.5);
|
||
border-radius: 4px;
|
||
color: #fff4dd;
|
||
font-size: 30px;
|
||
font-weight: 900;
|
||
background:
|
||
linear-gradient(145deg, rgba(107, 212, 177, 0.88), rgba(44, 110, 133, 0.92)),
|
||
radial-gradient(circle at 35% 35%, rgba(255, 255, 255, 0.28), transparent 45%);
|
||
}
|
||
|
||
.player-meta {
|
||
min-width: 88px;
|
||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
.player-meta p {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.player-meta strong {
|
||
display: block;
|
||
margin-top: 4px;
|
||
color: #ffdd4b;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.dealer-mark,
|
||
.missing-mark {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 50%;
|
||
font-size: 20px;
|
||
font-weight: 900;
|
||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
|
||
}
|
||
|
||
.dealer-mark {
|
||
background: radial-gradient(circle at 35% 30%, #ffe782, #bf8308 72%);
|
||
color: #5c3400;
|
||
}
|
||
|
||
.missing-mark {
|
||
background: radial-gradient(circle at 35% 30%, #a7e8ff, #2d7db6 78%);
|
||
color: #f6fcff;
|
||
}
|
||
|
||
.seat-top {
|
||
top: 28px;
|
||
right: 180px;
|
||
}
|
||
|
||
.seat-right {
|
||
top: 150px;
|
||
right: 42px;
|
||
}
|
||
|
||
.seat-bottom {
|
||
left: 56px;
|
||
bottom: 116px;
|
||
}
|
||
|
||
.seat-left {
|
||
left: 42px;
|
||
top: 180px;
|
||
}
|
||
|
||
.wall {
|
||
position: absolute;
|
||
display: flex;
|
||
gap: 2px;
|
||
z-index: 1;
|
||
}
|
||
|
||
.wall img {
|
||
display: block;
|
||
}
|
||
|
||
.wall-top {
|
||
left: 50%;
|
||
top: 24px;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.wall-top img {
|
||
width: 52px;
|
||
height: auto;
|
||
}
|
||
|
||
.wall-bottom {
|
||
left: 50%;
|
||
bottom: 146px;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.wall-bottom img {
|
||
width: 54px;
|
||
height: auto;
|
||
}
|
||
|
||
.wall-left {
|
||
left: 94px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
flex-direction: column;
|
||
}
|
||
|
||
.wall-left img,
|
||
.wall-right img {
|
||
width: 46px;
|
||
height: auto;
|
||
}
|
||
|
||
.wall-right {
|
||
right: 104px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
flex-direction: column;
|
||
}
|
||
|
||
.center-deck {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
width: 164px;
|
||
height: 132px;
|
||
transform: translate(-50%, -50%);
|
||
border-radius: 22px;
|
||
background:
|
||
linear-gradient(145deg, #4c1f24 0 22%, #2d2f38 22% 74%, #173f1f 74% 100%);
|
||
border: 4px solid rgba(230, 190, 95, 0.32);
|
||
box-shadow: 0 20px 34px rgba(0, 0, 0, 0.22);
|
||
}
|
||
|
||
.center-deck strong {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
color: #2eb4ff;
|
||
font-size: 42px;
|
||
font-weight: 900;
|
||
letter-spacing: 4px;
|
||
}
|
||
|
||
.wind {
|
||
position: absolute;
|
||
color: rgba(255, 255, 255, 0.46);
|
||
font-size: 22px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.wind.north {
|
||
top: 8px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.wind.south {
|
||
bottom: 8px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.wind.west {
|
||
top: 50%;
|
||
left: 12px;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.wind.east {
|
||
top: 50%;
|
||
right: 12px;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.meld-lane,
|
||
.discard-lane {
|
||
position: absolute;
|
||
display: flex;
|
||
gap: 4px;
|
||
z-index: 2;
|
||
}
|
||
|
||
.meld-lane {
|
||
gap: 12px;
|
||
}
|
||
|
||
.meld-group {
|
||
display: flex;
|
||
gap: 2px;
|
||
}
|
||
|
||
.lane-top {
|
||
left: 50%;
|
||
top: 118px;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.lane-right {
|
||
top: 50%;
|
||
right: 240px;
|
||
transform: translateY(-50%);
|
||
flex-direction: column;
|
||
}
|
||
|
||
.lane-bottom {
|
||
left: 50%;
|
||
bottom: 220px;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.meld-lane.lane-left,
|
||
.discard-lane.lane-left {
|
||
top: 50%;
|
||
left: 226px;
|
||
transform: translateY(-50%);
|
||
flex-direction: column;
|
||
}
|
||
|
||
.meld-lane img,
|
||
.discard-lane img {
|
||
display: block;
|
||
}
|
||
|
||
.discard-lane.lane-top img,
|
||
.meld-lane.lane-top img {
|
||
width: 54px;
|
||
}
|
||
|
||
.discard-lane.lane-bottom img,
|
||
.meld-lane.lane-bottom img {
|
||
width: 58px;
|
||
}
|
||
|
||
.discard-lane.lane-right img,
|
||
.discard-lane.lane-left img,
|
||
.meld-lane.lane-right img,
|
||
.meld-lane.lane-left img {
|
||
width: 44px;
|
||
}
|
||
|
||
.discard-lane.lane-top {
|
||
top: 188px;
|
||
}
|
||
|
||
.discard-lane.lane-bottom {
|
||
bottom: 282px;
|
||
max-width: 580px;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
}
|
||
|
||
.discard-lane.lane-right {
|
||
right: 260px;
|
||
}
|
||
|
||
.discard-lane.lane-left {
|
||
left: 248px;
|
||
}
|
||
|
||
.phase-banner {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 58%;
|
||
transform: translate(-50%, -50%);
|
||
min-width: 380px;
|
||
padding: 16px 28px;
|
||
border-radius: 14px;
|
||
background: rgba(17, 24, 45, 0.3);
|
||
color: #f8f7f2;
|
||
font-size: 18px;
|
||
text-align: center;
|
||
letter-spacing: 1px;
|
||
z-index: 3;
|
||
}
|
||
|
||
.overlay-panel {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 146px;
|
||
transform: translateX(-50%);
|
||
z-index: 4;
|
||
}
|
||
|
||
.choose-panel,
|
||
.action-panel,
|
||
.result-panel {
|
||
padding: 18px 22px;
|
||
border-radius: 22px;
|
||
border: 1px solid rgba(255, 235, 181, 0.22);
|
||
background: linear-gradient(180deg, rgba(13, 24, 52, 0.62), rgba(8, 18, 34, 0.82));
|
||
backdrop-filter: blur(5px);
|
||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
|
||
text-align: center;
|
||
}
|
||
|
||
.choose-panel h2,
|
||
.result-panel h2 {
|
||
font-size: 34px;
|
||
color: #fbe497;
|
||
}
|
||
|
||
.choose-panel p,
|
||
.action-panel p,
|
||
.result-panel p {
|
||
margin-top: 6px;
|
||
color: #dfe8ff;
|
||
}
|
||
|
||
.choose-actions,
|
||
.action-burst {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 18px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.suit-btn,
|
||
.burst-btn {
|
||
width: 108px;
|
||
height: 108px;
|
||
border: 0;
|
||
border-radius: 50%;
|
||
color: #fffaf2;
|
||
font-size: 56px;
|
||
font-weight: 900;
|
||
cursor: pointer;
|
||
box-shadow: inset 0 2px 8px rgba(255, 255, 255, 0.28), 0 18px 24px rgba(0, 0, 0, 0.22);
|
||
}
|
||
|
||
.suit-wan {
|
||
background: radial-gradient(circle at 35% 30%, #ff9b77, #e5392a 70%);
|
||
}
|
||
|
||
.suit-tiao {
|
||
background: radial-gradient(circle at 35% 30%, #8ceda6, #00a447 72%);
|
||
}
|
||
|
||
.suit-tong {
|
||
background: radial-gradient(circle at 35% 30%, #89e0ff, #3d81d7 72%);
|
||
}
|
||
|
||
.burst-btn.peng {
|
||
background: radial-gradient(circle at 35% 30%, #ffe57c, #f0ac11 72%);
|
||
color: #6f4200;
|
||
}
|
||
|
||
.burst-btn.pass {
|
||
background: radial-gradient(circle at 35% 30%, #81f6d3, #12ad84 72%);
|
||
}
|
||
|
||
.settlement-score {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
margin-bottom: 8px;
|
||
font-size: 34px;
|
||
font-weight: 900;
|
||
}
|
||
|
||
.loss-score {
|
||
color: #7ad0ff;
|
||
}
|
||
|
||
.win-score {
|
||
color: #ffe25a;
|
||
}
|
||
|
||
.next-round-btn {
|
||
min-width: 260px;
|
||
height: 82px;
|
||
margin-top: 18px;
|
||
border: 0;
|
||
border-radius: 18px;
|
||
color: #774600;
|
||
font-size: 36px;
|
||
font-weight: 900;
|
||
background: linear-gradient(180deg, #ffef84, #ffca2b);
|
||
box-shadow: 0 20px 28px rgba(0, 0, 0, 0.18);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.bottom-hand {
|
||
position: absolute;
|
||
left: 50%;
|
||
right: 46px;
|
||
bottom: 14px;
|
||
transform: translateX(-50%);
|
||
z-index: 3;
|
||
}
|
||
|
||
.hand-row {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 2px;
|
||
}
|
||
|
||
.hand-tile {
|
||
position: relative;
|
||
padding: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
transition: transform 0.18s ease, filter 0.18s ease;
|
||
}
|
||
|
||
.hand-tile img {
|
||
display: block;
|
||
width: 86px;
|
||
filter: drop-shadow(0 10px 8px rgba(0, 0, 0, 0.22));
|
||
}
|
||
|
||
.hand-tile.selected {
|
||
transform: translateY(-20px);
|
||
}
|
||
|
||
.hand-tile.glow img {
|
||
filter: drop-shadow(0 0 16px rgba(246, 178, 48, 0.75));
|
||
}
|
||
|
||
.game-footer {
|
||
display: grid;
|
||
grid-template-columns: 1fr 400px;
|
||
gap: 12px;
|
||
}
|
||
|
||
.room-summary {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px 16px;
|
||
padding: 14px 18px;
|
||
border-radius: 16px;
|
||
background: rgba(10, 13, 20, 0.66);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
color: #d8e0e5;
|
||
}
|
||
|
||
.ws-console {
|
||
border-radius: 16px;
|
||
background: rgba(10, 13, 20, 0.72);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.console-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
color: #f7e4b0;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
}
|
||
|
||
.console-error {
|
||
color: #ffadad;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.console-body {
|
||
max-height: 140px;
|
||
overflow: auto;
|
||
padding: 12px 14px;
|
||
}
|
||
|
||
.console-line,
|
||
.console-empty {
|
||
font-size: 12px;
|
||
color: #d2d9e0;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.console-line + .console-line {
|
||
margin-top: 6px;
|
||
}
|
||
|
||
@media (max-width: 1280px) {
|
||
.game-topbar {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.topbar-left,
|
||
.topbar-center,
|
||
.topbar-right {
|
||
justify-content: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.table-shell {
|
||
min-height: 680px;
|
||
}
|
||
|
||
.seat-top {
|
||
right: 104px;
|
||
}
|
||
|
||
.discard-lane.lane-right {
|
||
right: 220px;
|
||
}
|
||
|
||
.discard-lane.lane-left {
|
||
left: 208px;
|
||
}
|
||
|
||
.meld-lane.lane-right {
|
||
right: 204px;
|
||
}
|
||
|
||
.meld-lane.lane-left,
|
||
.discard-lane.lane-left {
|
||
left: 188px;
|
||
}
|
||
|
||
.hand-tile img {
|
||
width: 74px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 980px) {
|
||
.mahjong-stage {
|
||
padding: 10px;
|
||
}
|
||
|
||
.table-shell {
|
||
min-height: 600px;
|
||
}
|
||
|
||
.game-footer {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.player-meta {
|
||
min-width: 0;
|
||
}
|
||
|
||
.player-meta p {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.player-meta strong {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.avatar-card {
|
||
width: 60px;
|
||
height: 60px;
|
||
font-size: 24px;
|
||
}
|
||
|
||
.hand-tile img {
|
||
width: 62px;
|
||
}
|
||
|
||
.phase-banner {
|
||
min-width: min(92%, 380px);
|
||
font-size: 15px;
|
||
}
|
||
|
||
.suit-btn,
|
||
.burst-btn {
|
||
width: 88px;
|
||
height: 88px;
|
||
font-size: 46px;
|
||
}
|
||
|
||
.next-round-btn {
|
||
min-width: 220px;
|
||
height: 68px;
|
||
font-size: 28px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.table-shell {
|
||
min-height: 520px;
|
||
padding: 10px;
|
||
}
|
||
|
||
.table-desk {
|
||
inset: 10px;
|
||
width: calc(100% - 20px);
|
||
height: calc(100% - 20px);
|
||
}
|
||
|
||
.felt-frame {
|
||
display: none;
|
||
}
|
||
|
||
.seat-top,
|
||
.seat-right,
|
||
.seat-left,
|
||
.seat-bottom {
|
||
scale: 0.84;
|
||
transform-origin: center;
|
||
}
|
||
|
||
.seat-top {
|
||
top: 8px;
|
||
right: 24px;
|
||
}
|
||
|
||
.seat-right {
|
||
top: 88px;
|
||
right: -10px;
|
||
}
|
||
|
||
.seat-left {
|
||
left: -10px;
|
||
top: 118px;
|
||
}
|
||
|
||
.seat-bottom {
|
||
left: 10px;
|
||
bottom: 98px;
|
||
}
|
||
|
||
.wall-top img {
|
||
width: 34px;
|
||
}
|
||
|
||
.wall-bottom {
|
||
bottom: 114px;
|
||
}
|
||
|
||
.wall-bottom img {
|
||
width: 36px;
|
||
}
|
||
|
||
.wall-left {
|
||
left: 18px;
|
||
}
|
||
|
||
.wall-right {
|
||
right: 18px;
|
||
}
|
||
|
||
.wall-left img,
|
||
.wall-right img {
|
||
width: 28px;
|
||
}
|
||
|
||
.center-deck {
|
||
width: 124px;
|
||
height: 100px;
|
||
}
|
||
|
||
.center-deck strong {
|
||
font-size: 30px;
|
||
}
|
||
|
||
.wind {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.discard-lane.lane-top,
|
||
.meld-lane.lane-top {
|
||
top: 94px;
|
||
}
|
||
|
||
.discard-lane.lane-right,
|
||
.meld-lane.lane-right {
|
||
right: 88px;
|
||
}
|
||
|
||
.discard-lane.lane-left,
|
||
.meld-lane.lane-left {
|
||
left: 88px;
|
||
}
|
||
|
||
.discard-lane.lane-bottom,
|
||
.meld-lane.lane-bottom {
|
||
bottom: 184px;
|
||
}
|
||
|
||
.discard-lane.lane-top img,
|
||
.meld-lane.lane-top img {
|
||
width: 34px;
|
||
}
|
||
|
||
.discard-lane.lane-bottom img,
|
||
.meld-lane.lane-bottom img {
|
||
width: 34px;
|
||
}
|
||
|
||
.discard-lane.lane-left img,
|
||
.discard-lane.lane-right img,
|
||
.meld-lane.lane-left img,
|
||
.meld-lane.lane-right img {
|
||
width: 26px;
|
||
}
|
||
|
||
.bottom-hand {
|
||
right: 8px;
|
||
left: 8px;
|
||
transform: none;
|
||
}
|
||
|
||
.hand-row {
|
||
gap: 0;
|
||
overflow-x: auto;
|
||
justify-content: flex-start;
|
||
padding-bottom: 4px;
|
||
}
|
||
|
||
.hand-tile img {
|
||
width: 54px;
|
||
}
|
||
|
||
.hand-tile.selected {
|
||
transform: translateY(-12px);
|
||
}
|
||
|
||
.overlay-panel {
|
||
bottom: 86px;
|
||
width: calc(100% - 32px);
|
||
}
|
||
|
||
.choose-actions,
|
||
.action-burst {
|
||
gap: 10px;
|
||
}
|
||
|
||
.suit-btn,
|
||
.burst-btn {
|
||
width: 72px;
|
||
height: 72px;
|
||
font-size: 36px;
|
||
}
|
||
|
||
.settlement-score {
|
||
font-size: 28px;
|
||
}
|
||
|
||
.choose-panel h2,
|
||
.result-panel h2 {
|
||
font-size: 28px;
|
||
}
|
||
|
||
.table-watermark {
|
||
bottom: 98px;
|
||
}
|
||
|
||
.table-watermark strong {
|
||
font-size: 34px;
|
||
}
|
||
|
||
.table-watermark small {
|
||
font-size: 16px;
|
||
}
|
||
}
|
||
</style>
|