refactor(ChengduGamePage): replace manual WebSocket logic with composable hook - Replace manual WebSocket connection and state management with useChengduGameRoom composable - Remove unused imports and authentication related code - Simplify component by extracting room state logic into separate hook - Clean up redundant functions and variables that are now handled by the composable - Update component lifecycle to use the new composable's methods for connecting WebSocket and managing room state ```
346 lines
11 KiB
Vue
346 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import deskImage from '../assets/images/desk/desk_01.png'
|
||
import topBackImage from '../assets/images/tiles/top/tbgs_2.png'
|
||
import rightBackImage from '../assets/images/tiles/right/tbgs_1.png'
|
||
import bottomBackImage from '../assets/images/tiles/bottom/tdbgs_4.png'
|
||
import leftBackImage from '../assets/images/tiles/left/tbgs_3.png'
|
||
import TopPlayerCard from '../components/game/TopPlayerCard.vue'
|
||
import RightPlayerCard from '../components/game/RightPlayerCard.vue'
|
||
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
|
||
import LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
|
||
import type { SeatPlayerCardModel } from '../components/game/seat-player-card'
|
||
import { useChengduGameRoom, type SeatKey } from '../features/chengdu-game/useChengduGameRoom'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const {
|
||
roomState,
|
||
roomId,
|
||
roomName,
|
||
loggedInUserName,
|
||
wsStatus,
|
||
wsError,
|
||
wsMessages,
|
||
startGamePending,
|
||
leaveRoomPending,
|
||
canStartGame,
|
||
seatViews,
|
||
connectWs,
|
||
sendStartGame,
|
||
backHall,
|
||
} = useChengduGameRoom(route, router)
|
||
|
||
const now = ref(Date.now())
|
||
let clockTimer: number | null = null
|
||
|
||
const roomStatusText = computed(() => {
|
||
if (roomState.value.status === 'playing') {
|
||
return '对局中'
|
||
}
|
||
if (roomState.value.status === 'finished') {
|
||
return '已结束'
|
||
}
|
||
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<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
||
const scoreMap = roomState.value.game?.state?.scores ?? {}
|
||
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
|
||
const emptyLabel = missingSuitLabel(null)
|
||
|
||
return seatViews.value.reduce(
|
||
(acc, seat, index) => {
|
||
const playerId = seat.player?.playerId ?? ''
|
||
const score = playerId ? scoreMap[playerId] : undefined
|
||
|
||
acc[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),
|
||
missingSuitLabel: emptyLabel,
|
||
}
|
||
|
||
return acc
|
||
},
|
||
{
|
||
top: {
|
||
avatar: '1',
|
||
name: '空位',
|
||
money: '--',
|
||
dealer: false,
|
||
isTurn: false,
|
||
isOnline: false,
|
||
missingSuitLabel: emptyLabel,
|
||
},
|
||
right: {
|
||
avatar: '2',
|
||
name: '空位',
|
||
money: '--',
|
||
dealer: false,
|
||
isTurn: false,
|
||
isOnline: false,
|
||
missingSuitLabel: emptyLabel,
|
||
},
|
||
bottom: {
|
||
avatar: '我',
|
||
name: '空位',
|
||
money: '--',
|
||
dealer: false,
|
||
isTurn: false,
|
||
isOnline: false,
|
||
missingSuitLabel: emptyLabel,
|
||
},
|
||
left: {
|
||
avatar: '4',
|
||
name: '空位',
|
||
money: '--',
|
||
dealer: false,
|
||
isTurn: false,
|
||
isOnline: false,
|
||
missingSuitLabel: emptyLabel,
|
||
},
|
||
},
|
||
)
|
||
})
|
||
|
||
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]
|
||
}
|
||
|
||
onMounted(() => {
|
||
clockTimer = window.setInterval(() => {
|
||
now.value = Date.now()
|
||
}, 1000)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (clockTimer !== null) {
|
||
window.clearInterval(clockTimer)
|
||
clockTimer = null
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<section class="hall-page game-page">
|
||
<header class="hall-header game-header">
|
||
<div class="topbar-left">
|
||
<button class="ghost-btn topbar-back-btn" type="button" :disabled="leaveRoomPending" @click="backHall">
|
||
{{ leaveRoomPending ? '退出中...' : '返回大厅' }}
|
||
</button>
|
||
<div class="topbar-room-meta">
|
||
<p class="eyebrow">成都麻将对局</p>
|
||
<p class="topbar-room-name">{{ roomState.name || roomName || '未命名房间' }}</p>
|
||
<p v-if="loggedInUserName" class="sub-title">玩家:{{ loggedInUserName }}</p>
|
||
</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>
|
||
</div>
|
||
</header>
|
||
|
||
<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="ghost-btn ws-reconnect"
|
||
type="button"
|
||
:disabled="!canStartGame || startGamePending"
|
||
@click="sendStartGame"
|
||
>
|
||
{{ startGamePending ? '开局请求中...' : '开始游戏' }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<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>
|
||
|
||
<TopPlayerCard :player="seatDecor.top" />
|
||
<RightPlayerCard :player="seatDecor.right" />
|
||
<BottomPlayerCard :player="seatDecor.bottom" />
|
||
<LeftPlayerCard :player="seatDecor.left" />
|
||
|
||
<div class="wall wall-top">
|
||
<img v-for="key in wallBacks.top" :key="key" :src="getBackImage('top')" alt="" />
|
||
</div>
|
||
<div class="wall wall-right">
|
||
<img v-for="key in wallBacks.right" :key="key" :src="getBackImage('right')" alt="" />
|
||
</div>
|
||
<div class="wall wall-bottom">
|
||
<img v-for="key in wallBacks.bottom" :key="key" :src="getBackImage('bottom')" alt="" />
|
||
</div>
|
||
<div class="wall wall-left">
|
||
<img v-for="key in wallBacks.left" :key="key" :src="getBackImage('left')" alt="" />
|
||
</div>
|
||
|
||
<div class="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 seatViews"
|
||
:key="seat.key"
|
||
class="seat"
|
||
:class="[
|
||
`seat-${seat.key}`,
|
||
{ occupied: Boolean(seat.player), 'seat-me': seat.isSelf, 'seat-turn': seat.isTurn },
|
||
]"
|
||
>
|
||
<strong>{{ seat.label }}</strong>
|
||
<small v-if="seat.subLabel">{{ seat.subLabel }}</small>
|
||
<span v-if="seat.isTurn" class="turn-indicator">出牌中</span>
|
||
</div>
|
||
<div class="table-center">
|
||
<p>成都麻将</p>
|
||
<p>{{ roomState.id || roomId || '等待中...' }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="ws-panel">
|
||
<div class="ws-panel-head">
|
||
<strong>实时消息</strong>
|
||
<div class="ws-actions">
|
||
<span class="ws-state" :class="`is-${wsStatus}`">{{ wsStatus }}</span>
|
||
<button class="ghost-btn ws-reconnect" type="button" @click="connectWs">重连</button>
|
||
</div>
|
||
</div>
|
||
<p v-if="wsError" class="message error">{{ wsError }}</p>
|
||
<div class="ws-log">
|
||
<p v-if="wsMessages.length === 0" class="ws-empty">等待服务器消息...</p>
|
||
<p v-for="(line, idx) in wsMessages" :key="idx" class="ws-line">{{ line }}</p>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
</section>
|
||
</template>
|