Files
mahjong-web/src/views/ChengduGamePage.vue
wsy182 1b15748d0d ```
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
```
2026-03-24 14:12:04 +08:00

346 lines
11 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 } 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>