From 941d878931b729737f5363870f9adaa0646f6f3a Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Wed, 1 Apr 2026 10:26:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E6=91=B8=E8=83=A1=E7=89=8C=E5=92=8C=E6=9A=97=E6=9D=A0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加了 turnActionPending 状态管理当前回合动作状态 - 新增 canSelfHu 计算属性用于判断是否可以自摸胡牌 - 实现 concealedGangCandidates 计算属性计算可暗杠的牌面选项 - 添加 tileFaceKey 工具函数用于生成牌面键值 - 实现 clearTurnActionPending 和 markTurnActionPending 动作状态管理函数 - 新增牌面解析和胡牌判断相关辅助函数 - 修改 meld 解析逻辑支持数组格式的碰杠数据 - 在游戏状态更新时清理回合动作状态 - 添加 ACTION_ERROR 消息处理器处理操作错误 - 扩展 PENG/GANG/HU/PASS 消息解析支持 - 实现 submitConcealedGang 提交暗杠功能 - 实现 submitSelfHu 提交自摸胡牌功能 - 在 UI 界面添加暗杠和自摸胡牌按钮组件 - 集成 WebSocket 错误处理和状态清理逻辑 --- src/views/ChengduGamePage.vue | 347 +++++++++++++++++++++++++++++++++- 1 file changed, 345 insertions(+), 2 deletions(-) diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 657da66..ccc5237 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -96,9 +96,11 @@ const startGamePending = ref(false) const dingQuePending = ref(false) const discardPending = ref(false) const claimActionPending = ref(false) +const turnActionPending = ref(false) const selectedDiscardTileId = ref(null) let clockTimer: number | null = null let discardPendingTimer: number | null = null +let turnActionPendingTimer: number | null = null let unsubscribe: (() => void) | null = null let needsInitialRoomInfo = false @@ -508,6 +510,79 @@ const showClaimActions = computed(() => { return visibleClaimOptions.value.length > 0 }) +const canSelfHu = computed(() => { + const player = myPlayer.value + if (!player || !gameStore.roomId || wsStatus.value !== 'connected') { + return false + } + + if (showDingQueChooser.value || gameStore.phase !== 'playing' || gameStore.needDraw || gameStore.pendingClaim) { + return false + } + + if (player.seatIndex !== gameStore.currentTurn || turnActionPending.value) { + return false + } + + if (hasMissingSuitTiles.value) { + return false + } + + return isWinningHand(player.handTiles) +}) + +const concealedGangCandidates = computed(() => { + const player = myPlayer.value + if (!player || !gameStore.roomId || wsStatus.value !== 'connected') { + return [] + } + + if (showDingQueChooser.value || gameStore.phase !== 'playing' || gameStore.needDraw || gameStore.pendingClaim) { + return [] + } + + if (player.seatIndex !== gameStore.currentTurn || turnActionPending.value) { + return [] + } + + const firstTileByFace = new Map() + const faceCounts = new Map() + player.handTiles.forEach((tile) => { + const key = tileFaceKey(tile) + faceCounts.set(key, (faceCounts.get(key) ?? 0) + 1) + if (!firstTileByFace.has(key)) { + firstTileByFace.set(key, tile) + } + }) + + return Array.from(faceCounts.entries()) + .filter(([key, count]) => { + if (count < 4) { + return false + } + const tile = firstTileByFace.get(key) + if (!tile) { + return false + } + return !player.missingSuit || tile.suit !== player.missingSuit + }) + .map(([key]) => firstTileByFace.get(key)) + .filter((tile): tile is Tile => Boolean(tile)) + .sort((left, right) => { + const suitDiff = handSuitOrder[left.suit] - handSuitOrder[right.suit] + if (suitDiff !== 0) { + return suitDiff + } + + const valueDiff = left.value - right.value + if (valueDiff !== 0) { + return valueDiff + } + + return left.id - right.id + }) +}) + const actionCountdown = computed(() => { const countdown = roomCountdown.value if (!countdown) { @@ -694,6 +769,128 @@ function tileToText(tile: Tile): string { return `${tile.suit}${tile.value}` } +function tileFaceKey(tile: Tile): string { + return `${tile.suit}_${tile.value}` +} + +function clearTurnActionPending(): void { + turnActionPending.value = false + if (turnActionPendingTimer !== null) { + window.clearTimeout(turnActionPendingTimer) + turnActionPendingTimer = null + } +} + +function markTurnActionPending(kind: 'gang' | 'hu'): void { + clearTurnActionPending() + turnActionPending.value = true + turnActionPendingTimer = window.setTimeout(() => { + turnActionPending.value = false + turnActionPendingTimer = null + wsError.value = `${kind === 'gang' ? '杠牌' : '胡牌'}未收到服务器确认` + }, 2500) +} + +function parseFaceKey(key: string): { suit: Tile['suit']; value: number } | null { + const [suitRaw, valueRaw] = key.split('_') + if ((suitRaw !== 'W' && suitRaw !== 'T' && suitRaw !== 'B') || !valueRaw) { + return null + } + + const value = Number(valueRaw) + if (!Number.isInteger(value)) { + return null + } + + return { + suit: suitRaw, + value, + } +} + +function canFormWinningMelds(counts: Map): boolean { + const currentKey = Array.from(counts.keys()).sort().find((key) => (counts.get(key) ?? 0) > 0) + if (!currentKey) { + return true + } + + const currentCount = counts.get(currentKey) ?? 0 + if (currentCount >= 3) { + counts.set(currentKey, currentCount - 3) + if (canFormWinningMelds(counts)) { + counts.set(currentKey, currentCount) + return true + } + counts.set(currentKey, currentCount) + } + + const parsed = parseFaceKey(currentKey) + if (!parsed || parsed.value > 7) { + return false + } + + const secondKey = `${parsed.suit}_${parsed.value + 1}` + const thirdKey = `${parsed.suit}_${parsed.value + 2}` + const secondCount = counts.get(secondKey) ?? 0 + const thirdCount = counts.get(thirdKey) ?? 0 + if (secondCount <= 0 || thirdCount <= 0) { + return false + } + + counts.set(currentKey, currentCount - 1) + counts.set(secondKey, secondCount - 1) + counts.set(thirdKey, thirdCount - 1) + const success = canFormWinningMelds(counts) + counts.set(currentKey, currentCount) + counts.set(secondKey, secondCount) + counts.set(thirdKey, thirdCount) + return success +} + +function isWinningHand(tiles: Tile[]): boolean { + if (tiles.length % 3 !== 2) { + return false + } + + const counts = new Map() + tiles.forEach((tile) => { + const key = tileFaceKey(tile) + counts.set(key, (counts.get(key) ?? 0) + 1) + }) + + if (tiles.length === 14) { + let pairCount = 0 + let validQiDui = true + for (const count of counts.values()) { + if (count === 2) { + pairCount += 1 + } else if (count === 4) { + pairCount += 2 + } else { + validQiDui = false + break + } + } + if (validQiDui && pairCount === 7) { + return true + } + } + + for (const [key, count] of counts.entries()) { + if (count < 2) { + continue + } + counts.set(key, count - 2) + if (canFormWinningMelds(counts)) { + counts.set(key, count) + return true + } + counts.set(key, count) + } + + return false +} + function normalizeTile(tile: unknown): Tile | null { const source = asRecord(tile) if (!source) { @@ -792,6 +989,25 @@ function normalizeMelds(value: unknown): PlayerState['melds'] { return value .map((item) => { + if (Array.isArray(item)) { + const tiles = normalizeTiles(item) + if (tiles.length === 3) { + return { + type: 'peng', + tiles, + fromPlayerId: '', + } satisfies MeldState + } + if (tiles.length === 4) { + return { + type: 'ming_gang', + tiles, + fromPlayerId: '', + } satisfies MeldState + } + return null + } + const source = asRecord(item) if (!source) { return null @@ -810,10 +1026,15 @@ function normalizeMelds(value: unknown): PlayerState['melds'] { const concealed = readBoolean(source, 'concealed', 'is_concealed', 'isConcealed', 'hidden', 'is_hidden') ?? false - const type = normalizeMeldType( + const explicitType = normalizeMeldType( source.type ?? source.meld_type ?? source.meldType ?? source.kind, concealed, ) + const type = explicitType ?? (tiles.length === 4 + ? (concealed ? 'an_gang' : 'ming_gang') + : tiles.length === 3 + ? 'peng' + : null) if (type === 'peng') { return { @@ -986,6 +1207,7 @@ function handleRoomStateResponse(message: unknown): void { if (!gameStore.pendingClaim) { claimActionPending.value = false } + clearTurnActionPending() const previousRoom = activeRoom.value const roomPlayers = Object.values(gameStore.players) @@ -1304,6 +1526,7 @@ function handleRoomInfoResponse(message: unknown): void { if (!gameStore.pendingClaim) { claimActionPending.value = false } + clearTurnActionPending() const scores = asRecord(gameState?.scores) if (scores) { gameStore.scores = Object.fromEntries( @@ -1648,6 +1871,7 @@ function handlePlayerHandResponse(message: unknown): void { return } + clearTurnActionPending() const existingPlayer = gameStore.players[loggedInUserId.value] if (existingPlayer) { existingPlayer.handTiles = handTiles @@ -1767,6 +1991,32 @@ function handlePlayerTurn(message: unknown): void { applyPlayerTurnCountdown(payload as PlayerTurnPayload) } +function handleActionError(message: unknown): void { + const source = asRecord(message) + if (!source || typeof source.type !== 'string') { + return + } + + if (normalizeWsType(source.type) !== 'ACTION_ERROR') { + return + } + + const payload = asRecord(source.payload) + const roomId = + readString(payload ?? {}, 'room_id', 'roomId') || + readString(source, 'roomId') + if (roomId && gameStore.roomId && roomId !== gameStore.roomId) { + return + } + + const action = readString(payload ?? {}, 'action') || 'unknown' + const messageText = readString(payload ?? {}, 'message') || '操作失败' + claimActionPending.value = false + clearTurnActionPending() + wsError.value = messageText + wsMessages.value.push(`[action-error] ${action}: ${messageText}`) +} + function toGameAction(message: unknown): GameAction | null { if (!message || typeof message !== 'object') { return null @@ -1811,6 +2061,26 @@ function toGameAction(message: unknown): GameAction | null { return {type: 'CLAIM_RESOLVED', payload: payload as GameActionPayload<'CLAIM_RESOLVED'>} } return null + case 'PENG': + case 'GANG': + case 'HU': + case 'PASS': { + const resolvedPayload = asRecord(payload) + const playerId = + readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') || + readString(source, 'target') + const action = type.toLowerCase() + if (!playerId || (action !== 'peng' && action !== 'gang' && action !== 'hu' && action !== 'pass')) { + return null + } + return { + type: 'CLAIM_RESOLVED', + payload: { + playerId, + action, + }, + } + } case 'ROOM_PLAYER_UPDATE': if (payload && typeof payload === 'object') { return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameActionPayload<'ROOM_PLAYER_UPDATE'>} @@ -2290,17 +2560,62 @@ function drawTile(): void { }) } +function submitConcealedGang(tile: Tile): void { + if (!gameStore.roomId || turnActionPending.value) { + return + } + + markTurnActionPending('gang') + sendWsMessage({ + type: 'gang', + roomId: gameStore.roomId, + payload: { + room_id: gameStore.roomId, + tile: { + id: tile.id, + suit: tile.suit, + value: tile.value, + }, + }, + }) +} + +function submitSelfHu(): void { + if (!gameStore.roomId || !canSelfHu.value || turnActionPending.value) { + return + } + + markTurnActionPending('hu') + sendWsMessage({ + type: 'hu', + roomId: gameStore.roomId, + payload: { + room_id: gameStore.roomId, + }, + }) +} + function submitClaim(action: ClaimOptionState): void { if (claimActionPending.value || !gameStore.roomId || !visibleClaimOptions.value.includes(action)) { return } + const claimTile = gameStore.pendingClaim?.tile claimActionPending.value = true sendWsMessage({ type: action, roomId: gameStore.roomId, payload: { room_id: gameStore.roomId, + ...(action !== 'pass' && claimTile + ? { + tile: { + id: claimTile.id, + suit: claimTile.suit, + value: claimTile.value, + }, + } + : {}), }, }) } @@ -2403,6 +2718,7 @@ onMounted(() => { handleRoomStateResponse(msg) handlePlayerHandResponse(msg) handlePlayerTurn(msg) + handleActionError(msg) handleDingQueCountdown(msg) handleReadyStateResponse(msg) handlePlayerDingQueResponse(msg) @@ -2425,6 +2741,7 @@ onMounted(() => { } if (gameAction.type === 'CLAIM_RESOLVED') { claimActionPending.value = false + clearTurnActionPending() } if (gameAction.type === 'ROOM_TRUSTEE') { syncTrusteeState(gameAction.payload) @@ -2436,6 +2753,7 @@ onMounted(() => { }) wsClient.onError((message: string) => { markDiscardCompleted() + clearTurnActionPending() wsError.value = message wsMessages.value.push(`[error] ${message}`) @@ -2477,6 +2795,7 @@ onBeforeUnmount(() => { clockTimer = null } clearDiscardPendingTimer() + clearTurnActionPending() window.removeEventListener('click', handleGlobalClick) window.removeEventListener('keydown', handleGlobalEsc) @@ -2820,7 +3139,31 @@ onBeforeUnmount(() => { -
+
+ +
+ +
+