feat(game): 添加摸牌和碰杠胡操作功能

- 在游戏状态中添加 needDraw 字段用于标识当前回合是否需要摸牌
- 实现 canDrawTile 计算属性控制摸牌按钮的显示和启用状态
- 添加 claimActionPending 状态防止重复提交操作
- 实现 myClaimState、visibleClaimOptions 和 showClaimActions 计算属性
- 添加 submitClaim 方法处理碰/杠/胡/过操作
- 实现 normalizePendingClaim 函数解析服务端推送的声明状态
- 在底部手牌区域将牌图片改为按钮以便点击弃牌
- 添加摸牌按钮和声明操作栏界面元素
- 更新房间创建表单添加局数选择选项
- 添加 E2E 测试文件验证多人房间流程
- 为登录页面输入框和按钮添加 testid 属性便于测试
- 修复 test-results 文件中的失败测试记录
This commit is contained in:
2026-03-29 17:46:34 +08:00
parent 5c9c2a180d
commit 7751d3b8e3
13 changed files with 543 additions and 42 deletions

View File

@@ -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 },
input: { name: string; gameType: string; maxPlayers: number; totalRounds: number },
onAuthUpdated?: (next: AuthSession) => void,
): Promise<RoomItem> {
return authedRequest<RoomItem>({
@@ -45,6 +45,7 @@ export async function createRoom(
name: input.name,
game_type: input.gameType,
max_players: input.maxPlayers,
total_rounds: input.totalRounds,
},
})
}

View File

@@ -635,11 +635,36 @@
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.18));
}
.wall-live-tile-button {
padding: 0;
border: 0;
background: transparent;
appearance: none;
cursor: pointer;
}
.wall-live-tile-button:disabled {
cursor: default;
opacity: 1;
}
.wall-live-tile-button:disabled .wall-live-tile {
opacity: 1;
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.18));
}
.wall-bottom.wall-live .wall-live-tile-button + .wall-live-tile-button,
.wall-bottom.wall-live .wall-live-tile-button + .wall-live-tile,
.wall-bottom.wall-live .wall-live-tile + .wall-live-tile-button {
margin-left: -4px;
}
.wall-bottom.wall-live .wall-live-tile + .wall-live-tile {
margin-left: -4px;
}
.wall-bottom.wall-live .wall-live-tile.is-group-start {
.wall-bottom.wall-live .wall-live-tile.is-group-start,
.wall-bottom.wall-live .wall-live-tile-button.is-group-start {
margin-left: 12px;
}

View File

@@ -16,6 +16,7 @@ export const useGameStore = defineStore('game', {
dealerIndex: 0,
currentTurn: 0,
needDraw: false,
players: {},
@@ -57,6 +58,7 @@ export const useGameStore = defineStore('game', {
// 清除操作窗口
this.pendingClaim = undefined
this.needDraw = false
// 进入出牌阶段
this.phase = GAME_PHASE.PLAYING
@@ -87,6 +89,7 @@ export const useGameStore = defineStore('game', {
// 更新回合
this.currentTurn = data.nextSeat
this.needDraw = true
// 等待其他玩家响应
this.phase = GAME_PHASE.ACTION
@@ -95,6 +98,7 @@ export const useGameStore = defineStore('game', {
// 触发操作窗口(碰/杠/胡)
onPendingClaim(data: PendingClaimState) {
this.pendingClaim = data
this.needDraw = false
this.phase = GAME_PHASE.ACTION
},

View File

@@ -14,6 +14,9 @@ export interface GameState {
// 当前操作玩家(座位)
currentTurn: number
// 当前回合是否需要先摸牌
needDraw: boolean
// 玩家列表
players: Record<string, PlayerState>
@@ -28,4 +31,4 @@ export interface GameState {
// 分数playerId -> score
scores: Record<string, number>
}
}

View File

@@ -4,11 +4,11 @@ import type {Tile} from "../tile.ts";
export interface PendingClaimState {
// 当前被响应的牌
tile: Tile
tile?: Tile
// 出牌人
fromPlayerId: string
fromPlayerId?: string
// 当前玩家可执行操作
options: ClaimOptionState[]
}
}

View File

@@ -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>

View File

@@ -32,6 +32,7 @@ const createRoomForm = ref({
name: '',
gameType: 'chengdu',
maxPlayers: 4,
totalRounds: 8,
})
const quickJoinRoomId = ref('')
@@ -246,6 +247,7 @@ async function submitCreateRoom(): Promise<void> {
name: createRoomForm.value.name.trim(),
gameType: createRoomForm.value.gameType,
maxPlayers: Number(createRoomForm.value.maxPlayers),
totalRounds: Number(createRoomForm.value.totalRounds),
},
syncAuth,
)
@@ -265,6 +267,7 @@ async function submitCreateRoom(): Promise<void> {
})
quickJoinRoomId.value = room.room_id
createRoomForm.value.name = ''
createRoomForm.value.totalRounds = 8
showCreateModal.value = false
showCreatedModal.value = true
await refreshRooms()
@@ -499,11 +502,16 @@ onMounted(async () => {
<fieldset class="radio-group">
<legend>人数</legend>
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="2" /> 2</label>
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="3" /> 3</label>
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="4" /> 4</label>
</fieldset>
<fieldset class="radio-group">
<legend>局数</legend>
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="4" /> 4</label>
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="8" /> 8</label>
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="16" /> 16</label>
</fieldset>
<div class="modal-actions">
<button class="ghost-btn" type="button" @click="closeCreateModal">取消</button>
<button class="primary-btn" data-testid="submit-create-room" type="submit" :disabled="roomSubmitting">

View File

@@ -70,13 +70,13 @@ async function handleSubmit(): Promise<void> {
<form class="form" @submit.prevent="handleSubmit">
<label class="field">
<span>登录ID</span>
<input v-model.trim="form.loginId" type="text" placeholder="请输入手机号或账号" />
<input v-model.trim="form.loginId" data-testid="login-id" type="text" placeholder="请输入手机号或账号" />
</label>
<label class="field">
<span>密码</span>
<input v-model="form.password" type="password" placeholder="请输入密码" />
<input v-model="form.password" data-testid="login-password" type="password" placeholder="请输入密码" />
</label>
<button class="primary-btn" type="submit" :disabled="submitting">
<button class="primary-btn" data-testid="login-submit" type="submit" :disabled="submitting">
{{ submitting ? '登录中...' : '登录' }}
</button>
</form>