feat(game): 完善成都麻将游戏页面功能

- 添加WebSocket URL构建逻辑和认证令牌刷新功能
- 实现游戏状态显示包括阶段、网络状态、时钟等信息
- 添加游戏桌面背景图片和玩家座位装饰组件
- 重构CSS样式为网格布局提升响应式体验
- 配置环境变量支持API和WebSocket代理目标设置
- 优化WebSocket连接管理增加错误处理机制
- 添加游戏桌墙体和中心计数器等UI元素
- 修复多处字符串国际化和路径处理问题
This commit is contained in:
2026-03-24 13:44:53 +08:00
parent fcb9a02c68
commit a5c833c769
7 changed files with 657 additions and 42 deletions

View File

@@ -1,8 +1,13 @@
<script setup lang="ts">
<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 topBackImage from '../assets/images/tiles/top/tbgs_2.png'
import rightBackImage from '../assets/images/tiles/right/tbgs_1.png'
import bottomBackImage from '../assets/images/tiles/bottom/tdbgs_4.png'
import leftBackImage from '../assets/images/tiles/left/tbgs_3.png'
import type { AuthSession } from '../api/authed-request'
import { refreshAccessToken } from '../api/auth'
import { getUserInfo } from '../api/user'
import {
DEFAULT_MAX_PLAYERS,
@@ -29,8 +34,10 @@ const lastStartRequestId = ref('')
const leaveRoomPending = ref(false)
const lastLeaveRoomRequestId = ref('')
const leaveHallAfterAck = ref(false)
const now = ref(Date.now())
let clockTimer: number | null = null
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? 'ws://127.0.0.1:8080/ws'
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws'
type SeatKey = 'top' | 'right' | 'bottom' | 'left'
@@ -153,6 +160,116 @@ const roomStatusText = computed(() => {
return '等待中'
})
const currentPhaseText = computed(() => {
const phase = roomState.value.game?.state?.phase?.trim()
if (!phase) {
return roomState.value.status === 'playing' ? '牌局进行中' : '未开局'
}
const phaseLabelMap: Record<string, string> = {
dealing: '发牌',
draw: '摸牌',
discard: '出牌',
action: '响应',
settle: '结算',
finished: '已结束',
}
return phaseLabelMap[phase] ?? phase
})
const networkLabel = computed(() => {
if (wsStatus.value === 'connected') {
return '已连接'
}
if (wsStatus.value === 'connecting') {
return '连接中'
}
return '未连接'
})
const formattedClock = computed(() => {
return new Date(now.value).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
})
const statusRibbon = computed(() => {
return roomState.value.game?.rule?.name || currentPhaseText.value
})
const wallBacks = computed<Record<SeatKey, string[]>>(() => {
const wallSize = roomState.value.game?.state?.wall.length ?? 0
const perSide = Math.max(6, Math.ceil((wallSize || 48) / 4 / 2))
return {
top: Array.from({ length: perSide }, (_, index) => `top-${index}`),
right: Array.from({ length: perSide }, (_, index) => `right-${index}`),
bottom: Array.from({ length: perSide }, (_, index) => `bottom-${index}`),
left: Array.from({ length: perSide }, (_, index) => `left-${index}`),
}
})
const seatDecor = computed(() => {
const scoreMap = roomState.value.game?.state?.scores ?? {}
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
return seatViews.value.map((seat, index) => {
const playerId = seat.player?.playerId ?? ''
const score = playerId ? scoreMap[playerId] : undefined
return {
seat: seat.key,
avatar: seat.isSelf ? '我' : String(index + 1),
name: seat.player ? (seat.isSelf ? '你' : playerId) : '空位',
money: typeof score === 'number' ? `${score}` : '--',
dealer: seat.player?.index === dealerIndex,
isTurn: seat.isTurn,
isOnline: Boolean(seat.player),
missingSuit: null as string | null,
}
})
})
const centerTimer = computed(() => {
const wallLeft = roomState.value.game?.state?.wall.length
if (typeof wallLeft === 'number' && Number.isFinite(wallLeft)) {
return `余牌 ${wallLeft}`
}
return roomState.value.playerCount > 0
? `${roomState.value.playerCount}/${roomState.value.maxPlayers}`
: '等待中'
})
function missingSuitLabel(value: string | null | undefined): string {
if (!value) {
return '待定'
}
const suitMap: Record<string, string> = {
wan: '万',
tong: '筒',
tiao: '条',
}
return suitMap[value] ?? value
}
function getBackImage(seat: SeatKey): string {
const imageMap: Record<SeatKey, string> = {
top: topBackImage,
right: rightBackImage,
bottom: bottomBackImage,
left: leftBackImage,
}
return imageMap[seat]
}
function backHall(): void {
if (leaveRoomPending.value) {
return
@@ -162,7 +279,7 @@ function backHall(): void {
const sent = sendLeaveRoom()
if (!sent) {
leaveHallAfterAck.value = false
pushWsMessage('[client] 退出房间失败:未发送请求')
pushWsMessage('[client] Leave room request was not sent')
}
}
@@ -255,7 +372,41 @@ async function ensureCurrentUserId(): Promise<void> {
}
writeStoredAuth(auth.value)
} catch {
wsError.value = '获取当前用户ID失败部分操作可能不可用'
wsError.value = '获取当前用户 ID 失败,部分操作可能不可用'
}
}
async function ensureWsAuth(): Promise<string | null> {
const currentAuth = auth.value
if (!currentAuth?.token) {
return null
}
if (!currentAuth.refreshToken) {
return currentAuth.token
}
try {
const refreshed = await refreshAccessToken({
token: currentAuth.token,
tokenType: currentAuth.tokenType,
refreshToken: currentAuth.refreshToken,
})
const nextAuth = {
...currentAuth,
token: refreshed.token,
tokenType: refreshed.tokenType ?? currentAuth.tokenType,
refreshToken: refreshed.refreshToken ?? currentAuth.refreshToken,
expiresIn: refreshed.expiresIn,
user: refreshed.user ?? currentAuth.user,
}
auth.value = nextAuth
writeStoredAuth(nextAuth)
return nextAuth.token
} catch {
return currentAuth.token
}
}
@@ -588,11 +739,11 @@ function sendLeaveRoom(): boolean {
const sender = currentUserId.value
const targetRoomId = roomState.value.id || roomId.value
if (!sender) {
wsError.value = '缺少当前用户ID无法退出房间'
wsError.value = '缺少当前用户 ID无法退出房间'
return false
}
if (!targetRoomId) {
wsError.value = '缺少房间ID无法退出房间'
wsError.value = '缺少房间 ID无法退出房间'
return false
}
@@ -616,9 +767,9 @@ function sendLeaveRoom(): boolean {
return true
}
function connectWs(): void {
async function connectWs(): Promise<void> {
wsError.value = ''
const token = auth.value?.token
const token = await ensureWsAuth()
if (!token) {
wsError.value = '缺少 token无法建立 WebSocket 连接'
return
@@ -627,7 +778,7 @@ function connectWs(): void {
disconnectWs()
wsStatus.value = 'connecting'
const url = `${WS_BASE_URL}?token=${encodeURIComponent(token)}`
const url = buildWsUrl(token)
const socket = new WebSocket(url)
ws.value = socket
@@ -641,7 +792,7 @@ function connectWs(): void {
logWsReceive('文本消息', event.data)
try {
const parsed = JSON.parse(event.data)
logWsReceive('JSON消息', parsed)
logWsReceive('JSON 消息', parsed)
pushWsMessage(`[server] ${JSON.stringify(parsed, null, 2)}`)
} catch {
@@ -652,8 +803,8 @@ function connectWs(): void {
return
}
logWsReceive('二进制消息')
pushWsMessage('[binary] 收到二进制消息')
logWsReceive('binary message')
pushWsMessage('[binary] message received')
}
socket.onerror = () => {
@@ -668,12 +819,24 @@ function connectWs(): void {
lastLeaveRoomRequestId.value = ''
leaveHallAfterAck.value = false
wsError.value = '连接已断开,未收到退出房间确认'
pushWsMessage('[client] 连接断开退出房间请求未确认')
pushWsMessage('[client] 连接断开退出房间请求未确认')
}
pushWsMessage('WebSocket 已断开')
}
}
function buildWsUrl(token: string): string {
const baseUrl = /^wss?:\/\//.test(WS_BASE_URL)
? new URL(WS_BASE_URL)
: new URL(
WS_BASE_URL.startsWith('/') ? WS_BASE_URL : `/${WS_BASE_URL}`,
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
)
baseUrl.searchParams.set('token', token)
return baseUrl.toString()
}
watch(
roomId,
(nextRoomId) => {
@@ -722,11 +885,18 @@ watch(
)
onMounted(async () => {
clockTimer = window.setInterval(() => {
now.value = Date.now()
}, 1000)
await ensureCurrentUserId()
connectWs()
})
onBeforeUnmount(() => {
if (clockTimer !== null) {
window.clearInterval(clockTimer)
clockTimer = null
}
disconnectWs()
destroyActiveRoomState()
})
@@ -769,7 +939,7 @@ onBeforeUnmount(() => {
<strong>{{ roomState.name || roomName || '未命名房间' }}</strong>
</span>
<span class="room-brief-item room-brief-id">
<em>room_id</em>
<em>room_id:</em>
<strong>{{ roomId || '未选择房间' }}</strong>
</span>
<span class="room-brief-item">
@@ -799,7 +969,7 @@ onBeforeUnmount(() => {
<div class="table-watermark">
<span>{{ statusRibbon }}</span>
<strong>指尖四川麻将</strong>
<small>底注 6亿 · 封顶 32</small>
<small>底注 6 · 封顶 32 </small>
</div>
<article
@@ -851,8 +1021,8 @@ onBeforeUnmount(() => {
<span v-if="seat.isTurn" class="turn-indicator">出牌中</span>
</div>
<div class="table-center">
<p>Chengdu Mahjong</p>
<p>{{ roomState.id || roomId || 'Waiting...' }}</p>
<p>成都麻将</p>
<p>{{ roomState.id || roomId || '等待中...' }}</p>
</div>
</div>