Files
mahjong-web/src/views/ChengduGamePage.vue
wsy182 0fa14ca407 feat(game): 实现成都麻将游戏界面和核心功能
- 添加麻将桌面背景和完整的UI布局设计
- 实现玩家座位渲染、牌面显示和游戏状态管理
- 集成定缺选择、碰牌操作和结算功能
- 添加计时器、网络状态和实时消息显示
- 创建麻将牌面图片资源和动态加载机制
- 实现游戏流程控制和玩家交互逻辑
2026-03-18 17:26:20 +08:00

2095 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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'
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 WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? 'ws://127.0.0.1:8080/ws'
const DEFAULT_MAX_PLAYERS = 4
type SeatKey = 'top' | 'right' | 'bottom' | 'left'
type TileSuit = 'wan' | 'tong' | 'tiao' | 'honor'
type MissingSuit = 'wan' | 'tiao' | 'tong'
type TileDirection = 'bottom' | 'top' | 'left' | 'right'
interface RoomPlayerState {
index: number
playerId: string
ready: boolean
}
interface RoomState {
id: string
name: string
ownerId: string
maxPlayers: number
status: string
players: RoomPlayerState[]
currentTurnIndex: number | null
}
interface ActionEventLike {
type?: unknown
status?: unknown
requestId?: unknown
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 candidate = auth.value?.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 = ref<RoomState>({
id: roomId.value,
name: roomName.value,
ownerId: '',
maxPlayers: DEFAULT_MAX_PLAYERS,
status: 'waiting',
players: [],
currentTurnIndex: null,
})
const isRoomFull = computed(() => {
return (
roomState.value.maxPlayers > 0 &&
roomState.value.players.length === 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 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 {
sendLeaveRoom()
void router.push('/hall')
}
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 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 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 keys = [
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 normalizeRoom(input: unknown): RoomState | null {
const room = toRecord(input)
if (!room) {
return null
}
const 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 players = playersRaw
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
.sort((a, b) => a.index - b.index)
return {
id,
name: toStringOrEmpty(room.name) || roomState.value.name,
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id),
maxPlayers,
status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting',
players,
currentTurnIndex: extractCurrentTurnIndex(room),
}
}
function mergeRoomState(next: RoomState): void {
if (roomId.value && next.id !== roomId.value) {
return
}
roomState.value = {
...roomState.value,
...next,
players: next.players.length > 0 ? next.players : roomState.value.players,
currentTurnIndex:
next.currentTurnIndex !== null ? next.currentTurnIndex : roomState.value.currentTurnIndex,
}
}
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 candidates: unknown[] = [event, event.payload, event.data]
const payload = toRecord(event.payload)
if (payload) {
candidates.push(payload.room, payload.state, payload.roomState)
}
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(): void {
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
return
}
const sender = currentUserId.value
const targetRoomId = roomState.value.id || roomId.value
if (!sender || !targetRoomId) {
return
}
const requestId = createRequestId('leave-room')
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}`)
}
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
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) => {
roomState.value = {
id: nextRoomId,
name: roomName.value,
ownerId: '',
maxPlayers: DEFAULT_MAX_PLAYERS,
status: 'waiting',
players: [],
currentTurnIndex: null,
}
overlayState.value = 'missing'
startGamePending.value = false
lastStartRequestId.value = ''
},
{ 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(() => {
connectWs()
clockTimer = window.setInterval(() => {
now.value = new Date()
}, 1000)
})
onBeforeUnmount(() => {
disconnectWs()
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="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>
<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>