feat(game): 添加自摸胡牌和暗杠功能支持
- 添加了 turnActionPending 状态管理当前回合动作状态 - 新增 canSelfHu 计算属性用于判断是否可以自摸胡牌 - 实现 concealedGangCandidates 计算属性计算可暗杠的牌面选项 - 添加 tileFaceKey 工具函数用于生成牌面键值 - 实现 clearTurnActionPending 和 markTurnActionPending 动作状态管理函数 - 新增牌面解析和胡牌判断相关辅助函数 - 修改 meld 解析逻辑支持数组格式的碰杠数据 - 在游戏状态更新时清理回合动作状态 - 添加 ACTION_ERROR 消息处理器处理操作错误 - 扩展 PENG/GANG/HU/PASS 消息解析支持 - 实现 submitConcealedGang 提交暗杠功能 - 实现 submitSelfHu 提交自摸胡牌功能 - 在 UI 界面添加暗杠和自摸胡牌按钮组件 - 集成 WebSocket 错误处理和状态清理逻辑
This commit is contained in:
@@ -96,9 +96,11 @@ const startGamePending = ref(false)
|
|||||||
const dingQuePending = ref(false)
|
const dingQuePending = ref(false)
|
||||||
const discardPending = ref(false)
|
const discardPending = ref(false)
|
||||||
const claimActionPending = ref(false)
|
const claimActionPending = ref(false)
|
||||||
|
const turnActionPending = ref(false)
|
||||||
const selectedDiscardTileId = ref<number | null>(null)
|
const selectedDiscardTileId = ref<number | null>(null)
|
||||||
let clockTimer: number | null = null
|
let clockTimer: number | null = null
|
||||||
let discardPendingTimer: number | null = null
|
let discardPendingTimer: number | null = null
|
||||||
|
let turnActionPendingTimer: number | null = null
|
||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
let needsInitialRoomInfo = false
|
let needsInitialRoomInfo = false
|
||||||
|
|
||||||
@@ -508,6 +510,79 @@ const showClaimActions = computed(() => {
|
|||||||
return visibleClaimOptions.value.length > 0
|
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<Tile[]>(() => {
|
||||||
|
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<string, Tile>()
|
||||||
|
const faceCounts = new Map<string, number>()
|
||||||
|
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 actionCountdown = computed(() => {
|
||||||
const countdown = roomCountdown.value
|
const countdown = roomCountdown.value
|
||||||
if (!countdown) {
|
if (!countdown) {
|
||||||
@@ -694,6 +769,128 @@ function tileToText(tile: Tile): string {
|
|||||||
return `${tile.suit}${tile.value}`
|
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<string, number>): 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<string, number>()
|
||||||
|
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 {
|
function normalizeTile(tile: unknown): Tile | null {
|
||||||
const source = asRecord(tile)
|
const source = asRecord(tile)
|
||||||
if (!source) {
|
if (!source) {
|
||||||
@@ -792,6 +989,25 @@ function normalizeMelds(value: unknown): PlayerState['melds'] {
|
|||||||
|
|
||||||
return value
|
return value
|
||||||
.map((item) => {
|
.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)
|
const source = asRecord(item)
|
||||||
if (!source) {
|
if (!source) {
|
||||||
return null
|
return null
|
||||||
@@ -810,10 +1026,15 @@ function normalizeMelds(value: unknown): PlayerState['melds'] {
|
|||||||
|
|
||||||
const concealed =
|
const concealed =
|
||||||
readBoolean(source, 'concealed', 'is_concealed', 'isConcealed', 'hidden', 'is_hidden') ?? false
|
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,
|
source.type ?? source.meld_type ?? source.meldType ?? source.kind,
|
||||||
concealed,
|
concealed,
|
||||||
)
|
)
|
||||||
|
const type = explicitType ?? (tiles.length === 4
|
||||||
|
? (concealed ? 'an_gang' : 'ming_gang')
|
||||||
|
: tiles.length === 3
|
||||||
|
? 'peng'
|
||||||
|
: null)
|
||||||
|
|
||||||
if (type === 'peng') {
|
if (type === 'peng') {
|
||||||
return {
|
return {
|
||||||
@@ -986,6 +1207,7 @@ function handleRoomStateResponse(message: unknown): void {
|
|||||||
if (!gameStore.pendingClaim) {
|
if (!gameStore.pendingClaim) {
|
||||||
claimActionPending.value = false
|
claimActionPending.value = false
|
||||||
}
|
}
|
||||||
|
clearTurnActionPending()
|
||||||
|
|
||||||
const previousRoom = activeRoom.value
|
const previousRoom = activeRoom.value
|
||||||
const roomPlayers = Object.values(gameStore.players)
|
const roomPlayers = Object.values(gameStore.players)
|
||||||
@@ -1304,6 +1526,7 @@ function handleRoomInfoResponse(message: unknown): void {
|
|||||||
if (!gameStore.pendingClaim) {
|
if (!gameStore.pendingClaim) {
|
||||||
claimActionPending.value = false
|
claimActionPending.value = false
|
||||||
}
|
}
|
||||||
|
clearTurnActionPending()
|
||||||
const scores = asRecord(gameState?.scores)
|
const scores = asRecord(gameState?.scores)
|
||||||
if (scores) {
|
if (scores) {
|
||||||
gameStore.scores = Object.fromEntries(
|
gameStore.scores = Object.fromEntries(
|
||||||
@@ -1648,6 +1871,7 @@ function handlePlayerHandResponse(message: unknown): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearTurnActionPending()
|
||||||
const existingPlayer = gameStore.players[loggedInUserId.value]
|
const existingPlayer = gameStore.players[loggedInUserId.value]
|
||||||
if (existingPlayer) {
|
if (existingPlayer) {
|
||||||
existingPlayer.handTiles = handTiles
|
existingPlayer.handTiles = handTiles
|
||||||
@@ -1767,6 +1991,32 @@ function handlePlayerTurn(message: unknown): void {
|
|||||||
applyPlayerTurnCountdown(payload as PlayerTurnPayload)
|
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 {
|
function toGameAction(message: unknown): GameAction | null {
|
||||||
if (!message || typeof message !== 'object') {
|
if (!message || typeof message !== 'object') {
|
||||||
return null
|
return null
|
||||||
@@ -1811,6 +2061,26 @@ function toGameAction(message: unknown): GameAction | null {
|
|||||||
return {type: 'CLAIM_RESOLVED', payload: payload as GameActionPayload<'CLAIM_RESOLVED'>}
|
return {type: 'CLAIM_RESOLVED', payload: payload as GameActionPayload<'CLAIM_RESOLVED'>}
|
||||||
}
|
}
|
||||||
return null
|
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':
|
case 'ROOM_PLAYER_UPDATE':
|
||||||
if (payload && typeof payload === 'object') {
|
if (payload && typeof payload === 'object') {
|
||||||
return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameActionPayload<'ROOM_PLAYER_UPDATE'>}
|
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 {
|
function submitClaim(action: ClaimOptionState): void {
|
||||||
if (claimActionPending.value || !gameStore.roomId || !visibleClaimOptions.value.includes(action)) {
|
if (claimActionPending.value || !gameStore.roomId || !visibleClaimOptions.value.includes(action)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const claimTile = gameStore.pendingClaim?.tile
|
||||||
claimActionPending.value = true
|
claimActionPending.value = true
|
||||||
sendWsMessage({
|
sendWsMessage({
|
||||||
type: action,
|
type: action,
|
||||||
roomId: gameStore.roomId,
|
roomId: gameStore.roomId,
|
||||||
payload: {
|
payload: {
|
||||||
room_id: gameStore.roomId,
|
room_id: gameStore.roomId,
|
||||||
|
...(action !== 'pass' && claimTile
|
||||||
|
? {
|
||||||
|
tile: {
|
||||||
|
id: claimTile.id,
|
||||||
|
suit: claimTile.suit,
|
||||||
|
value: claimTile.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2403,6 +2718,7 @@ onMounted(() => {
|
|||||||
handleRoomStateResponse(msg)
|
handleRoomStateResponse(msg)
|
||||||
handlePlayerHandResponse(msg)
|
handlePlayerHandResponse(msg)
|
||||||
handlePlayerTurn(msg)
|
handlePlayerTurn(msg)
|
||||||
|
handleActionError(msg)
|
||||||
handleDingQueCountdown(msg)
|
handleDingQueCountdown(msg)
|
||||||
handleReadyStateResponse(msg)
|
handleReadyStateResponse(msg)
|
||||||
handlePlayerDingQueResponse(msg)
|
handlePlayerDingQueResponse(msg)
|
||||||
@@ -2425,6 +2741,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
if (gameAction.type === 'CLAIM_RESOLVED') {
|
if (gameAction.type === 'CLAIM_RESOLVED') {
|
||||||
claimActionPending.value = false
|
claimActionPending.value = false
|
||||||
|
clearTurnActionPending()
|
||||||
}
|
}
|
||||||
if (gameAction.type === 'ROOM_TRUSTEE') {
|
if (gameAction.type === 'ROOM_TRUSTEE') {
|
||||||
syncTrusteeState(gameAction.payload)
|
syncTrusteeState(gameAction.payload)
|
||||||
@@ -2436,6 +2753,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
wsClient.onError((message: string) => {
|
wsClient.onError((message: string) => {
|
||||||
markDiscardCompleted()
|
markDiscardCompleted()
|
||||||
|
clearTurnActionPending()
|
||||||
wsError.value = message
|
wsError.value = message
|
||||||
wsMessages.value.push(`[error] ${message}`)
|
wsMessages.value.push(`[error] ${message}`)
|
||||||
|
|
||||||
@@ -2477,6 +2795,7 @@ onBeforeUnmount(() => {
|
|||||||
clockTimer = null
|
clockTimer = null
|
||||||
}
|
}
|
||||||
clearDiscardPendingTimer()
|
clearDiscardPendingTimer()
|
||||||
|
clearTurnActionPending()
|
||||||
|
|
||||||
window.removeEventListener('click', handleGlobalClick)
|
window.removeEventListener('click', handleGlobalClick)
|
||||||
window.removeEventListener('keydown', handleGlobalEsc)
|
window.removeEventListener('keydown', handleGlobalEsc)
|
||||||
@@ -2820,7 +3139,31 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showClaimActions" class="claim-action-bar" data-testid="claim-action-bar">
|
<div v-if="concealedGangCandidates.length > 0" class="hand-action-bar">
|
||||||
|
<button
|
||||||
|
v-for="tile in concealedGangCandidates"
|
||||||
|
:key="`gang-${tile.id}`"
|
||||||
|
class="hand-action-tile"
|
||||||
|
:data-testid="`hand-gang-${tile.suit}-${tile.value}`"
|
||||||
|
type="button"
|
||||||
|
:disabled="turnActionPending"
|
||||||
|
@click="submitConcealedGang(tile)"
|
||||||
|
>
|
||||||
|
杠 {{ formatTile(tile) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canSelfHu || showClaimActions" class="claim-action-bar" data-testid="claim-action-bar">
|
||||||
|
<button
|
||||||
|
v-if="canSelfHu"
|
||||||
|
class="ready-toggle ready-toggle-inline"
|
||||||
|
data-testid="claim-self-hu"
|
||||||
|
type="button"
|
||||||
|
:disabled="turnActionPending"
|
||||||
|
@click="submitSelfHu"
|
||||||
|
>
|
||||||
|
<span class="ready-toggle-label">胡</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="option in visibleClaimOptions"
|
v-for="option in visibleClaimOptions"
|
||||||
:key="option"
|
:key="option"
|
||||||
|
|||||||
Reference in New Issue
Block a user