Files
mahjong-web/src/views/ChengduGamePage.vue
wsy182 679116e455 feat(game): 添加游戏房间菜单和托管功能
- 引入机器人和退出图标资源
- 实现游戏房间顶部菜单触发器和弹出菜单
- 添加托管模式切换功能
- 实现退出房间功能
- 添加全局点击和ESC键关闭菜单事件监听
- 优化菜单动画效果和交互反馈
- 移除侧边按钮区域的聊天、赞赏和开局按钮
- 调整时钟位置以适应新菜单布局
2026-03-24 17:25:37 +08:00

496 lines
15 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 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>