From 5c9c2a180dabb9d3e49d2d1aec1ea86fa013c47e Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Sun, 29 Mar 2026 16:47:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=88=90?= =?UTF-8?q?=E9=83=BD=E9=BA=BB=E5=B0=86=E6=89=93=E7=89=8C=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=92=8CE2E=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加discardPending状态控制丢弃牌操作 - 实现canDiscardTiles计算属性判断是否可以丢弃牌 - 新增handleRoomStateResponse函数处理房间状态响应 - 实现discardTile函数发送丢弃牌消息 - 在游戏页面添加手牌操作栏显示可丢弃的牌 - 为定缺按钮和准备按钮添加data-testid标识 - 在大厅页面为房间操作元素添加data-testid标识 - 添加手牌操作相关的CSS样式 - 配置Playwright E2E测试框架 - 创建房间流程到打牌的完整E2E测试用例 --- playwright.config.ts | 20 ++ src/assets/styles/room.css | 37 ++++ src/views/ChengduGamePage.vue | 230 ++++++++++++++++++++ src/views/HallPage.vue | 13 +- test-results/.last-run.json | 4 + tests/e2e/room-flow.spec.ts | 383 ++++++++++++++++++++++++++++++++++ 6 files changed, 681 insertions(+), 6 deletions(-) create mode 100644 playwright.config.ts create mode 100644 test-results/.last-run.json create mode 100644 tests/e2e/room-flow.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..63cd9f6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 60_000, + expect: { + timeout: 10_000, + }, + use: { + baseURL: 'http://127.0.0.1:4173', + 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/assets/styles/room.css b/src/assets/styles/room.css index f4f8f8b..b9ad288 100644 --- a/src/assets/styles/room.css +++ b/src/assets/styles/room.css @@ -917,6 +917,43 @@ margin-bottom: 10px; } +.hand-action-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + max-width: min(760px, calc(100vw - 120px)); + margin: 0 auto; + padding: 10px 14px; + border-radius: 18px; + background: rgba(14, 25, 22, 0.72); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24); +} + +.hand-action-tile { + min-width: 58px; + padding: 10px 12px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 12px; + background: linear-gradient(180deg, rgba(244, 228, 194, 0.98), rgba(214, 190, 145, 0.96)); + color: #2e1e14; + font-size: 15px; + font-weight: 800; + letter-spacing: 0.04em; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.4), + 0 6px 14px rgba(0, 0, 0, 0.18); + transition: transform 120ms ease-out, box-shadow 120ms ease-out, opacity 120ms ease-out; +} + +.hand-action-tile:active:not(:disabled) { + transform: translateY(1px); +} + +.hand-action-tile:disabled { + opacity: 0.55; +} + .ding-que-bar { position: absolute; right: 150px; diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 3d9fe9e..6ba58f0 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -78,6 +78,7 @@ const leaveRoomPending = ref(false) const readyTogglePending = ref(false) const startGamePending = ref(false) const dingQuePending = ref(false) +const discardPending = ref(false) let clockTimer: number | null = null let unsubscribe: (() => void) | null = null let needsInitialRoomInfo = false @@ -356,6 +357,23 @@ const showDingQueChooser = computed(() => { return player.handTiles.length > 0 && !player.missingSuit }) +const canDiscardTiles = computed(() => { + const player = myPlayer.value + if (!player || !gameStore.roomId) { + return false + } + + if (gameStore.phase !== 'playing') { + return false + } + + if (!player.missingSuit || player.handTiles.length === 0) { + return false + } + + return player.seatIndex === gameStore.currentTurn +}) + function applyPlayerReadyState(playerId: string, ready: boolean): void { const player = gameStore.players[playerId] if (player) { @@ -591,6 +609,174 @@ function requestRoomInfo(): void { }) } +function handleRoomStateResponse(message: unknown): void { + const source = asRecord(message) + if (!source || typeof source.type !== 'string') { + return + } + + if (normalizeWsType(source.type) !== 'ROOM_STATE') { + return + } + + const payload = asRecord(source.payload) + if (!payload) { + return + } + + const roomId = + readString(payload, 'room_id', 'roomId') || + readString(source, 'roomId') || + gameStore.roomId + if (!roomId) { + return + } + if (gameStore.roomId && roomId !== gameStore.roomId) { + return + } + + const previousPlayers = gameStore.players + const nextPlayers: typeof gameStore.players = {} + const gamePlayers = Array.isArray(payload.players) ? payload.players : [] + + gamePlayers.forEach((item, fallbackIndex) => { + const player = asRecord(item) + if (!player) { + return + } + + const playerId = readString(player, 'player_id', 'PlayerID') + if (!playerId) { + return + } + + const previous = previousPlayers[playerId] + const seatIndex = previous?.seatIndex ?? fallbackIndex + const handCount = readNumber(player, 'hand_count', 'handCount') ?? previous?.handCount ?? 0 + const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles) + const melds = normalizeMelds( + player.melds ?? + player.exposed_melds ?? + player.exposedMelds ?? + player.claims, + ) + const hasHu = Boolean(player.has_hu ?? player.hasHu) + const dingQue = readString(player, 'ding_que', 'dingQue') + const scores = asRecord(payload.scores) + const score = scores?.[playerId] + + nextPlayers[playerId] = { + playerId, + seatIndex, + displayName: previous?.displayName ?? playerId, + avatarURL: previous?.avatarURL, + missingSuit: dingQue || previous?.missingSuit || null, + handTiles: previous?.handTiles ?? [], + handCount, + melds, + discardTiles: outTiles, + hasHu, + score: typeof score === 'number' ? score : previous?.score ?? 0, + isReady: previous?.isReady ?? false, + } + }) + + if (Object.keys(nextPlayers).length > 0) { + gameStore.players = nextPlayers + } + + gameStore.roomId = roomId + + const phase = + readString(payload, 'phase') || + readString(payload, 'status') || + 'waiting' + const phaseMap: Record = { + waiting: 'waiting', + dealing: 'dealing', + playing: 'playing', + action: 'action', + settlement: 'settlement', + finished: 'settlement', + } + gameStore.phase = phaseMap[phase] ?? gameStore.phase + + const wallCount = readNumber(payload, 'wall_count', 'wallCount') + if (typeof wallCount === 'number') { + gameStore.remainingTiles = wallCount + } + + const currentTurnSeat = readNumber(payload, 'current_turn', 'currentTurn') + const currentTurnPlayerId = readString(payload, 'current_turn_player', 'currentTurnPlayer') + const currentTurn = + currentTurnSeat ?? + (currentTurnPlayerId && gameStore.players[currentTurnPlayerId] + ? gameStore.players[currentTurnPlayerId].seatIndex + : null) + if (typeof currentTurn === 'number') { + gameStore.currentTurn = currentTurn + } + + const scores = asRecord(payload.scores) + if (scores) { + gameStore.scores = Object.fromEntries( + Object.entries(scores).filter(([, value]) => typeof value === 'number'), + ) as Record + } + gameStore.winners = readStringArray(payload, 'winners') + gameStore.pendingClaim = undefined + + const previousRoom = activeRoom.value + const roomPlayers = Object.values(gameStore.players) + .sort((left, right) => left.seatIndex - right.seatIndex) + .map((player) => { + const previousPlayer = previousRoom?.players.find((item) => item.playerId === player.playerId) + return { + index: player.seatIndex, + playerId: player.playerId, + displayName: player.displayName, + missingSuit: player.missingSuit, + ready: previousPlayer?.ready ?? player.isReady, + hand: player.playerId === loggedInUserId.value + ? player.handTiles.map((tile) => tileToText(tile)) + : Array.from({length: player.handCount}, () => ''), + melds: player.melds.map((meld) => meld.type), + outTiles: player.discardTiles.map((tile) => tileToText(tile)), + hasHu: player.hasHu, + } + }) + + setActiveRoom({ + roomId, + roomName: previousRoom?.roomName || roomName.value, + gameType: previousRoom?.gameType || 'chengdu', + ownerId: previousRoom?.ownerId || '', + maxPlayers: previousRoom?.maxPlayers ?? 4, + playerCount: roomPlayers.length, + status: phase === 'settlement' ? 'finished' : phase === 'waiting' ? 'waiting' : 'playing', + createdAt: previousRoom?.createdAt || '', + updatedAt: previousRoom?.updatedAt || '', + players: roomPlayers, + myHand: myHandTiles.value.map((tile) => tileToText(tile)), + game: { + state: { + wall: Array.from({length: wallCount ?? 0}, (_, index) => `wall-${index}`), + scores: gameStore.scores, + dealerIndex: previousRoom?.game?.state?.dealerIndex ?? gameStore.dealerIndex, + currentTurn: typeof currentTurn === 'number' ? currentTurn : previousRoom?.game?.state?.currentTurn ?? -1, + phase, + }, + }, + }) + + if (phase !== 'waiting') { + startGamePending.value = false + } + if (currentTurnPlayerId && currentTurnPlayerId !== loggedInUserId.value) { + discardPending.value = false + } +} + function handleRoomInfoResponse(message: unknown): void { const source = asRecord(message) if (!source || typeof source.type !== 'string') { @@ -1140,6 +1326,11 @@ function handlePlayerHandResponse(message: unknown): void { roomPlayer.hand = room.myHand } } + + discardPending.value = false + if (gameStore.phase !== 'waiting') { + startGamePending.value = false + } } function toGameAction(message: unknown): GameAction | null { @@ -1539,6 +1730,22 @@ function chooseDingQue(suit: Tile['suit']): void { }) } +function discardTile(tile: Tile): void { + if (discardPending.value || !canDiscardTiles.value) { + return + } + + discardPending.value = true + sendWsMessage({ + type: 'discard', + roomId: gameStore.roomId, + payload: { + room_id: gameStore.roomId, + tile, + }, + }) +} + function handleLeaveRoom(): void { menuOpen.value = false backHall() @@ -1633,6 +1840,7 @@ onMounted(() => { const text = typeof msg === 'string' ? msg : JSON.stringify(msg) wsMessages.value.push(`[server] ${text}`) handleRoomInfoResponse(msg) + handleRoomStateResponse(msg) handlePlayerHandResponse(msg) handleReadyStateResponse(msg) handlePlayerDingQueResponse(msg) @@ -1642,6 +1850,9 @@ onMounted(() => { if (gameAction.type === 'GAME_START') { startGamePending.value = false } + if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === loggedInUserId.value) { + discardPending.value = false + } if (gameAction.type === 'ROOM_PLAYER_UPDATE') { syncReadyStatesFromRoomUpdate(gameAction.payload) readyTogglePending.value = false @@ -1854,6 +2065,7 @@ onBeforeUnmount(() => {
+ +
+ +
diff --git a/src/views/HallPage.vue b/src/views/HallPage.vue index 672372c..7541bcc 100644 --- a/src/views/HallPage.vue +++ b/src/views/HallPage.vue @@ -449,6 +449,7 @@ onMounted(async () => { + @@ -471,8 +472,8 @@ onMounted(async () => {

快速加入

- - + +
@@ -486,7 +487,7 @@ onMounted(async () => {