Files
mahjong-web/src/views/ChengduGamePage.vue
wsy182 e96c45739e feat(game): 添加成都麻将游戏页面和大厅功能
- 实现 ChengduGamePage.vue 组件,包含完整的麻将游戏界面
- 实现 HallPage.vue 组件,支持房间列表展示、创建和加入功能
- 添加 mahjong API 接口用于房间管理操作
- 集成 store 状态管理和本地存储功能
- 实现 ChengduBottomActions 等游戏控制组件
- 添加 websocket 连接和游戏会话管理逻辑
- 实现游戏倒计时、结算等功能模块
2026-04-03 20:46:50 +08:00

242 lines
9.1 KiB
Vue

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import deskImage from '../assets/images/desk/desk_01_1920_945.png'
import robotIcon from '../assets/images/icons/robot.svg'
import exitIcon from '../assets/images/icons/exit.svg'
import '../assets/styles/room.css'
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 WindSquare from '../components/game/WindSquare.vue'
import ChengduBottomActions from '../components/chengdu/ChengduBottomActions.vue'
import ChengduDeskZones from '../components/chengdu/ChengduDeskZones.vue'
import ChengduSettlementOverlay from '../components/chengdu/ChengduSettlementOverlay.vue'
import ChengduTableHeader from '../components/chengdu/ChengduTableHeader.vue'
import ChengduWallSeats from '../components/chengdu/ChengduWallSeats.vue'
import { useGameStore } from '../store/gameStore'
import { useRoomMetaSnapshotState } from '../store'
import { useChengduGameActions } from './chengdu/composables/useChengduGameActions'
import { useChengduGameSession } from './chengdu/composables/useChengduGameSession'
import { useChengduGameSocket } from './chengdu/composables/useChengduGameSocket'
import { formatTile, useChengduTableView } from './chengdu/composables/useChengduTableView'
import type { ActionCountdownView, DisplayPlayer } from './chengdu/types'
const gameStore = useGameStore()
const roomMeta = useRoomMetaSnapshotState()
const route = useRoute()
const router = useRouter()
const session = useChengduGameSession({
route,
router,
gameStore,
roomMeta,
})
const routeRoomName = computed(() => (typeof route.query.roomName === 'string' ? route.query.roomName : ''))
const myPlayer = computed(() => gameStore.players[session.loggedInUserId.value] as DisplayPlayer | undefined)
const myHandTiles = computed(() => myPlayer.value?.handTiles ?? [])
const gamePlayers = computed<DisplayPlayer[]>(() =>
Object.values(gameStore.players).sort((a, b) => a.seatIndex - b.seatIndex) as DisplayPlayer[],
)
const tableView = useChengduTableView({
roomMeta,
gamePlayers,
gameStore,
localCachedAvatarUrl: session.localCachedAvatarUrl,
loggedInUserId: session.loggedInUserId,
loggedInUserName: session.loggedInUserName,
myHandTiles,
myPlayer,
routeRoomName,
})
const socket = useChengduGameSocket({
route,
router,
gameStore,
roomMeta,
roomName: tableView.roomName,
myHandTiles,
myPlayer,
session,
})
const actions = useChengduGameActions({
gameStore,
roomMeta,
gamePlayers,
myPlayer,
session,
})
const actionCountdown = computed<ActionCountdownView | null>(() => {
const countdown = session.roomCountdown.value
if (!countdown) {
return null
}
const deadlineAt = countdown.actionDeadlineAt ? Date.parse(countdown.actionDeadlineAt) : Number.NaN
const fallbackRemaining = countdown.remaining > 0 ? countdown.remaining : countdown.countdownSeconds
const derivedRemaining = Number.isFinite(deadlineAt)
? Math.ceil((deadlineAt - session.now.value) / 1000)
: fallbackRemaining
const remaining = Math.max(0, derivedRemaining)
if (remaining <= 0) {
return null
}
const targetPlayerIds = countdown.playerIds.filter((playerId) => typeof playerId === 'string' && playerId.trim())
if (targetPlayerIds.length === 0) {
return null
}
const playerLabel = targetPlayerIds
.map((playerId) => {
if (playerId === session.loggedInUserId.value) {
return '你'
}
const targetPlayer = gameStore.players[playerId]
if (targetPlayer?.displayName) {
return targetPlayer.displayName
}
if (targetPlayer) {
return `玩家${targetPlayer.seatIndex + 1}`
}
return '玩家'
})
.join('、')
const duration = countdown.duration > 0 ? countdown.duration : Math.max(remaining, fallbackRemaining, 1)
const includesSelf = targetPlayerIds.includes(session.loggedInUserId.value)
return {
playerLabel,
remaining,
duration,
isSelf: includesSelf,
progress: Math.max(0, Math.min(100, (remaining / duration) * 100)),
}
})
function handleLeaveRoom(): void {
session.menuOpen.value = false
session.backHall()
}
</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>
<ChengduTableHeader
:leave-room-pending="session.leaveRoomPending"
:menu-open="session.menuOpen"
:menu-trigger-active="session.menuTriggerActive"
:is-trust-mode="session.isTrustMode"
:wall-count="tableView.roomState.game.state.wall.length || 48"
:network-label="session.networkLabel"
:ws-status="session.wsStatus"
:formatted-clock="session.formattedClock"
:room-name="tableView.roomState.name || tableView.roomName"
:current-phase-text="tableView.currentPhaseText"
:player-count="tableView.roomState.playerCount"
:max-players="tableView.roomState.maxPlayers"
:round-text="tableView.roundText"
:room-status-text="tableView.roomStatusText"
:ws-error="session.wsError"
:action-countdown="actionCountdown"
@toggle-menu="session.toggleMenu"
@toggle-trust-mode="session.toggleTrustMode"
@leave-room="handleLeaveRoom"
>
<template #robot-icon>
<span class="menu-item-icon">
<img :src="robotIcon" alt=""/>
</span>
</template>
<template #exit-icon>
<span class="menu-item-icon">
<img :src="exitIcon" alt=""/>
</span>
</template>
</ChengduTableHeader>
<TopPlayerCard :player="tableView.seatDecor.top"/>
<RightPlayerCard :player="tableView.seatDecor.right"/>
<BottomPlayerCard :player="tableView.seatDecor.bottom"/>
<LeftPlayerCard :player="tableView.seatDecor.left"/>
<ChengduDeskZones :desk-seats="tableView.deskSeats"/>
<ChengduWallSeats
:wall-seats="tableView.wallSeats"
:selected-discard-tile-id="session.selectedDiscardTileId"
:discard-blocked-reason="discardBlockedReason"
:discard-tile-blocked-reason="discardTileBlockedReason"
:format-tile="formatTile"
@select-discard-tile="selectDiscardTile"
/>
<WindSquare class="center-wind-square" :seat-winds="tableView.seatWinds"
:active-position="tableView.currentTurnSeat"/>
<ChengduSettlementOverlay
:show="socket.showSettlementOverlay"
:is-last-round="actions.isLastRound"
:current-round="gameStore.currentRound"
:total-rounds="gameStore.totalRounds"
:settlement-players="tableView.settlementPlayers"
:logged-in-user-id="session.loggedInUserId"
:next-round-pending="session.nextRoundPending"
:settlement-countdown="socket.settlementCountdown"
@next-round="actions.nextRound"
@back-hall="session.backHall"
/>
<ChengduBottomActions
:show-ding-que-chooser="actions.showDingQueChooser"
:show-ready-toggle="actions.showReadyToggle"
:show-start-game-button="actions.showStartGameButton"
:selected-discard-tile="actions.selectedDiscardTile"
:ding-que-pending="session.dingQuePending"
:can-confirm-discard="actions.canConfirmDiscard"
:discard-pending="session.discardPending"
:confirm-discard-label="actions.confirmDiscardLabel"
:ready-toggle-pending="session.readyTogglePending"
:my-ready-state="actions.myReadyState"
:can-draw-tile="actions.canDrawTile"
:can-start-game="actions.canStartGame"
:is-room-owner="actions.isRoomOwner"
:can-self-gang="actions.canSelfGang"
:can-self-hu="actions.canSelfHu"
:show-claim-actions="actions.showClaimActions"
:turn-action-pending="session.turnActionPending"
:visible-claim-options="actions.visibleClaimOptions"
:claim-action-pending="session.claimActionPending"
:show-waiting-owner-tip="actions.showWaitingOwnerTip"
@choose-ding-que="actions.chooseDingQue"
@confirm-discard="actions.confirmDiscard"
@toggle-ready-state="actions.toggleReadyState"
@draw-tile="actions.drawTile"
@start-game="actions.startGame"
@submit-self-gang="actions.submitSelfGang"
@submit-self-hu="actions.submitSelfHu"
@submit-claim="actions.submitClaim"
/>
</div>
</section>
</div>
</section>
</template>