Files
mahjong-web/src/views/ChengduGamePage.vue
wsy182 4f6ef1d0ec refactor(game): 重构游戏模块并添加WebSocket客户端
- 修改开发环境配置中的代理目标地址
- 移除旧的活动房间状态管理模块
- 移除成都麻将游戏相关的测试用例
- 添加新的游戏动作发送功能
- 实现WebSocket客户端类并支持自动重连
- 添加WebSocket消息处理器注册机制
- 创建游戏相关状态类型定义
- 添加ID生成工具函数
- 移除废弃的游戏相关模块和常量定义
- 添加WebSocket消息结构定义
- 重构游戏状态相关类型定义
2026-03-24 23:42:03 +08:00

412 lines
13 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 wanIcon from '../assets/images/flowerClolor/wan.png'
import tongIcon from '../assets/images/flowerClolor/tong.png'
import tiaoIcon from '../assets/images/flowerClolor/tiao.png'
import robotIcon from '../assets/images/icons/robot.svg'
import exitIcon from '../assets/images/icons/exit.svg'
import '../assets/styles/room.css'
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'
const route = useRoute()
const router = useRouter()
const {
roomState,
roomName,
loggedInUserName,
wsStatus,
wsError,
wsMessages,
leaveRoomPending,
seatViews,
selectedTile,
connectWs,
selectTile,
backHall,
} = useChengduGameRoom(route, router)
const now = ref(Date.now())
let clockTimer: number | null = null
const menuOpen = ref(false)
const isTrustMode = ref(false)
const menuTriggerActive = ref(false)
let menuTriggerTimer: number | null = null
let menuOpenTimer: number | null = null
const roomStatusText = computed(() => {
if (roomState.value.status === 'playing') {
return '对局中'
}
if (roomState.value.status === 'finished') {
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: '发牌',
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 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 defaultMissingSuitLabel = missingSuitLabel(null)
const emptySeat = (avatar: string): SeatPlayerCardModel => ({
avatar,
name: '空位',
money: '--',
dealer: false,
isTurn: false,
isOnline: false,
missingSuitLabel: defaultMissingSuitLabel,
})
const result: Record<SeatKey, SeatPlayerCardModel> = {
top: emptySeat('1'),
right: emptySeat('2'),
bottom: emptySeat('我'),
left: emptySeat('4'),
}
for (const [index, seat] of seatViews.value.entries()) {
if (!seat.player) {
continue
}
const playerId = seat.player.playerId
const score = scoreMap[playerId]
result[seat.key] = {
avatar: seat.isSelf ? '我' : String(index + 1),
name: seat.isSelf ? '你自己' : seat.player.displayName || `玩家${seat.player.index + 1}`,
money: typeof score === 'number' ? String(score) : '--',
dealer: seat.player.index === dealerIndex,
isTurn: seat.isTurn,
isOnline: true,
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
}
}
return result
})
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
const floatingMissingSuit = computed(() => {
const suitMap: Record<string, string> = {
: wanIcon,
: tongIcon,
: tiaoIcon,
}
return {
top: suitMap[seatDecor.value.top.missingSuitLabel] ?? '',
left: suitMap[seatDecor.value.left.missingSuitLabel] ?? '',
right: suitMap[seatDecor.value.right.missingSuitLabel] ?? '',
}
})
function missingSuitLabel(value: string | null | undefined): string {
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 toggleMenu(): void {
menuTriggerActive.value = true
if (menuTriggerTimer !== null) {
window.clearTimeout(menuTriggerTimer)
}
menuTriggerTimer = window.setTimeout(() => {
menuTriggerActive.value = false
menuTriggerTimer = null
}, 180)
if (menuOpen.value) {
menuOpen.value = false
return
}
if (menuOpenTimer !== null) {
window.clearTimeout(menuOpenTimer)
}
menuOpenTimer = window.setTimeout(() => {
menuOpen.value = true
menuOpenTimer = null
}, 85)
}
function toggleTrustMode(): void {
isTrustMode.value = !isTrustMode.value
menuOpen.value = false
}
function handleLeaveRoom(): void {
menuOpen.value = false
backHall()
}
function handleGlobalClick(event: MouseEvent): void {
const target = event.target as HTMLElement | null
if (!target) {
return
}
if (target.closest('.menu-trigger-wrap')) {
return
}
menuOpen.value = false
}
function handleGlobalEsc(event: KeyboardEvent): void {
if (event.key === 'Escape') {
menuOpen.value = false
}
}
onMounted(() => {
clockTimer = window.setInterval(() => {
now.value = Date.now()
}, 1000)
window.addEventListener('click', handleGlobalClick)
window.addEventListener('keydown', handleGlobalEsc)
})
onBeforeUnmount(() => {
if (clockTimer !== null) {
window.clearInterval(clockTimer)
clockTimer = null
}
window.removeEventListener('click', handleGlobalClick)
window.removeEventListener('keydown', handleGlobalEsc)
if (menuTriggerTimer !== null) {
window.clearTimeout(menuTriggerTimer)
menuTriggerTimer = null
}
if (menuOpenTimer !== null) {
window.clearTimeout(menuOpenTimer)
menuOpenTimer = null
}
})
</script>
<template>
<section class="picture-scene">
<div class="picture-layout">
<section class="table-stage">
<img class="table-desk" :src="deskImage" alt=""/>
<div class="table-felt">
<div class="table-surface"></div>
<div class="inner-outline outer"></div>
<div class="inner-outline mid"></div>
<div class="top-left-tools">
<div class="menu-trigger-wrap">
<button
class="metal-circle menu-trigger"
:class="{ 'is-feedback': menuTriggerActive }"
type="button"
:disabled="leaveRoomPending"
@click.stop="toggleMenu"
>
<span class="menu-trigger-icon"></span>
</button>
<transition name="menu-pop">
<div v-if="menuOpen" class="menu-popover" @click.stop>
<div class="menu-list">
<button class="menu-item menu-item-delay-1" type="button" @click="toggleTrustMode">
<span class="menu-item-icon">
<img :src="robotIcon" alt="" />
</span>
<span>{{ isTrustMode ? '取消托管' : '托管' }}</span>
</button>
<button
class="menu-item menu-item-danger menu-item-delay-2"
type="button"
:disabled="leaveRoomPending"
@click="handleLeaveRoom"
>
<span class="menu-item-icon">
<img :src="exitIcon" alt="" />
</span>
<span>{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
</button>
</div>
</div>
</transition>
</div>
<div class="left-counter">
<span class="counter-light"></span>
<strong>{{ roomState.game?.state?.wall.length ?? 48 }}</strong>
</div>
<span v-if="isTrustMode" class="trust-chip">托管中</span>
</div>
<div class="top-right-clock">
<div class="signal-chip">
<span class="wifi-dot" :class="`is-${wsStatus}`"></span>
<strong>{{ networkLabel }}</strong>
</div>
<span>{{ formattedClock }}</span>
</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="floating-status top">
<img v-if="floatingMissingSuit.top" :src="floatingMissingSuit.top" alt=""/>
<span>{{ seatDecor.top.missingSuitLabel }}</span>
</div>
<div class="floating-status left">
<img v-if="floatingMissingSuit.left" :src="floatingMissingSuit.left" alt=""/>
<span>{{ seatDecor.left.missingSuitLabel }}</span>
</div>
<div class="floating-status right">
<img v-if="floatingMissingSuit.right" :src="floatingMissingSuit.right" alt=""/>
<span>{{ seatDecor.right.missingSuitLabel }}</span>
</div>
<div class="bottom-control-panel">
<div class="player-hand" v-if="roomState.myHand.length > 0">
<button
v-for="(tile, index) in roomState.myHand"
:key="`${tile}-${index}`"
class="tile-chip"
:class="{ selected: selectedTile === tile }"
type="button"
@click="selectTile(tile)"
>
{{ tile }}
</button>
</div>
</div>
</div>
</section>
<aside class="ws-sidebar">
<div class="sidebar-head">
<div>
<p class="sidebar-title">WebSocket 消息</p>
<small>{{ networkLabel }} · {{ loggedInUserName || '未登录昵称' }}</small>
</div>
<button class="sidebar-btn" type="button" @click="connectWs">重连</button>
</div>
<div class="sidebar-stats">
<div class="sidebar-stat">
<span>房间</span>
<strong>{{ roomState.name || roomName || '未命名' }}</strong>
</div>
<div class="sidebar-stat">
<span>阶段</span>
<strong>{{ currentPhaseText }}</strong>
</div>
<div class="sidebar-stat">
<span>人数</span>
<strong>{{ roomState.playerCount }}/{{ roomState.maxPlayers }}</strong>
</div>
<div class="sidebar-stat">
<span>状态</span>
<strong>{{ roomStatusText }}</strong>
</div>
</div>
<p v-if="wsError" class="sidebar-error">{{ wsError }}</p>
<div class="sidebar-log">
<p v-if="rightMessages.length === 0" class="sidebar-empty">等待服务器消息...</p>
<p v-for="(line, index) in rightMessages" :key="index" class="sidebar-line">{{ line }}</p>
</div>
</aside>
</div>
</section>
</template>