feat(game): 添加摸牌和碰杠胡操作功能
- 在游戏状态中添加 needDraw 字段用于标识当前回合是否需要摸牌 - 实现 canDrawTile 计算属性控制摸牌按钮的显示和启用状态 - 添加 claimActionPending 状态防止重复提交操作 - 实现 myClaimState、visibleClaimOptions 和 showClaimActions 计算属性 - 添加 submitClaim 方法处理碰/杠/胡/过操作 - 实现 normalizePendingClaim 函数解析服务端推送的声明状态 - 在底部手牌区域将牌图片改为按钮以便点击弃牌 - 添加摸牌按钮和声明操作栏界面元素 - 更新房间创建表单添加局数选择选项 - 添加 E2E 测试文件验证多人房间流程 - 为登录页面输入框和按钮添加 testid 属性便于测试 - 修复 test-results 文件中的失败测试记录
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 },
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import type {Tile} from "../tile.ts";
|
||||
|
||||
export interface PendingClaimState {
|
||||
// 当前被响应的牌
|
||||
tile: Tile
|
||||
tile?: Tile
|
||||
|
||||
// 出牌人
|
||||
fromPlayerId: string
|
||||
fromPlayerId?: string
|
||||
|
||||
// 当前玩家可执行操作
|
||||
options: ClaimOptionState[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user