diff --git a/chengdu-mahjong-features.md b/chengdu-mahjong-features.md index 54a2e21..dcbcfa9 100644 --- a/chengdu-mahjong-features.md +++ b/chengdu-mahjong-features.md @@ -31,6 +31,7 @@ HTTP 接口: { "name": "房间名", "game_type": "chengdu", + "total_rounds": 8, "max_players": 4 } ``` diff --git a/src/api/mahjong.ts b/src/api/mahjong.ts index b59a355..e68255e 100644 --- a/src/api/mahjong.ts +++ b/src/api/mahjong.ts @@ -33,7 +33,7 @@ const ROOM_JOIN_PATH = import.meta.env.VITE_ROOM_JOIN_PATH ?? '/api/v1/game/mahj export async function createRoom( auth: AuthSession, - input: { name: string; gameType: string; maxPlayers: number; totalRounds: number }, + input: { name: string; gameType: string; totalRounds: number; maxPlayers: number }, onAuthUpdated?: (next: AuthSession) => void, ): Promise { return authedRequest({ @@ -44,8 +44,8 @@ export async function createRoom( body: { name: input.name, game_type: input.gameType, - max_players: input.maxPlayers, total_rounds: input.totalRounds, + max_players: input.maxPlayers, }, }) } diff --git a/src/assets/styles/room.css b/src/assets/styles/room.css index 35b909b..f2f3f28 100644 --- a/src/assets/styles/room.css +++ b/src/assets/styles/room.css @@ -312,6 +312,61 @@ z-index: 5; } +.action-countdown { + position: absolute; + top: 92px; + right: 40px; + min-width: 188px; + padding: 10px 12px; + border: 1px solid rgba(255, 219, 131, 0.22); + border-radius: 12px; + color: #fff5d5; + background: rgba(21, 17, 14, 0.82); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.22); + z-index: 5; +} + +.action-countdown.is-self { + border-color: rgba(107, 237, 174, 0.36); + color: #e8fff3; +} + +.action-countdown-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 13px; + font-weight: 700; +} + +.action-countdown-head strong { + font-size: 20px; + line-height: 1; +} + +.action-countdown-track { + position: relative; + overflow: hidden; + width: 100%; + height: 6px; + margin-top: 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); +} + +.action-countdown-fill { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #ffd569 0%, #ff8e3c 100%); + transition: width 0.9s linear; +} + +.action-countdown.is-self .action-countdown-fill { + background: linear-gradient(90deg, #75f0aa 0%, #22b573 100%); +} + .signal-chip { display: inline-flex; align-items: center; @@ -445,6 +500,22 @@ 0 2px 6px rgba(0, 0, 0, 0.2); } +.picture-scene .player-meta .trustee-chip { + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 4px; + padding: 1px 8px; + border-radius: 999px; + color: #eaffef; + font-size: 10px; + font-weight: 700; + background: linear-gradient(180deg, rgba(57, 182, 110, 0.86), rgba(19, 105, 61, 0.94)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.2), + 0 2px 6px rgba(0, 0, 0, 0.18); +} + .picture-scene .player-badge.seat-right .player-meta, .picture-scene .player-badge.seat-left .player-meta { display: flex; @@ -554,12 +625,12 @@ } .wall-right { - right: 110px; + right: 140px; gap: 0; } .wall-left { - left: 110px; + left: 140px; } .wall-left img, @@ -636,6 +707,7 @@ } .wall-live-tile-button { + position: relative; padding: 0; border: 0; background: transparent; @@ -643,6 +715,24 @@ cursor: pointer; } +.wall-live-tile-lack-tag { + position: absolute; + top: 21px; + left: 5px; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: 6px; + color: #fff8e8; + font-size: 10px; + line-height: 16px; + font-weight: 800; + background: linear-gradient(180deg, rgba(200, 56, 41, 0.95), rgba(137, 25, 14, 0.96)); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); + z-index: 2; + pointer-events: none; +} + .wall-live-tile-button:disabled { cursor: default; opacity: 1; @@ -701,6 +791,116 @@ left: 110px; } +.desk-zone { + position: absolute; + display: flex; + gap: 0; + max-width: 280px; + max-height: 220px; + pointer-events: none; + z-index: 2; + filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.2)); +} + +.desk-zone-top, +.desk-zone-bottom { + left: 50%; + transform: translateX(-50%); +} + +.desk-zone-top { + top: 208px; +} + +.desk-zone-bottom { + bottom: 220px; +} + +.desk-zone-left, +.desk-zone-right { + top: 50%; + transform: translateY(-50%); + flex-direction: column; +} + +.desk-zone-left { + left: 240px; +} + +.desk-zone-right { + right: 240px; +} + +.desk-tile { + display: block; + object-fit: contain; +} + +.desk-zone-top .desk-tile, +.desk-zone-bottom .desk-tile { + width: 30px; + height: 44px; +} + +.desk-zone-left .desk-tile, +.desk-zone-right .desk-tile { + width: 44px; + height: 30px; +} + +.desk-zone-top .desk-tile + .desk-tile { + margin-left: -0px; +} + +.desk-zone-bottom .desk-tile + .desk-tile { + margin-left: -2px; +} + +.desk-zone-left .desk-tile + .desk-tile, +.desk-zone-right .desk-tile + .desk-tile { + margin-top: -14px; +} + +.desk-zone-top .desk-tile.is-group-start, +.desk-zone-bottom .desk-tile.is-group-start { + margin-left: 6px; +} + +.desk-zone-bottom .desk-tile.is-group-start { + margin-left: 13px; +} + +.desk-zone-left .desk-tile.is-group-start, +.desk-zone-right .desk-tile.is-group-start { + margin-top: 7px; +} + +.desk-tile.is-covered { + opacity: 0.95; +} + +.desk-hu-flag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + margin-left: 6px; + padding: 0 7px; + border-radius: 999px; + color: #fff3da; + font-size: 12px; + font-weight: 800; + background: linear-gradient(180deg, rgba(219, 81, 56, 0.92), rgba(146, 32, 20, 0.96)); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.22); +} + +.desk-zone-left .desk-hu-flag, +.desk-zone-right .desk-hu-flag { + margin-top: 6px; + margin-left: 0; +} + .center-wind-square { position: absolute; left: 50%; @@ -1287,6 +1487,22 @@ left: 88px; } + .desk-zone-top { + top: 196px; + } + + .desk-zone-bottom { + bottom: 208px; + } + + .desk-zone-left { + left: 186px; + } + + .desk-zone-right { + right: 186px; + } + .inner-outline.mid { inset: 70px 72px 120px; } @@ -1307,6 +1523,12 @@ height: 62px; font-size: 22px; } + + .action-countdown { + top: 82px; + right: 20px; + min-width: 164px; + } } @media (max-width: 640px) { @@ -1329,6 +1551,11 @@ display: none; } + .desk-zone-top, + .desk-zone-bottom { + display: none; + } + .wall-left { left: 32px; } @@ -1337,6 +1564,14 @@ right: 32px; } + .desk-zone-left { + left: 84px; + } + + .desk-zone-right { + right: 84px; + } + .floating-status.left, .floating-status.right { display: none; @@ -1354,6 +1589,21 @@ font-size: 16px; } + .top-right-clock { + top: 16px; + right: 16px; + gap: 8px; + padding: 7px 10px; + } + + .action-countdown { + top: 62px; + right: 16px; + min-width: 0; + width: calc(100% - 32px); + padding: 8px 10px; + } + .bottom-control-panel { width: calc(100% - 20px); } diff --git a/src/components/game/SeatPlayerCard.vue b/src/components/game/SeatPlayerCard.vue index 40f1fcc..beef22a 100644 --- a/src/components/game/SeatPlayerCard.vue +++ b/src/components/game/SeatPlayerCard.vue @@ -51,6 +51,7 @@ const resolvedAvatarUrl = computed(() => {

{{ player.name }}

+ 托管中 已准备
diff --git a/src/components/game/seat-player-card.ts b/src/components/game/seat-player-card.ts index 6be38c6..1c9b71e 100644 --- a/src/components/game/seat-player-card.ts +++ b/src/components/game/seat-player-card.ts @@ -5,5 +5,6 @@ export interface SeatPlayerCardModel { dealer: boolean // 是否庄家 isTurn: boolean // 是否当前轮到该玩家 isReady: boolean // 是否已准备 + isTrustee: boolean // 是否托管 missingSuitLabel: string // 定缺花色(万/筒/条) } diff --git a/src/game/actions.ts b/src/game/actions.ts index f061995..f4c0e04 100644 --- a/src/game/actions.ts +++ b/src/game/actions.ts @@ -22,6 +22,13 @@ export interface RoomPlayerUpdatePayload { }> } +export interface RoomTrusteePayload { + player_id?: string + playerId?: string + trustee?: boolean + reason?: string +} + /** * 游戏动作定义(只描述“发生了什么”) @@ -80,3 +87,8 @@ export type GameAction = type: 'ROOM_PLAYER_UPDATE' payload: RoomPlayerUpdatePayload } + + | { + type: 'ROOM_TRUSTEE' + payload: RoomTrusteePayload +} diff --git a/src/game/dispatcher.ts b/src/game/dispatcher.ts index 1571eec..1dfeb24 100644 --- a/src/game/dispatcher.ts +++ b/src/game/dispatcher.ts @@ -34,6 +34,10 @@ export function dispatchGameAction(action: GameAction) { store.onRoomPlayerUpdate(action.payload) break + case 'ROOM_TRUSTEE': + store.onRoomTrustee(action.payload) + break + default: throw new Error('Invalid game action') diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 36d78cd..4df6903 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -4,7 +4,7 @@ import { type GameState, type PendingClaimState, } from '../types/state' -import type { RoomPlayerUpdatePayload } from '../game/actions' +import type { RoomPlayerUpdatePayload, RoomTrusteePayload } from '../game/actions' import type { Tile } from '../types/tile' @@ -157,6 +157,7 @@ export const useGameStore = defineStore('game', { typeof avatarUrlRaw === 'string' ? avatarUrlRaw : previous?.avatarURL, + isTrustee: previous?.isTrustee ?? false, missingSuit: typeof missingSuitRaw === 'string' || missingSuitRaw === null ? missingSuitRaw @@ -185,6 +186,7 @@ export const useGameStore = defineStore('game', { seatIndex: previous?.seatIndex ?? index, displayName: previous?.displayName ?? playerId, avatarURL: previous?.avatarURL, + isTrustee: previous?.isTrustee ?? false, missingSuit: previous?.missingSuit, handTiles: previous?.handTiles ?? [], handCount: previous?.handCount ?? 0, @@ -200,6 +202,23 @@ export const useGameStore = defineStore('game', { this.players = nextPlayers }, + onRoomTrustee(payload: RoomTrusteePayload) { + const playerId = + (typeof payload.player_id === 'string' && payload.player_id) || + (typeof payload.playerId === 'string' && payload.playerId) || + '' + if (!playerId) { + return + } + + const player = this.players[playerId] + if (!player) { + return + } + + player.isTrustee = typeof payload.trustee === 'boolean' ? payload.trustee : true + }, + // 清理操作窗口 clearPendingClaim() { this.pendingClaim = undefined diff --git a/src/store/state.ts b/src/store/state.ts index 22fa278..9a2dfdf 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -5,6 +5,7 @@ export interface RoomPlayerState { displayName?: string missingSuit?: string | null ready: boolean + trustee?: boolean hand: string[] melds: string[] outTiles: string[] diff --git a/src/types/state/playerState.ts b/src/types/state/playerState.ts index 63b076c..4953145 100644 --- a/src/types/state/playerState.ts +++ b/src/types/state/playerState.ts @@ -7,6 +7,7 @@ export interface PlayerState { seatIndex: number displayName?: string missingSuit?: string | null + isTrustee: boolean // 手牌(只有自己有完整数据,后端可控制) handTiles: Tile[] diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 63b89ac..57c1503 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -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('idle') const wsMessages = ref([]) const wsError = ref('') +const roomCountdown = ref(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>(() => { 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>(() => { } } + emptyState[targetSeat] = { + tiles: seatTiles, + } + } + + return emptyState +}) + +const deskSeats = computed>(() => { + const emptyState: Record = { + 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>(() => { } 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>(() => { dealer: false, isTurn: false, isReady: false, + isTrustee: false, missingSuitLabel: defaultMissingSuitLabel, }) @@ -1312,6 +1457,7 @@ const seatDecor = computed>(() => { 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(() => { {{ formattedClock }} +
+
+ {{ actionCountdown.playerLabel }}操作倒计时 + {{ actionCountdown.remaining }}s +
+
+ +
+
+ -
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
{ :src="tile.src" :alt="tile.alt" /> -
-
+
{ :src="tile.src" :alt="tile.alt" /> -
-
+
-
-
+
{ :src="tile.src" :alt="tile.alt" /> -
diff --git a/src/views/HallPage.vue b/src/views/HallPage.vue index 73fe096..0da461e 100644 --- a/src/views/HallPage.vue +++ b/src/views/HallPage.vue @@ -137,6 +137,7 @@ function mapRoomPlayers(room: RoomItem): RoomPlayerState[] { (typeof item.PlayerName === 'string' && item.PlayerName) || (item.player_id === currentUserId.value ? displayName.value : undefined), ready: Boolean(item.ready), + trustee: false, hand: [], melds: [], outTiles: [], diff --git a/src/ws/client.ts b/src/ws/client.ts index e2592f3..6e70c76 100644 --- a/src/ws/client.ts +++ b/src/ws/client.ts @@ -77,8 +77,10 @@ class WsClient { this.ws.onmessage = (event) => { try { const data = JSON.parse(event.data) + console.log('[WS:RECV]', data) this.messageHandlers.forEach(fn => fn(data)) } catch { + console.log('[WS:RECV]', event.data) this.messageHandlers.forEach(fn => fn(event.data)) } }