From 7751d3b8e37d9fc0271367bbfa482b3a2d958102 Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Sun, 29 Mar 2026 17:46:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=91=B8?= =?UTF-8?q?=E7=89=8C=E5=92=8C=E7=A2=B0=E6=9D=A0=E8=83=A1=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在游戏状态中添加 needDraw 字段用于标识当前回合是否需要摸牌 - 实现 canDrawTile 计算属性控制摸牌按钮的显示和启用状态 - 添加 claimActionPending 状态防止重复提交操作 - 实现 myClaimState、visibleClaimOptions 和 showClaimActions 计算属性 - 添加 submitClaim 方法处理碰/杠/胡/过操作 - 实现 normalizePendingClaim 函数解析服务端推送的声明状态 - 在底部手牌区域将牌图片改为按钮以便点击弃牌 - 添加摸牌按钮和声明操作栏界面元素 - 更新房间创建表单添加局数选择选项 - 添加 E2E 测试文件验证多人房间流程 - 为登录页面输入框和按钮添加 testid 属性便于测试 - 修复 test-results 文件中的失败测试记录 --- README.md | 3 + package.json | 3 +- playwright.config.ts | 8 +- src/api/mahjong.ts | 3 +- src/assets/styles/room.css | 27 ++- src/store/gameStore.ts | 4 + src/types/state/gameState.ts | 5 +- src/types/state/pendingClaimState.ts | 6 +- src/views/ChengduGamePage.vue | 210 +++++++++++++++++-- src/views/HallPage.vue | 12 +- src/views/LoginPage.vue | 6 +- test-results/.last-run.json | 6 +- tests/e2e/room-flow.live.spec.ts | 292 +++++++++++++++++++++++++++ 13 files changed, 543 insertions(+), 42 deletions(-) create mode 100644 tests/e2e/room-flow.live.spec.ts diff --git a/README.md b/README.md index 2fbd11e..8125f32 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,6 @@ Preview the production build: ```bash pnpm preview ``` + +测试账号:A,B,C,D +测试密码:123456 \ No newline at end of file diff --git a/package.json b/package.json index ebd3a6f..3eefa86 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev": "vite", "build": "vue-tsc -b && vite build", "preview": "vite preview", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:e2e:live": "PLAYWRIGHT_LIVE=1 playwright test tests/e2e/room-flow.live.spec.ts" }, "dependencies": { "pinia": "^3.0.4", diff --git a/playwright.config.ts b/playwright.config.ts index 63cd9f6..5817a4f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,14 +7,8 @@ export default defineConfig({ timeout: 10_000, }, use: { - baseURL: 'http://127.0.0.1:4173', + baseURL: 'http://localhost:5173', headless: true, trace: 'on-first-retry', }, - webServer: { - command: 'pnpm dev --host 127.0.0.1 --port 4173', - url: 'http://127.0.0.1:4173', - reuseExistingServer: true, - timeout: 120_000, - }, }) diff --git a/src/api/mahjong.ts b/src/api/mahjong.ts index a4c368b..b59a355 100644 --- a/src/api/mahjong.ts +++ b/src/api/mahjong.ts @@ -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 { return authedRequest({ @@ -45,6 +45,7 @@ export async function createRoom( name: input.name, game_type: input.gameType, max_players: input.maxPlayers, + total_rounds: input.totalRounds, }, }) } diff --git a/src/assets/styles/room.css b/src/assets/styles/room.css index b9ad288..35b909b 100644 --- a/src/assets/styles/room.css +++ b/src/assets/styles/room.css @@ -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; } diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 2911f05..36d78cd 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -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 }, diff --git a/src/types/state/gameState.ts b/src/types/state/gameState.ts index 676266a..d968587 100644 --- a/src/types/state/gameState.ts +++ b/src/types/state/gameState.ts @@ -14,6 +14,9 @@ export interface GameState { // 当前操作玩家(座位) currentTurn: number + // 当前回合是否需要先摸牌 + needDraw: boolean + // 玩家列表 players: Record @@ -28,4 +31,4 @@ export interface GameState { // 分数(playerId -> score) scores: Record -} \ No newline at end of file +} diff --git a/src/types/state/pendingClaimState.ts b/src/types/state/pendingClaimState.ts index be57b33..adc745c 100644 --- a/src/types/state/pendingClaimState.ts +++ b/src/types/state/pendingClaimState.ts @@ -4,11 +4,11 @@ import type {Tile} from "../tile.ts"; export interface PendingClaimState { // 当前被响应的牌 - tile: Tile + tile?: Tile // 出牌人 - fromPlayerId: string + fromPlayerId?: string // 当前玩家可执行操作 options: ClaimOptionState[] -} \ No newline at end of file +} diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 6ba58f0..63b89ac 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -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(() => { + return gameStore.pendingClaim +}) + +const visibleClaimOptions = computed(() => { + 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 | 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 } 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>(() => { 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 { 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(() => {
- +
@@ -2103,6 +2261,16 @@ onBeforeUnmount(() => { {{ myReadyState ? '取 消' : '准 备' }} + +
-
+
diff --git a/src/views/HallPage.vue b/src/views/HallPage.vue index 7541bcc..73fe096 100644 --- a/src/views/HallPage.vue +++ b/src/views/HallPage.vue @@ -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 { 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 { }) 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 () => {
人数 - -
+
+ 局数 + + + +
+