From 7289635340a0094b50a1cd616a139e944b826243 Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Fri, 27 Mar 2026 16:37:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=9B=B4=E6=96=B0=E6=88=90?= =?UTF-8?q?=E9=83=BD=E9=BA=BB=E5=B0=86=E6=B8=B8=E6=88=8F=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除静态背景图片导入,改为动态获取牌面图片 - 添加 MeldState 类型定义,支持副露状态管理 - 重构牌面图片获取逻辑,为不同座位创建独立配置文件 - 定义 TableTileImageType、WallTileItem 和 WallSeatState 接口 - 移除 selectedTile 响应式变量,优化手牌显示逻辑 - 创建 sortedVisibleHandTiles 计算属性替代原 visibleHandTiles - 添加 normalizeMeldType 和 normalizeMelds 函数处理副露数据标准化 - 在 PlayerState 中新增 handCount 和 hasHu 属性 - 更新房间玩家数据结构,同步处理手牌计数和胡牌状态 - 重构牌墙显示逻辑,实现动态渲染各座位手牌和副露 - 添加胡牌标识显示功能,改进牌面分组展示效果 - 优化 CSS 样式,调整牌墙布局和间距设置 --- src/assets/styles/room.css | 84 +++++++- src/store/gameStore.ts | 6 + src/types/state/playerState.ts | 2 + src/views/ChengduGamePage.vue | 355 +++++++++++++++++++++++++-------- 4 files changed, 368 insertions(+), 79 deletions(-) diff --git a/src/assets/styles/room.css b/src/assets/styles/room.css index 2675b63..f170ce1 100644 --- a/src/assets/styles/room.css +++ b/src/assets/styles/room.css @@ -513,6 +513,11 @@ object-fit: contain; } +.wall-live .wall-live-tile { + display: block; + object-fit: contain; +} + .wall-top, .wall-bottom { left: 50%; @@ -565,6 +570,83 @@ bottom: 108px; } +.wall-top.wall-live .wall-live-tile, +.wall-bottom.wall-live .wall-live-tile { + width: 36px; + height: 54px; +} + +.wall-left.wall-live .wall-live-tile { + width: 60px; + height: 40px; +} + +.wall-right.wall-live .wall-live-tile { + width: 60px; + height: 40px; +} + +.wall-top.wall-live .wall-live-tile + .wall-live-tile, +.wall-bottom.wall-live .wall-live-tile + .wall-live-tile { + margin-left: -4px; +} + +.wall-left.wall-live .wall-live-tile + .wall-live-tile, +.wall-right.wall-live .wall-live-tile + .wall-live-tile { + margin-top: -22px; +} + +.wall-left.wall-live .wall-live-tile + .wall-live-tile{ + margin-top: -24px; +} + +.wall-live .wall-live-tile.is-group-start { + margin-left: 10px; +} + +.wall-left.wall-live .wall-live-tile.is-group-start, +.wall-right.wall-live .wall-live-tile.is-group-start { + margin-left: 0; + margin-top: 8px; +} + +.wall-bottom.wall-live { + --wall-bottom-live-scale: 1; + --wall-bottom-live-width: calc(60px * var(--wall-bottom-live-scale)); + --wall-bottom-live-height: calc(86px * var(--wall-bottom-live-scale)); + gap: 0; +} + +.wall-bottom.wall-live .wall-live-tile { + width: var(--wall-bottom-live-width); + height: var(--wall-bottom-live-height); + filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.18)); +} + +.wall-bottom.wall-live .wall-live-tile + .wall-live-tile { + margin-left: -4px; +} + +.wall-bottom.wall-live .wall-live-tile.is-group-start { + margin-left: 12px; +} + +.wall-hu-flag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + margin-left: 8px; + padding: 0 7px; + border-radius: 999px; + color: #fff3da; + font-size: 12px; + font-weight: 800; + background: linear-gradient(180deg, rgba(219, 81, 56, 0.92), rgba(146, 32, 20, 0.96)); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.22); +} + /* 提高特异性,保证本页使用 room.css 配置值 */ .picture-scene .wall-top { top: 120px; @@ -575,7 +657,7 @@ } .picture-scene .wall-bottom { - bottom: 160px; + bottom: 124px; } .picture-scene .wall-left { diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 08f108d..9001951 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -43,6 +43,7 @@ export const useGameStore = defineStore('game', { if (player.playerId === this.getMyPlayerId()) { player.handTiles.push(data.tile) } + player.handCount += 1 // 剩余牌数减少 this.remainingTiles = Math.max(0, this.remainingTiles - 1) @@ -75,6 +76,7 @@ export const useGameStore = defineStore('game', { player.handTiles.splice(index, 1) } } + player.handCount = Math.max(0, player.handCount - 1) // 加入出牌区 player.discardTiles.push(data.tile) @@ -152,8 +154,10 @@ export const useGameStore = defineStore('game', { ? missingSuitRaw : previous?.missingSuit, handTiles: previous?.handTiles ?? [], + handCount: previous?.handCount ?? 0, melds: previous?.melds ?? [], discardTiles: previous?.discardTiles ?? [], + hasHu: previous?.hasHu ?? false, score: previous?.score ?? 0, isReady: typeof readyRaw === 'boolean' @@ -175,8 +179,10 @@ export const useGameStore = defineStore('game', { avatarURL: previous?.avatarURL, missingSuit: previous?.missingSuit, handTiles: previous?.handTiles ?? [], + handCount: previous?.handCount ?? 0, melds: previous?.melds ?? [], discardTiles: previous?.discardTiles ?? [], + hasHu: previous?.hasHu ?? false, score: previous?.score ?? 0, isReady: previous?.isReady ?? false, } diff --git a/src/types/state/playerState.ts b/src/types/state/playerState.ts index 54791b9..63b076c 100644 --- a/src/types/state/playerState.ts +++ b/src/types/state/playerState.ts @@ -10,12 +10,14 @@ export interface PlayerState { // 手牌(只有自己有完整数据,后端可控制) handTiles: Tile[] + handCount: number // 副露(碰/杠) melds: MeldState[] // 出牌区 discardTiles: Tile[] + hasHu: boolean // 分数 score: number diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 927a832..ae0b25e 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -8,10 +8,6 @@ import tiaoIcon from '../assets/images/flowerClolor/tiao.png' import robotIcon from '../assets/images/icons/robot.svg' import exitIcon from '../assets/images/icons/exit.svg' import '../assets/styles/room.css' -import topBackImage from '../assets/images/tiles/top/tbgs_2.png' -import rightBackImage from '../assets/images/tiles/right/tbgs_1.png' -import bottomBackImage from '../assets/images/tiles/bottom/tdbgs_4.png' -import leftBackImage from '../assets/images/tiles/left/tbgs_3.png' import TopPlayerCard from '../components/game/TopPlayerCard.vue' import RightPlayerCard from '../components/game/RightPlayerCard.vue' import BottomPlayerCard from '../components/game/BottomPlayerCard.vue' @@ -35,9 +31,12 @@ import {sendWsMessage} from '../ws/sender' import {buildWsUrl} from '../ws/url' import {useGameStore} from '../store/gameStore' import {setActiveRoom, useActiveRoomState} from '../store' -import type {PlayerState} from '../types/state' +import type {MeldState, PlayerState} from '../types/state' import type {Tile} from '../types/tile' -import {getTileImage} from "../config/bottomTileMap.ts"; +import {getTileImage as getBottomTileImage} from '../config/bottomTileMap.ts' +import {getTileImage as getTopTileImage} from '../config/topTileMap.ts' +import {getTileImage as getRightTileImage} from '../config/rightTileMap.ts' +import {getTileImage as getLeftTileImage} from '../config/leftTileMap.ts' const gameStore = useGameStore() const activeRoom = useActiveRoomState() @@ -52,6 +51,20 @@ type DisplayPlayer = PlayerState & { type GameActionPayload = Extract['payload'] type HandSuitLabel = '万' | '筒' | '条' +type TableTileImageType = 'hand' | 'exposed' | 'covered' + +interface WallTileItem { + key: string + src: string + alt: string + imageType: TableTileImageType + suit?: Tile['suit'] +} + +interface WallSeatState { + tiles: WallTileItem[] + hasHu: boolean +} interface SeatViewModel { key: SeatKey @@ -64,7 +77,6 @@ const now = ref(Date.now()) const wsStatus = ref('idle') const wsMessages = ref([]) const wsError = ref('') -const selectedTile = ref(null) const leaveRoomPending = ref(false) const readyTogglePending = ref(false) const startGamePending = ref(false) @@ -178,12 +190,8 @@ const visibleHandTileGroups = computed(() => { .filter((group) => group.tiles.length > 0) }) -const visibleHandTiles = computed(() => { - if (gameStore.phase === 'waiting') { - return [] - } - - return myHandTiles.value +const sortedVisibleHandTiles = computed(() => { + return visibleHandTileGroups.value.flatMap((group) => group.tiles) }) const remainingTiles = computed(() => { @@ -465,6 +473,83 @@ function normalizeTiles(value: unknown): Tile[] { .filter((item): item is Tile => Boolean(item)) } +function normalizeMeldType(value: unknown, concealed = false): MeldState['type'] | null { + if (typeof value !== 'string') { + return concealed ? 'an_gang' : null + } + + const normalized = value.replace(/[-\s]/g, '_').toLowerCase() + if (normalized === 'peng') { + return 'peng' + } + if (normalized === 'ming_gang' || normalized === 'gang' || normalized === 'gang_open') { + return concealed ? 'an_gang' : 'ming_gang' + } + if (normalized === 'an_gang' || normalized === 'angang' || normalized === 'concealed_gang') { + return 'an_gang' + } + + return concealed ? 'an_gang' : null +} + +function normalizeMelds(value: unknown): PlayerState['melds'] { + if (!Array.isArray(value)) { + return [] + } + + return value + .map((item) => { + const source = asRecord(item) + if (!source) { + return null + } + + const tiles = normalizeTiles( + source.tiles ?? + source.meld_tiles ?? + source.meldTiles ?? + source.cards ?? + source.card_list, + ) + if (tiles.length === 0) { + return null + } + + const concealed = + readBoolean(source, 'concealed', 'is_concealed', 'isConcealed', 'hidden', 'is_hidden') ?? false + const type = normalizeMeldType( + source.type ?? source.meld_type ?? source.meldType ?? source.kind, + concealed, + ) + + if (type === 'peng') { + return { + type, + tiles, + fromPlayerId: readString(source, 'from_player_id', 'fromPlayerId'), + } satisfies MeldState + } + + if (type === 'ming_gang') { + return { + type, + tiles, + fromPlayerId: readString(source, 'from_player_id', 'fromPlayerId'), + } satisfies MeldState + } + + if (type === 'an_gang') { + return { + type, + tiles, + } satisfies MeldState + } + + return null + }) + .filter((item): item is MeldState => Boolean(item)) +} + function requestRoomInfo(): void { const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : '' const roomId = routeRoomId || gameStore.roomId || activeRoom.value?.roomId || '' @@ -538,8 +623,10 @@ function handleRoomInfoResponse(message: unknown): void { missingSuit?: string | null isReady: boolean handTiles: Tile[] + handCount: number melds: PlayerState['melds'] discardTiles: Tile[] + hasHu: boolean score: number } }>() @@ -582,8 +669,10 @@ function handleRoomInfoResponse(message: unknown): void { missingSuit, isReady: ready, handTiles: [], + handCount: 0, melds: [], discardTiles: [], + hasHu: false, score: 0, }, }) @@ -609,6 +698,13 @@ function handleRoomInfoResponse(message: unknown): void { const missingSuit = readString(player, 'missing_suit', 'MissingSuit') || existing?.gamePlayer.missingSuit || null const handCount = readNumber(player, 'hand_count', '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) playerMap.set(playerId, { roomPlayer: { @@ -618,9 +714,9 @@ function handleRoomInfoResponse(message: unknown): void { missingSuit, ready: existing?.roomPlayer.ready ?? false, hand: Array.from({length: handCount}, () => ''), - melds: [], + melds: melds.map((meld) => meld.type), outTiles: outTiles.map((tile) => tileToText(tile)), - hasHu: Boolean(player.has_hu ?? player.hasHu), + hasHu, }, gamePlayer: { playerId, @@ -630,8 +726,10 @@ function handleRoomInfoResponse(message: unknown): void { missingSuit, isReady: existing?.gamePlayer.isReady ?? false, handTiles: existing?.gamePlayer.handTiles ?? [], - melds: existing?.gamePlayer.melds ?? [], + handCount, + melds: melds.length > 0 ? melds : existing?.gamePlayer.melds ?? [], discardTiles: outTiles, + hasHu, score: existing?.gamePlayer.score ?? 0, }, }) @@ -643,6 +741,7 @@ function handleRoomInfoResponse(message: unknown): void { if (current) { current.roomPlayer.hand = privateHand.map((tile) => tileToText(tile)) current.gamePlayer.handTiles = privateHand + current.gamePlayer.handCount = privateHand.length } } @@ -662,8 +761,14 @@ function handleRoomInfoResponse(message: unknown): void { avatarURL: gamePlayer.avatarURL ?? previous?.avatarURL, missingSuit: gamePlayer.missingSuit ?? previous?.missingSuit, handTiles: gamePlayer.handTiles.length > 0 ? gamePlayer.handTiles : previous?.handTiles ?? [], + handCount: gamePlayer.handCount > 0 + ? gamePlayer.handCount + : gamePlayer.handTiles.length > 0 + ? gamePlayer.handTiles.length + : (previous?.handCount ?? 0), melds: gamePlayer.melds.length > 0 ? gamePlayer.melds : previous?.melds ?? [], discardTiles: gamePlayer.discardTiles.length > 0 ? gamePlayer.discardTiles : previous?.discardTiles ?? [], + hasHu: gamePlayer.hasHu || previous?.hasHu || false, score: typeof score === 'number' ? score : previous?.score ?? gamePlayer.score ?? 0, isReady: gamePlayer.isReady, } @@ -764,25 +869,110 @@ const formattedClock = computed(() => { }) }) -const wallBacks = computed>(() => { - if (gameStore.phase === 'waiting' || remainingTiles.value <= 0) { - return { - top: [], - right: [], - bottom: [], - left: [], +function buildWallTileImage( + seat: SeatKey, + tile: Tile | undefined, + imageType: TableTileImageType, +): string { + switch (seat) { + case 'top': + return getTopTileImage(tile, imageType, 'top') + case 'right': + return getRightTileImage(tile, imageType, 'right') + case 'left': + return getLeftTileImage(tile, imageType, 'left') + case 'bottom': + default: + if (!tile) { + return '' + } + return getBottomTileImage(tile, imageType, 'bottom') + } +} + +function emptyWallSeat(): WallSeatState { + return { + tiles: [], + hasHu: false, + } +} + +const wallSeats = computed>(() => { + const emptyState: Record = { + top: emptyWallSeat(), + right: emptyWallSeat(), + bottom: emptyWallSeat(), + left: emptyWallSeat(), + } + + if (gameStore.phase === 'waiting') { + return emptyState + } + + for (const seat of seatViews.value) { + if (!seat.player) { + continue + } + + const seatTiles: WallTileItem[] = [] + const targetSeat = seat.key + + if (seat.isSelf) { + sortedVisibleHandTiles.value.forEach((tile, index) => { + const src = buildWallTileImage(targetSeat, tile, 'hand') + if (!src) { + return + } + + seatTiles.push({ + key: `hand-${tile.id}-${index}`, + src, + alt: formatTile(tile), + imageType: 'hand', + suit: tile.suit, + }) + }) + } else { + for (let index = 0; index < seat.player.handCount; index += 1) { + const src = buildWallTileImage(targetSeat, undefined, 'hand') + if (!src) { + continue + } + + seatTiles.push({ + key: `concealed-${index}`, + src, + alt: '手牌背面', + imageType: 'hand', + }) + } + } + + seat.player.melds.forEach((meld, meldIndex) => { + meld.tiles.forEach((tile, tileIndex) => { + const imageType: TableTileImageType = meld.type === 'an_gang' ? 'covered' : 'exposed' + const src = buildWallTileImage(targetSeat, tile, imageType) + if (!src) { + return + } + + seatTiles.push({ + key: `${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`, + src, + alt: formatTile(tile), + imageType, + suit: tile.suit, + }) + }) + }) + + emptyState[targetSeat] = { + tiles: seatTiles, + hasHu: seat.player.hasHu, } } - const wallSize = remainingTiles.value - const perSide = Math.max(6, Math.ceil((wallSize || 48) / 4 / 2)) - - return { - top: Array.from({length: perSide}, (_, index) => `top-${index}`), - right: Array.from({length: perSide}, (_, index) => `right-${index}`), - bottom: Array.from({length: perSide}, (_, index) => `bottom-${index}`), - left: Array.from({length: perSide}, (_, index) => `left-${index}`), - } + return emptyState }) const seatDecor = computed>(() => { @@ -860,17 +1050,6 @@ function missingSuitLabel(value: string | null | undefined): string { return suitMap[value] ?? value } -function getBackImage(seat: SeatKey): string { - const imageMap: Record = { - top: topBackImage, - right: rightBackImage, - bottom: bottomBackImage, - left: leftBackImage, - } - - return imageMap[seat] -} - function toggleMenu(): void { menuTriggerActive.value = true if (menuTriggerTimer !== null) { @@ -900,10 +1079,6 @@ function toggleTrustMode(): void { menuOpen.value = false } -function selectTile(tile: string): void { - selectedTile.value = selectedTile.value === tile ? null : tile -} - function formatTile(tile: Tile): string { return `${tile.suit}${tile.value}` } @@ -938,6 +1113,7 @@ function handlePlayerHandResponse(message: unknown): void { const existingPlayer = gameStore.players[loggedInUserId.value] if (existingPlayer) { existingPlayer.handTiles = handTiles + existingPlayer.handCount = handTiles.length } const room = activeRoom.value @@ -1338,8 +1514,10 @@ function hydrateFromActiveRoom(routeRoomId: string): void { missingSuit: player.missingSuit ?? previous?.missingSuit, isReady: player.ready, handTiles: previous?.handTiles ?? [], + handCount: previous?.handCount ?? 0, melds: previous?.melds ?? [], discardTiles: previous?.discardTiles ?? [], + hasHu: previous?.hasHu ?? false, score: previous?.score ?? 0, } } @@ -1507,17 +1685,61 @@ onBeforeUnmount(() => { -
- +
+ +
-
- +
+ +
-
- +
+ +
-
- +
+ +
@@ -1562,29 +1784,6 @@ onBeforeUnmount(() => { 开始游戏
-
-
- -
-