feat(game): 添加成都麻将房间配置和桌面牌面显示功能

- 在房间创建接口中添加总回合数配置选项
- 实现桌面弃牌区域的可视化展示,区分各玩家的弃牌和组合
- 添加缺门标识显示,帮助玩家识别缺门牌组起始位置
- 优化牌面操作状态管理,增加弃牌等待状态和超时处理机制
- 更新样式布局适配新的桌面牌面区域,调整墙体和桌面对齐方式
- 修复多处牌面状态同步问题,确保游戏流程中的界面一致性
This commit is contained in:
2026-03-29 17:50:34 +08:00
parent 7751d3b8e3
commit be9bd8c76d
13 changed files with 641 additions and 28 deletions

View File

@@ -16,7 +16,7 @@ import westWind from '../assets/images/direction/xi.png'
import northWind from '../assets/images/direction/bei.png'
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
import type {SeatKey} from '../game/seat'
import type {GameAction, RoomPlayerUpdatePayload} from '../game/actions'
import type {GameAction, RoomPlayerUpdatePayload, RoomTrusteePayload} from '../game/actions'
import {dispatchGameAction} from '../game/dispatcher'
import {refreshAccessToken} from '../api/auth'
import {AuthExpiredError, type AuthSession} from '../api/authed-request'
@@ -55,12 +55,18 @@ interface WallTileItem {
src: string
alt: string
imageType: TableTileImageType
isGroupStart?: boolean
showLackTag?: boolean
suit?: Tile['suit']
tile?: Tile
}
interface WallSeatState {
tiles: WallTileItem[]
}
interface DeskSeatState {
tiles: WallTileItem[]
hasHu: boolean
}
@@ -71,10 +77,19 @@ interface SeatViewModel {
isTurn: boolean
}
interface PlayerActionTimer {
playerIds: string[]
actionDeadlineAt?: string | null
countdownSeconds: number
duration: number
remaining: number
}
const now = ref(Date.now())
const wsStatus = ref<WsStatus>('idle')
const wsMessages = ref<string[]>([])
const wsError = ref('')
const roomCountdown = ref<PlayerActionTimer | null>(null)
const leaveRoomPending = ref(false)
const readyTogglePending = ref(false)
const startGamePending = ref(false)
@@ -82,6 +97,7 @@ const dingQuePending = ref(false)
const discardPending = ref(false)
const claimActionPending = ref(false)
let clockTimer: number | null = null
let discardPendingTimer: number | null = null
let unsubscribe: (() => void) | null = null
let needsInitialRoomInfo = false
@@ -322,8 +338,19 @@ const allPlayersReady = computed(() => {
)
})
const hasRoundStarted = computed(() => {
return gamePlayers.value.some((player) => {
return (
player.handCount > 0 ||
player.handTiles.length > 0 ||
player.melds.length > 0 ||
player.discardTiles.length > 0
)
})
})
const showStartGameButton = computed(() => {
return gameStore.phase === 'waiting' && allPlayersReady.value
return gameStore.phase === 'waiting' && allPlayersReady.value && !hasRoundStarted.value
})
const showWaitingOwnerTip = computed(() => {
@@ -335,7 +362,7 @@ const canStartGame = computed(() => {
})
const showReadyToggle = computed(() => {
if (gameStore.phase !== 'waiting' || !gameStore.roomId) {
if (gameStore.phase !== 'waiting' || !gameStore.roomId || hasRoundStarted.value) {
return false
}
@@ -365,19 +392,20 @@ const canDiscardTiles = computed(() => {
return false
}
if (gameStore.phase !== 'playing') {
if (wsStatus.value !== 'connected') {
return false
}
if (gameStore.needDraw) {
if (player.handTiles.length === 0) {
return false
}
if (!player.missingSuit || player.handTiles.length === 0) {
if (discardPending.value) {
return false
}
return player.seatIndex === gameStore.currentTurn
// 交给后端做最终合法性校验,前端只避免明显无效点击。
return true
})
const canDrawTile = computed(() => {
@@ -411,6 +439,56 @@ const showClaimActions = computed(() => {
return visibleClaimOptions.value.length > 0
})
const actionCountdown = computed(() => {
const countdown = 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 - 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 === 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(loggedInUserId.value)
return {
playerLabel,
remaining,
duration,
isSelf: includesSelf,
progress: Math.max(0, Math.min(100, (remaining / duration) * 100)),
}
})
function applyPlayerReadyState(playerId: string, ready: boolean): void {
const player = gameStore.players[playerId]
if (player) {
@@ -745,6 +823,7 @@ function handleRoomStateResponse(message: unknown): void {
seatIndex,
displayName: previous?.displayName ?? playerId,
avatarURL: previous?.avatarURL,
isTrustee: previous?.isTrustee ?? false,
missingSuit: dingQue || previous?.missingSuit || null,
handTiles: previous?.handTiles ?? [],
handCount,
@@ -852,7 +931,7 @@ function handleRoomStateResponse(message: unknown): void {
startGamePending.value = false
}
if (currentTurnPlayerId && currentTurnPlayerId !== loggedInUserId.value) {
discardPending.value = false
markDiscardCompleted()
}
}
@@ -900,6 +979,7 @@ function handleRoomInfoResponse(message: unknown): void {
displayName?: string
missingSuit?: string | null
ready: boolean
trustee: boolean
hand: string[]
melds: string[]
outTiles: string[]
@@ -912,6 +992,7 @@ function handleRoomInfoResponse(message: unknown): void {
avatarURL?: string
missingSuit?: string | null
isReady: boolean
isTrustee: boolean
handTiles: Tile[]
handCount: number
melds: PlayerState['melds']
@@ -946,6 +1027,7 @@ function handleRoomInfoResponse(message: unknown): void {
displayName: displayName || undefined,
missingSuit,
ready,
trustee: false,
hand: [],
melds: [],
outTiles: [],
@@ -958,6 +1040,7 @@ function handleRoomInfoResponse(message: unknown): void {
avatarURL: readString(player, 'avatar_url', 'AvatarUrl', 'avatar', 'avatarUrl') || undefined,
missingSuit,
isReady: ready,
isTrustee: false,
handTiles: [],
handCount: 0,
melds: [],
@@ -1003,6 +1086,7 @@ function handleRoomInfoResponse(message: unknown): void {
displayName: displayName || undefined,
missingSuit,
ready: existing?.roomPlayer.ready ?? false,
trustee: existing?.roomPlayer.trustee ?? false,
hand: Array.from({length: handCount}, () => ''),
melds: melds.map((meld) => meld.type),
outTiles: outTiles.map((tile) => tileToText(tile)),
@@ -1015,6 +1099,7 @@ function handleRoomInfoResponse(message: unknown): void {
avatarURL: existing?.gamePlayer.avatarURL,
missingSuit,
isReady: existing?.gamePlayer.isReady ?? false,
isTrustee: existing?.gamePlayer.isTrustee ?? false,
handTiles: existing?.gamePlayer.handTiles ?? [],
handCount,
melds: melds.length > 0 ? melds : existing?.gamePlayer.melds ?? [],
@@ -1053,6 +1138,7 @@ function handleRoomInfoResponse(message: unknown): void {
displayName: gamePlayer.displayName ?? previous?.displayName,
avatarURL: gamePlayer.avatarURL ?? previous?.avatarURL,
missingSuit: gamePlayer.missingSuit ?? previous?.missingSuit,
isTrustee: previous?.isTrustee ?? gamePlayer.isTrustee,
handTiles: gamePlayer.handTiles.length > 0 ? gamePlayer.handTiles : previous?.handTiles ?? [],
handCount: gamePlayer.handCount > 0
? gamePlayer.handCount
@@ -1190,6 +1276,12 @@ function buildWallTileImage(
}
function emptyWallSeat(): WallSeatState {
return {
tiles: [],
}
}
function emptyDeskSeat(): DeskSeatState {
return {
tiles: [],
hasHu: false,
@@ -1217,17 +1309,25 @@ const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
const targetSeat = seat.key
if (seat.isSelf) {
const missingSuit = seat.player.missingSuit as Tile['suit'] | null | undefined
sortedVisibleHandTiles.value.forEach((tile, index) => {
const src = buildWallTileImage(targetSeat, tile, 'hand')
if (!src) {
return
}
const previousTile = index > 0 ? sortedVisibleHandTiles.value[index - 1] : undefined
const isMissingSuitGroupStart = Boolean(
missingSuit &&
tile.suit === missingSuit &&
(!previousTile || previousTile.suit !== tile.suit),
)
seatTiles.push({
key: `hand-${tile.id}-${index}`,
src,
alt: formatTile(tile),
imageType: 'hand',
showLackTag: isMissingSuitGroupStart,
suit: tile.suit,
tile,
})
@@ -1248,6 +1348,49 @@ const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
}
}
emptyState[targetSeat] = {
tiles: seatTiles,
}
}
return emptyState
})
const deskSeats = computed<Record<SeatKey, DeskSeatState>>(() => {
const emptyState: Record<SeatKey, DeskSeatState> = {
top: emptyDeskSeat(),
right: emptyDeskSeat(),
bottom: emptyDeskSeat(),
left: emptyDeskSeat(),
}
if (gameStore.phase === 'waiting' && myHandTiles.value.length === 0) {
return emptyState
}
for (const seat of seatViews.value) {
if (!seat.player) {
continue
}
const seatTiles: WallTileItem[] = []
const targetSeat = seat.key
seat.player.discardTiles.forEach((tile, index) => {
const src = buildWallTileImage(targetSeat, tile, 'exposed')
if (!src) {
return
}
seatTiles.push({
key: `discard-${tile.id}-${index}`,
src,
alt: formatTile(tile),
imageType: 'exposed',
suit: tile.suit,
})
})
seat.player.melds.forEach((meld, meldIndex) => {
meld.tiles.forEach((tile, tileIndex) => {
const imageType: TableTileImageType = meld.type === 'an_gang' ? 'covered' : 'exposed'
@@ -1257,10 +1400,11 @@ const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
}
seatTiles.push({
key: `${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`,
key: `desk-${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`,
src,
alt: formatTile(tile),
imageType,
isGroupStart: tileIndex === 0,
suit: tile.suit,
})
})
@@ -1285,6 +1429,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
dealer: false,
isTurn: false,
isReady: false,
isTrustee: false,
missingSuitLabel: defaultMissingSuitLabel,
})
@@ -1312,6 +1457,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
dealer: seat.player.seatIndex === dealerIndex,
isTurn: seat.isTurn,
isReady: Boolean(seat.player.isReady),
isTrustee: Boolean(seat.player.isTrustee),
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
}
}
@@ -1415,12 +1561,57 @@ function handlePlayerHandResponse(message: unknown): void {
}
}
discardPending.value = false
markDiscardCompleted()
if (gameStore.phase !== 'waiting') {
startGamePending.value = false
}
}
function handleRoomCountdown(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string') {
return
}
if (normalizeWsType(source.type) !== 'ROOM_COUNTDOWN') {
return
}
const payload = asRecord(source.payload) ?? source
const roomId =
readString(payload, 'room_id', 'roomId') ||
readString(source, 'roomId')
if (roomId && gameStore.roomId && roomId !== gameStore.roomId) {
return
}
const playerIds = readStringArray(payload, 'player_ids', 'playerIds', 'PlayerIDs')
const fallbackPlayerId = readString(payload, 'player_id', 'playerId', 'PlayerID')
const normalizedPlayerIds = playerIds.length > 0 ? playerIds : (fallbackPlayerId ? [fallbackPlayerId] : [])
if (normalizedPlayerIds.length === 0) {
roomCountdown.value = null
return
}
const countdownSeconds = readNumber(payload, 'countdown_seconds', 'CountdownSeconds') ?? 0
const duration = readNumber(payload, 'duration', 'Duration') ?? countdownSeconds
const remaining = readNumber(payload, 'remaining', 'Remaining') ?? countdownSeconds
const actionDeadlineAt = readString(payload, 'action_deadline_at', 'ActionDeadlineAt') || null
if (countdownSeconds <= 0 && remaining <= 0 && !actionDeadlineAt) {
roomCountdown.value = null
return
}
roomCountdown.value = {
playerIds: normalizedPlayerIds,
actionDeadlineAt,
countdownSeconds,
duration,
remaining,
}
}
function toGameAction(message: unknown): GameAction | null {
if (!message || typeof message !== 'object') {
return null
@@ -1475,11 +1666,44 @@ function toGameAction(message: unknown): GameAction | null {
return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameActionPayload<'ROOM_PLAYER_UPDATE'>}
}
return null
case 'ROOM_TRUSTEE':
if (payload && typeof payload === 'object') {
return {type: 'ROOM_TRUSTEE', payload: payload as GameActionPayload<'ROOM_TRUSTEE'>}
}
return {
type: 'ROOM_TRUSTEE',
payload: source as unknown as GameActionPayload<'ROOM_TRUSTEE'>,
}
default:
return null
}
}
function syncTrusteeState(payload: RoomTrusteePayload): void {
const playerId =
(typeof payload.player_id === 'string' && payload.player_id) ||
(typeof payload.playerId === 'string' && payload.playerId) ||
''
if (!playerId) {
return
}
const trustee = typeof payload.trustee === 'boolean' ? payload.trustee : true
if (playerId === loggedInUserId.value) {
isTrustMode.value = trustee
}
const room = activeRoom.value
if (!room || room.roomId !== gameStore.roomId) {
return
}
const roomPlayer = room.players.find((item) => item.playerId === playerId)
if (roomPlayer) {
roomPlayer.trustee = trustee
}
}
function handleReadyStateResponse(message: unknown): void {
if (!message || typeof message !== 'object') {
return
@@ -1838,18 +2062,42 @@ function chooseDingQue(suit: Tile['suit']): void {
})
}
function clearDiscardPendingTimer(): void {
if (discardPendingTimer !== null) {
window.clearTimeout(discardPendingTimer)
discardPendingTimer = null
}
}
function markDiscardCompleted(): void {
clearDiscardPendingTimer()
discardPending.value = false
}
function markDiscardPendingWithFallback(): void {
clearDiscardPendingTimer()
discardPending.value = true
discardPendingTimer = window.setTimeout(() => {
discardPending.value = false
discardPendingTimer = null
}, 2000)
}
function discardTile(tile: Tile): void {
if (discardPending.value || !canDiscardTiles.value) {
if (!canDiscardTiles.value || !gameStore.roomId) {
return
}
discardPending.value = true
markDiscardPendingWithFallback()
sendWsMessage({
type: 'discard',
roomId: gameStore.roomId,
payload: {
room_id: gameStore.roomId,
tile,
tile: {
id: tile.id,
suit: tile.suit,
value: tile.value,
},
},
})
}
@@ -1940,6 +2188,7 @@ function hydrateFromActiveRoom(routeRoomId: string): void {
displayName: player.displayName || player.playerId,
avatarURL: previous?.avatarURL,
missingSuit: player.missingSuit ?? previous?.missingSuit,
isTrustee: player.trustee ?? previous?.isTrustee ?? false,
isReady: player.ready,
handTiles: previous?.handTiles ?? [],
handCount: previous?.handCount ?? 0,
@@ -1979,6 +2228,7 @@ onMounted(() => {
handleRoomInfoResponse(msg)
handleRoomStateResponse(msg)
handlePlayerHandResponse(msg)
handleRoomCountdown(msg)
handleReadyStateResponse(msg)
handlePlayerDingQueResponse(msg)
const gameAction = toGameAction(msg)
@@ -1988,7 +2238,7 @@ onMounted(() => {
startGamePending.value = false
}
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === loggedInUserId.value) {
discardPending.value = false
markDiscardCompleted()
}
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
syncReadyStatesFromRoomUpdate(gameAction.payload)
@@ -1997,9 +2247,13 @@ onMounted(() => {
if (gameAction.type === 'CLAIM_RESOLVED') {
claimActionPending.value = false
}
if (gameAction.type === 'ROOM_TRUSTEE') {
syncTrusteeState(gameAction.payload)
}
}
})
wsClient.onError((message: string) => {
markDiscardCompleted()
wsError.value = message
wsMessages.value.push(`[error] ${message}`)
@@ -2040,6 +2294,7 @@ onBeforeUnmount(() => {
window.clearInterval(clockTimer)
clockTimer = null
}
clearDiscardPendingTimer()
window.removeEventListener('click', handleGlobalClick)
window.removeEventListener('keydown', handleGlobalEsc)
@@ -2117,13 +2372,80 @@ onBeforeUnmount(() => {
<span>{{ formattedClock }}</span>
</div>
<div v-if="actionCountdown" class="action-countdown" :class="{ 'is-self': actionCountdown.isSelf }">
<div class="action-countdown-head">
<span>{{ actionCountdown.playerLabel }}操作倒计时</span>
<strong>{{ actionCountdown.remaining }}s</strong>
</div>
<div class="action-countdown-track">
<span class="action-countdown-fill" :style="{ width: `${actionCountdown.progress}%` }"></span>
</div>
</div>
<TopPlayerCard :player="seatDecor.top"/>
<RightPlayerCard :player="seatDecor.right"/>
<BottomPlayerCard :player="seatDecor.bottom"/>
<LeftPlayerCard :player="seatDecor.left"/>
<div v-if="wallSeats.top.tiles.length > 0 || wallSeats.top.hasHu" class="wall wall-top wall-live">
<div v-if="deskSeats.top.tiles.length > 0 || deskSeats.top.hasHu" class="desk-zone desk-zone-top">
<img
v-for="tile in deskSeats.top.tiles"
:key="tile.key"
class="desk-tile"
:class="{
'is-group-start': tile.isGroupStart,
'is-covered': tile.imageType === 'covered',
}"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="deskSeats.top.hasHu" class="desk-hu-flag"></span>
</div>
<div v-if="deskSeats.right.tiles.length > 0 || deskSeats.right.hasHu" class="desk-zone desk-zone-right">
<img
v-for="tile in deskSeats.right.tiles"
:key="tile.key"
class="desk-tile"
:class="{
'is-group-start': tile.isGroupStart,
'is-covered': tile.imageType === 'covered',
}"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="deskSeats.right.hasHu" class="desk-hu-flag"></span>
</div>
<div v-if="deskSeats.bottom.tiles.length > 0 || deskSeats.bottom.hasHu" class="desk-zone desk-zone-bottom">
<img
v-for="tile in deskSeats.bottom.tiles"
:key="tile.key"
class="desk-tile"
:class="{
'is-group-start': tile.isGroupStart,
'is-covered': tile.imageType === 'covered',
}"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="deskSeats.bottom.hasHu" class="desk-hu-flag"></span>
</div>
<div v-if="deskSeats.left.tiles.length > 0 || deskSeats.left.hasHu" class="desk-zone desk-zone-left">
<img
v-for="tile in deskSeats.left.tiles"
:key="tile.key"
class="desk-tile"
:class="{
'is-group-start': tile.isGroupStart,
'is-covered': tile.imageType === 'covered',
}"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="deskSeats.left.hasHu" class="desk-hu-flag"></span>
</div>
<div v-if="wallSeats.top.tiles.length > 0" class="wall wall-top wall-live">
<img
v-for="(tile, index) in wallSeats.top.tiles"
:key="tile.key"
@@ -2135,9 +2457,8 @@ onBeforeUnmount(() => {
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.top.hasHu" class="wall-hu-flag"></span>
</div>
<div v-if="wallSeats.right.tiles.length > 0 || wallSeats.right.hasHu" class="wall wall-right wall-live">
<div v-if="wallSeats.right.tiles.length > 0" class="wall wall-right wall-live">
<img
v-for="(tile, index) in wallSeats.right.tiles"
:key="tile.key"
@@ -2149,21 +2470,22 @@ onBeforeUnmount(() => {
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.right.hasHu" class="wall-hu-flag"></span>
</div>
<div v-if="wallSeats.bottom.tiles.length > 0 || wallSeats.bottom.hasHu" class="wall wall-bottom wall-live">
<div v-if="wallSeats.bottom.tiles.length > 0" class="wall wall-bottom wall-live">
<template v-for="(tile, index) in wallSeats.bottom.tiles" :key="tile.key">
<button
v-if="tile.tile && tile.imageType === 'hand'"
class="wall-live-tile-button"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
'is-lack-tagged': tile.showLackTag,
}"
:data-testid="`hand-tile-${tile.tile.id}`"
type="button"
:disabled="!canDiscardTiles || discardPending"
@click="discardTile(tile.tile)"
>
<span v-if="tile.showLackTag" class="wall-live-tile-lack-tag"></span>
<img
class="wall-live-tile"
:src="tile.src"
@@ -2181,9 +2503,8 @@ onBeforeUnmount(() => {
:alt="tile.alt"
/>
</template>
<span v-if="wallSeats.bottom.hasHu" class="wall-hu-flag"></span>
</div>
<div v-if="wallSeats.left.tiles.length > 0 || wallSeats.left.hasHu" class="wall wall-left wall-live">
<div v-if="wallSeats.left.tiles.length > 0" class="wall wall-left wall-live">
<img
v-for="(tile, index) in wallSeats.left.tiles"
:key="tile.key"
@@ -2195,7 +2516,6 @@ onBeforeUnmount(() => {
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.left.hasHu" class="wall-hu-flag"></span>
</div>
<!-- <div class="floating-status top">-->