feat(game): 添加游戏托管功能和倒计时显示
- 添加 RoomTrusteePayload 接口定义和 ROOM_TRUSTEE 动作类型 - 在玩家状态中增加 trustee 字段用于标识托管状态 - 实现托管模式切换和状态同步功能 - 添加房间倒计时功能支持玩家操作限时 - 实现倒计时 UI 组件显示操作剩余时间 - 修改游戏开始逻辑避免回合开始后重复准备 - 更新 WebSocket 消息处理支持新的托管消息类型 - 添加托管玩家的视觉标识显示托管状态 - 移除房间创建时不必要的总回合数参数
This commit is contained in:
@@ -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<RoomItem> {
|
||||
return authedRequest<RoomItem>({
|
||||
@@ -45,7 +45,6 @@ export async function createRoom(
|
||||
name: input.name,
|
||||
game_type: input.gameType,
|
||||
max_players: input.maxPlayers,
|
||||
total_rounds: input.totalRounds,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const resolvedAvatarUrl = computed(() => {
|
||||
|
||||
<div class="player-meta">
|
||||
<p>{{ player.name }}</p>
|
||||
<small v-if="player.isTrustee" class="trustee-chip">托管中</small>
|
||||
<small v-if="player.isReady" class="ready-chip">已准备</small>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,5 +5,6 @@ export interface SeatPlayerCardModel {
|
||||
dealer: boolean // 是否庄家
|
||||
isTurn: boolean // 是否当前轮到该玩家
|
||||
isReady: boolean // 是否已准备
|
||||
isTrustee: boolean // 是否托管
|
||||
missingSuitLabel: string // 定缺花色(万/筒/条)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface RoomPlayerState {
|
||||
displayName?: string
|
||||
missingSuit?: string | null
|
||||
ready: boolean
|
||||
trustee?: boolean
|
||||
hand: string[]
|
||||
melds: string[]
|
||||
outTiles: string[]
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface PlayerState {
|
||||
seatIndex: number
|
||||
displayName?: string
|
||||
missingSuit?: string | null
|
||||
isTrustee: boolean
|
||||
|
||||
// 手牌(只有自己有完整数据,后端可控制)
|
||||
handTiles: Tile[]
|
||||
|
||||
@@ -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<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)
|
||||
@@ -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<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
||||
dealer: false,
|
||||
isTurn: false,
|
||||
isReady: false,
|
||||
isTrustee: false,
|
||||
missingSuitLabel: defaultMissingSuitLabel,
|
||||
})
|
||||
|
||||
@@ -1312,6 +1390,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),
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
<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"/>
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user