From 623ee94b048b3d03fefdd915d72ab5b01974fdc3 Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Sun, 29 Mar 2026 17:50:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E6=89=98=E7=AE=A1=E5=8A=9F=E8=83=BD=E5=92=8C=E5=80=92?= =?UTF-8?q?=E8=AE=A1=E6=97=B6=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 RoomTrusteePayload 接口定义和 ROOM_TRUSTEE 动作类型 - 在玩家状态中增加 trustee 字段用于标识托管状态 - 实现托管模式切换和状态同步功能 - 添加房间倒计时功能支持玩家操作限时 - 实现倒计时 UI 组件显示操作剩余时间 - 修改游戏开始逻辑避免回合开始后重复准备 - 更新 WebSocket 消息处理支持新的托管消息类型 - 添加托管玩家的视觉标识显示托管状态 - 移除房间创建时不必要的总回合数参数 --- src/api/mahjong.ts | 3 +- src/assets/styles/room.css | 92 ++++++++++++ src/components/game/SeatPlayerCard.vue | 1 + src/components/game/seat-player-card.ts | 1 + src/game/actions.ts | 12 ++ src/game/dispatcher.ts | 4 + src/store/gameStore.ts | 21 ++- src/store/state.ts | 1 + src/types/state/playerState.ts | 1 + src/views/ChengduGamePage.vue | 178 +++++++++++++++++++++++- src/views/HallPage.vue | 1 + src/ws/client.ts | 2 + 12 files changed, 311 insertions(+), 6 deletions(-) diff --git a/src/api/mahjong.ts b/src/api/mahjong.ts index b59a355..a4c368b 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; maxPlayers: number }, onAuthUpdated?: (next: AuthSession) => void, ): Promise { return authedRequest({ @@ -45,7 +45,6 @@ export async function createRoom( name: input.name, game_type: input.gameType, max_players: input.maxPlayers, - total_rounds: input.totalRounds, }, }) } diff --git a/src/assets/styles/room.css b/src/assets/styles/room.css index 35b909b..d5bc1d6 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; @@ -1307,6 +1378,12 @@ height: 62px; font-size: 22px; } + + .action-countdown { + top: 82px; + right: 20px; + min-width: 164px; + } } @media (max-width: 640px) { @@ -1354,6 +1431,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..9d6fd16 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' @@ -71,10 +71,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) @@ -322,8 +331,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 +355,7 @@ const canStartGame = computed(() => { }) const showReadyToggle = computed(() => { - if (gameStore.phase !== 'waiting' || !gameStore.roomId) { + if (gameStore.phase !== 'waiting' || !gameStore.roomId || hasRoundStarted.value) { return false } @@ -411,6 +431,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) { @@ -900,6 +970,7 @@ function handleRoomInfoResponse(message: unknown): void { displayName?: string missingSuit?: string | null ready: boolean + trustee: boolean hand: string[] melds: string[] outTiles: string[] @@ -912,6 +983,7 @@ function handleRoomInfoResponse(message: unknown): void { avatarURL?: string missingSuit?: string | null isReady: boolean + isTrustee: boolean handTiles: Tile[] handCount: number melds: PlayerState['melds'] @@ -946,6 +1018,7 @@ function handleRoomInfoResponse(message: unknown): void { displayName: displayName || undefined, missingSuit, ready, + trustee: false, hand: [], melds: [], outTiles: [], @@ -958,6 +1031,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 +1077,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 +1090,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 +1129,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 @@ -1285,6 +1362,7 @@ const seatDecor = computed>(() => { dealer: false, isTurn: false, isReady: false, + isTrustee: false, missingSuitLabel: defaultMissingSuitLabel, }) @@ -1312,6 +1390,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), } } @@ -1421,6 +1500,51 @@ function handlePlayerHandResponse(message: unknown): void { } } +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 +1599,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 @@ -1940,6 +2097,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 +2137,7 @@ onMounted(() => { handleRoomInfoResponse(msg) handleRoomStateResponse(msg) handlePlayerHandResponse(msg) + handleRoomCountdown(msg) handleReadyStateResponse(msg) handlePlayerDingQueResponse(msg) const gameAction = toGameAction(msg) @@ -1997,6 +2156,9 @@ onMounted(() => { if (gameAction.type === 'CLAIM_RESOLVED') { claimActionPending.value = false } + if (gameAction.type === 'ROOM_TRUSTEE') { + syncTrusteeState(gameAction.payload) + } } }) wsClient.onError((message: string) => { @@ -2117,6 +2279,16 @@ onBeforeUnmount(() => { {{ formattedClock }} +
+
+ {{ actionCountdown.playerLabel }}操作倒计时 + {{ actionCountdown.remaining }}s +
+
+ +
+
+ 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)) } }