feat(game): 添加摸牌和碰杠胡操作功能
- 在游戏状态中添加 needDraw 字段用于标识当前回合是否需要摸牌 - 实现 canDrawTile 计算属性控制摸牌按钮的显示和启用状态 - 添加 claimActionPending 状态防止重复提交操作 - 实现 myClaimState、visibleClaimOptions 和 showClaimActions 计算属性 - 添加 submitClaim 方法处理碰/杠/胡/过操作 - 实现 normalizePendingClaim 函数解析服务端推送的声明状态 - 在底部手牌区域将牌图片改为按钮以便点击弃牌 - 添加摸牌按钮和声明操作栏界面元素 - 更新房间创建表单添加局数选择选项 - 添加 E2E 测试文件验证多人房间流程 - 为登录页面输入框和按钮添加 testid 属性便于测试 - 修复 test-results 文件中的失败测试记录
This commit is contained in:
@@ -28,7 +28,7 @@ import {sendWsMessage} from '../ws/sender'
|
||||
import {buildWsUrl} from '../ws/url'
|
||||
import {useGameStore} from '../store/gameStore'
|
||||
import {clearActiveRoom, setActiveRoom, useActiveRoomState} from '../store'
|
||||
import type {MeldState, PlayerState} from '../types/state'
|
||||
import type {ClaimOptionState, MeldState, PendingClaimState, PlayerState} from '../types/state'
|
||||
import type {Tile} from '../types/tile'
|
||||
import {getTileImage as getBottomTileImage} from '../config/bottomTileMap.ts'
|
||||
import {getTileImage as getTopTileImage} from '../config/topTileMap.ts'
|
||||
@@ -56,6 +56,7 @@ interface WallTileItem {
|
||||
alt: string
|
||||
imageType: TableTileImageType
|
||||
suit?: Tile['suit']
|
||||
tile?: Tile
|
||||
}
|
||||
|
||||
interface WallSeatState {
|
||||
@@ -79,6 +80,7 @@ const readyTogglePending = ref(false)
|
||||
const startGamePending = ref(false)
|
||||
const dingQuePending = ref(false)
|
||||
const discardPending = ref(false)
|
||||
const claimActionPending = ref(false)
|
||||
let clockTimer: number | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
let needsInitialRoomInfo = false
|
||||
@@ -367,6 +369,10 @@ const canDiscardTiles = computed(() => {
|
||||
return false
|
||||
}
|
||||
|
||||
if (gameStore.needDraw) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!player.missingSuit || player.handTiles.length === 0) {
|
||||
return false
|
||||
}
|
||||
@@ -374,6 +380,37 @@ const canDiscardTiles = computed(() => {
|
||||
return player.seatIndex === gameStore.currentTurn
|
||||
})
|
||||
|
||||
const canDrawTile = computed(() => {
|
||||
const player = myPlayer.value
|
||||
if (!player || !gameStore.roomId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (gameStore.phase !== 'playing') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!gameStore.needDraw) {
|
||||
return false
|
||||
}
|
||||
|
||||
return player.seatIndex === gameStore.currentTurn
|
||||
})
|
||||
|
||||
const myClaimState = computed<PendingClaimState | undefined>(() => {
|
||||
return gameStore.pendingClaim
|
||||
})
|
||||
|
||||
const visibleClaimOptions = computed<ClaimOptionState[]>(() => {
|
||||
const options = myClaimState.value?.options ?? []
|
||||
const order: ClaimOptionState[] = ['hu', 'gang', 'peng', 'pass']
|
||||
return order.filter((option) => options.includes(option))
|
||||
})
|
||||
|
||||
const showClaimActions = computed(() => {
|
||||
return visibleClaimOptions.value.length > 0
|
||||
})
|
||||
|
||||
function applyPlayerReadyState(playerId: string, ready: boolean): void {
|
||||
const player = gameStore.players[playerId]
|
||||
if (player) {
|
||||
@@ -510,6 +547,44 @@ function normalizeTiles(value: unknown): Tile[] {
|
||||
.filter((item): item is Tile => Boolean(item))
|
||||
}
|
||||
|
||||
function normalizePendingClaim(gameState: Record<string, unknown> | null | undefined): PendingClaimState | undefined {
|
||||
if (!gameState || !loggedInUserId.value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const pendingClaim = asRecord(gameState.pending_claim ?? gameState.pendingClaim)
|
||||
if (!pendingClaim) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const selfOptions = asRecord(pendingClaim[loggedInUserId.value])
|
||||
if (!selfOptions) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const options: ClaimOptionState[] = []
|
||||
if (readBoolean(selfOptions, 'hu')) {
|
||||
options.push('hu')
|
||||
}
|
||||
if (readBoolean(selfOptions, 'gang')) {
|
||||
options.push('gang')
|
||||
}
|
||||
if (readBoolean(selfOptions, 'peng')) {
|
||||
options.push('peng')
|
||||
}
|
||||
if (options.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
options.push('pass')
|
||||
|
||||
return {
|
||||
tile: normalizeTile(gameState.last_discard_tile ?? gameState.lastDiscardTile) ?? undefined,
|
||||
fromPlayerId: readString(gameState, 'last_discard_by', 'lastDiscardBy') || undefined,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMeldType(value: unknown, concealed = false): MeldState['type'] | null {
|
||||
if (typeof value !== 'string') {
|
||||
return concealed ? 'an_gang' : null
|
||||
@@ -705,6 +780,7 @@ function handleRoomStateResponse(message: unknown): void {
|
||||
if (typeof wallCount === 'number') {
|
||||
gameStore.remainingTiles = wallCount
|
||||
}
|
||||
gameStore.needDraw = readBoolean(payload, 'need_draw', 'needDraw') ?? false
|
||||
|
||||
const currentTurnSeat = readNumber(payload, 'current_turn', 'currentTurn')
|
||||
const currentTurnPlayerId = readString(payload, 'current_turn_player', 'currentTurnPlayer')
|
||||
@@ -724,7 +800,10 @@ function handleRoomStateResponse(message: unknown): void {
|
||||
) as Record<string, number>
|
||||
}
|
||||
gameStore.winners = readStringArray(payload, 'winners')
|
||||
gameStore.pendingClaim = undefined
|
||||
gameStore.pendingClaim = normalizePendingClaim(payload)
|
||||
if (!gameStore.pendingClaim) {
|
||||
claimActionPending.value = false
|
||||
}
|
||||
|
||||
const previousRoom = activeRoom.value
|
||||
const roomPlayers = Object.values(gameStore.players)
|
||||
@@ -789,6 +868,7 @@ function handleRoomInfoResponse(message: unknown): void {
|
||||
}
|
||||
|
||||
const payload = asRecord(source.payload) ?? source
|
||||
syncCurrentUserID(readString(source, 'target'))
|
||||
const room = asRecord(payload.room)
|
||||
const gameState = asRecord(payload.game_state)
|
||||
const playerView = asRecord(payload.player_view)
|
||||
@@ -1030,6 +1110,11 @@ function handleRoomInfoResponse(message: unknown): void {
|
||||
if (typeof currentTurn === 'number') {
|
||||
gameStore.currentTurn = currentTurn
|
||||
}
|
||||
gameStore.needDraw = readBoolean(gameState ?? {}, 'need_draw', 'needDraw') ?? false
|
||||
gameStore.pendingClaim = normalizePendingClaim(gameState)
|
||||
if (!gameStore.pendingClaim) {
|
||||
claimActionPending.value = false
|
||||
}
|
||||
const scores = asRecord(gameState?.scores)
|
||||
if (scores) {
|
||||
gameStore.scores = Object.fromEntries(
|
||||
@@ -1144,6 +1229,7 @@ const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
|
||||
alt: formatTile(tile),
|
||||
imageType: 'hand',
|
||||
suit: tile.suit,
|
||||
tile,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
@@ -1299,6 +1385,8 @@ function handlePlayerHandResponse(message: unknown): void {
|
||||
return
|
||||
}
|
||||
|
||||
syncCurrentUserID(readString(source, 'target'))
|
||||
|
||||
const roomId =
|
||||
readString(payload, 'room_id', 'roomId') ||
|
||||
readString(source, 'roomId')
|
||||
@@ -1529,6 +1617,26 @@ function syncAuthSession(next: AuthSession): void {
|
||||
writeStoredAuth(auth.value)
|
||||
}
|
||||
|
||||
function syncCurrentUserID(userID: string): void {
|
||||
if (!userID || loggedInUserId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentAuth = auth.value
|
||||
if (!currentAuth) {
|
||||
return
|
||||
}
|
||||
|
||||
auth.value = {
|
||||
...currentAuth,
|
||||
user: {
|
||||
...(currentAuth.user ?? {}),
|
||||
id: userID,
|
||||
},
|
||||
}
|
||||
writeStoredAuth(auth.value)
|
||||
}
|
||||
|
||||
async function ensureCurrentUserLoaded(): Promise<void> {
|
||||
if (loggedInUserId.value) {
|
||||
return
|
||||
@@ -1746,6 +1854,35 @@ function discardTile(tile: Tile): void {
|
||||
})
|
||||
}
|
||||
|
||||
function drawTile(): void {
|
||||
if (!canDrawTile.value) {
|
||||
return
|
||||
}
|
||||
|
||||
sendWsMessage({
|
||||
type: 'draw',
|
||||
roomId: gameStore.roomId,
|
||||
payload: {
|
||||
room_id: gameStore.roomId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function submitClaim(action: ClaimOptionState): void {
|
||||
if (claimActionPending.value || !gameStore.roomId || !visibleClaimOptions.value.includes(action)) {
|
||||
return
|
||||
}
|
||||
|
||||
claimActionPending.value = true
|
||||
sendWsMessage({
|
||||
type: action,
|
||||
roomId: gameStore.roomId,
|
||||
payload: {
|
||||
room_id: gameStore.roomId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleLeaveRoom(): void {
|
||||
menuOpen.value = false
|
||||
backHall()
|
||||
@@ -1857,6 +1994,9 @@ onMounted(() => {
|
||||
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
||||
readyTogglePending.value = false
|
||||
}
|
||||
if (gameAction.type === 'CLAIM_RESOLVED') {
|
||||
claimActionPending.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
wsClient.onError((message: string) => {
|
||||
@@ -2012,17 +2152,35 @@ onBeforeUnmount(() => {
|
||||
<span v-if="wallSeats.right.hasHu" class="wall-hu-flag">胡</span>
|
||||
</div>
|
||||
<div v-if="wallSeats.bottom.tiles.length > 0 || wallSeats.bottom.hasHu" class="wall wall-bottom wall-live">
|
||||
<img
|
||||
v-for="(tile, index) in wallSeats.bottom.tiles"
|
||||
:key="tile.key"
|
||||
class="wall-live-tile"
|
||||
:class="{
|
||||
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
||||
'is-exposed': tile.imageType !== 'hand',
|
||||
}"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
<template v-for="(tile, index) in wallSeats.bottom.tiles" :key="tile.key">
|
||||
<button
|
||||
v-if="tile.tile && tile.imageType === 'hand'"
|
||||
class="wall-live-tile-button"
|
||||
:class="{
|
||||
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
||||
}"
|
||||
:data-testid="`hand-tile-${tile.tile.id}`"
|
||||
type="button"
|
||||
:disabled="!canDiscardTiles || discardPending"
|
||||
@click="discardTile(tile.tile)"
|
||||
>
|
||||
<img
|
||||
class="wall-live-tile"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
</button>
|
||||
<img
|
||||
v-else
|
||||
class="wall-live-tile"
|
||||
:class="{
|
||||
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
||||
'is-exposed': tile.imageType !== 'hand',
|
||||
}"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="wallSeats.bottom.hasHu" class="wall-hu-flag">胡</span>
|
||||
</div>
|
||||
<div v-if="wallSeats.left.tiles.length > 0 || wallSeats.left.hasHu" class="wall wall-left wall-live">
|
||||
@@ -2103,6 +2261,16 @@ onBeforeUnmount(() => {
|
||||
<span class="ready-toggle-label">{{ myReadyState ? '取 消' : '准 备' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canDrawTile"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
data-testid="draw-tile"
|
||||
type="button"
|
||||
@click="drawTile"
|
||||
>
|
||||
<span class="ready-toggle-label">摸牌</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showStartGameButton && isRoomOwner"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
@@ -2115,17 +2283,17 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="sortedVisibleHandTiles.length > 0" class="hand-action-bar" data-testid="hand-action-bar">
|
||||
<div v-if="showClaimActions" class="claim-action-bar" data-testid="claim-action-bar">
|
||||
<button
|
||||
v-for="tile in sortedVisibleHandTiles"
|
||||
:key="tile.id"
|
||||
class="hand-action-tile"
|
||||
:data-testid="`hand-tile-${tile.id}`"
|
||||
v-for="option in visibleClaimOptions"
|
||||
:key="option"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
:data-testid="`claim-${option}`"
|
||||
type="button"
|
||||
:disabled="!canDiscardTiles || discardPending"
|
||||
@click="discardTile(tile)"
|
||||
:disabled="claimActionPending"
|
||||
@click="submitClaim(option)"
|
||||
>
|
||||
{{ formatTile(tile) }}
|
||||
<span class="ready-toggle-label">{{ option === 'peng' ? '碰' : option === 'gang' ? '杠' : option === 'hu' ? '胡' : '过' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user