- 引入机器人和退出图标资源 - 实现游戏房间顶部菜单触发器和弹出菜单 - 添加托管模式切换功能 - 实现退出房间功能 - 添加全局点击和ESC键关闭菜单事件监听 - 优化菜单动画效果和交互反馈 - 移除侧边按钮区域的聊天、赞赏和开局按钮 - 调整时钟位置以适应新菜单布局
496 lines
15 KiB
Vue
496 lines
15 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'
|
||
import {type SeatKey, useChengduGameRoom} from '../features/chengdu-game/useChengduGameRoom'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const {
|
||
roomState,
|
||
roomId,
|
||
roomName,
|
||
loggedInUserName,
|
||
wsStatus,
|
||
wsError,
|
||
wsMessages,
|
||
startGamePending,
|
||
leaveRoomPending,
|
||
canStartGame,
|
||
seatViews,
|
||
selectedTile,
|
||
actionButtons,
|
||
connectWs,
|
||
sendStartGame,
|
||
selectTile,
|
||
sendGameAction,
|
||
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 '已结束'
|
||
}
|
||
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 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 centerTimer = computed(() => {
|
||
const wallLeft = roomState.value.game?.state?.wall.length
|
||
if (typeof wallLeft === 'number' && Number.isFinite(wallLeft)) {
|
||
return String(wallLeft).padStart(2, '0')
|
||
}
|
||
|
||
return String(roomState.value.playerCount).padStart(2, '0')
|
||
})
|
||
|
||
const selectedTileText = computed(() => selectedTile.value ?? '未选择')
|
||
|
||
const pendingClaimText = computed(() => {
|
||
const claim = roomState.value.game?.state?.pendingClaim
|
||
if (!claim) {
|
||
return '无'
|
||
}
|
||
|
||
try {
|
||
return JSON.stringify(claim)
|
||
} catch {
|
||
return '有待响应动作'
|
||
}
|
||
})
|
||
|
||
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 {
|
||
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 actionTheme(type: string): 'gold' | 'jade' | 'blue' {
|
||
if (type === 'hu' || type === 'gang') {
|
||
return 'gold'
|
||
}
|
||
if (type === 'pass') {
|
||
return 'jade'
|
||
}
|
||
return 'blue'
|
||
}
|
||
|
||
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="inner-outline diamond"></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>
|
||
|
||
<div class="scene-watermark">
|
||
<strong>指尖四川麻将</strong>
|
||
<span>{{ roomState.name || roomName || '成都麻将房' }}</span>
|
||
<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-desk">
|
||
<span class="wind north">北</span>
|
||
<span class="wind west">西</span>
|
||
<strong>{{ centerTimer }}</strong>
|
||
<span class="wind south">南</span>
|
||
<span class="wind east">东</span>
|
||
</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="claim-banner">
|
||
<span>{{ roomStatusText }}</span>
|
||
<strong>{{ currentPhaseText }}</strong>
|
||
</div>
|
||
|
||
<div class="bottom-control-panel">
|
||
<div class="control-copy">
|
||
<p>房间 {{ roomId || '--' }}</p>
|
||
<small>当前选择:{{ selectedTileText }} · 待响应:{{ pendingClaimText }}</small>
|
||
</div>
|
||
|
||
<div class="action-orbs">
|
||
<button
|
||
v-for="action in actionButtons"
|
||
:key="action.type"
|
||
class="orb-button"
|
||
:class="`theme-${actionTheme(action.type)}`"
|
||
type="button"
|
||
:disabled="action.disabled"
|
||
@click="sendGameAction(action.type)"
|
||
>
|
||
{{ action.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<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>
|
||
<p v-else class="empty-hand">等待服务端下发 `my_hand`。</p>
|
||
</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>
|