feat(game): 更新成都麻将游戏页面功能实现

- 移除静态背景图片导入,改为动态获取牌面图片
- 添加 MeldState 类型定义,支持副露状态管理
- 重构牌面图片获取逻辑,为不同座位创建独立配置文件
- 定义 TableTileImageType、WallTileItem 和 WallSeatState 接口
- 移除 selectedTile 响应式变量,优化手牌显示逻辑
- 创建 sortedVisibleHandTiles 计算属性替代原 visibleHandTiles
- 添加 normalizeMeldType 和 normalizeMelds 函数处理副露数据标准化
- 在 PlayerState 中新增 handCount 和 hasHu 属性
- 更新房间玩家数据结构,同步处理手牌计数和胡牌状态
- 重构牌墙显示逻辑,实现动态渲染各座位手牌和副露
- 添加胡牌标识显示功能,改进牌面分组展示效果
- 优化 CSS 样式,调整牌墙布局和间距设置
This commit is contained in:
2026-03-27 16:37:10 +08:00
parent dc09c7e487
commit 7289635340
4 changed files with 368 additions and 79 deletions

View File

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

View File

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

View File

@@ -10,12 +10,14 @@ export interface PlayerState {
// 手牌(只有自己有完整数据,后端可控制)
handTiles: Tile[]
handCount: number
// 副露(碰/杠)
melds: MeldState[]
// 出牌区
discardTiles: Tile[]
hasHu: boolean
// 分数
score: number

View File

@@ -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<TType extends GameAction['type']> = Extract<GameAction, { type: TType }>['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<WsStatus>('idle')
const wsMessages = ref<string[]>([])
const wsError = ref('')
const selectedTile = ref<string | null>(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<Record<SeatKey, string[]>>(() => {
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<Record<SeatKey, WallSeatState>>(() => {
const emptyState: Record<SeatKey, WallSeatState> = {
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<Record<SeatKey, SeatPlayerCardModel>>(() => {
@@ -860,17 +1050,6 @@ function missingSuitLabel(value: string | null | undefined): string {
return suitMap[value] ?? value
}
function getBackImage(seat: SeatKey): string {
const imageMap: Record<SeatKey, string> = {
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(() => {
<BottomPlayerCard :player="seatDecor.bottom"/>
<LeftPlayerCard :player="seatDecor.left"/>
<div class="wall wall-top">
<img v-for="key in wallBacks.top" :key="key" :src="getBackImage('top')" alt=""/>
<div v-if="wallSeats.top.tiles.length > 0 || wallSeats.top.hasHu" class="wall wall-top wall-live">
<img
v-for="(tile, index) in wallSeats.top.tiles"
:key="tile.key"
class="wall-live-tile"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.top.tiles[index - 1]?.suit !== tile.suit,
'is-exposed': tile.imageType !== 'hand',
}"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.top.hasHu" class="wall-hu-flag"></span>
</div>
<div class="wall wall-right">
<img v-for="key in wallBacks.right" :key="key" :src="getBackImage('right')" alt=""/>
<div v-if="wallSeats.right.tiles.length > 0 || wallSeats.right.hasHu" class="wall wall-right wall-live">
<img
v-for="(tile, index) in wallSeats.right.tiles"
:key="tile.key"
class="wall-live-tile"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.right.tiles[index - 1]?.suit !== tile.suit,
'is-exposed': tile.imageType !== 'hand',
}"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.right.hasHu" class="wall-hu-flag"></span>
</div>
<div class="wall wall-bottom">
<img v-for="key in wallBacks.bottom" :key="key" :src="getBackImage('bottom')" alt=""/>
<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"
/>
<span v-if="wallSeats.bottom.hasHu" class="wall-hu-flag"></span>
</div>
<div class="wall wall-left">
<img v-for="key in wallBacks.left" :key="key" :src="getBackImage('left')" alt=""/>
<div v-if="wallSeats.left.tiles.length > 0 || wallSeats.left.hasHu" class="wall wall-left wall-live">
<img
v-for="(tile, index) in wallSeats.left.tiles"
:key="tile.key"
class="wall-live-tile"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.left.tiles[index - 1]?.suit !== tile.suit,
'is-exposed': tile.imageType !== 'hand',
}"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.left.hasHu" class="wall-hu-flag"></span>
</div>
<div class="floating-status top">
@@ -1562,29 +1784,6 @@ onBeforeUnmount(() => {
<span class="ready-toggle-label">开始游戏</span>
</button>
</div>
<div class="player-hand" v-if="visibleHandTiles.length > 0">
<div
v-for="group in visibleHandTileGroups"
:key="group.suit"
class="player-hand-group"
:data-suit="group.suit"
>
<button
v-for="tile in group.tiles"
:key="tile.id"
class="tile-chip"
:class="{ selected: selectedTile === formatTile(tile) }"
type="button"
@click="selectTile(formatTile(tile))"
>
<img
class="tile-chip-image"
:src="getTileImage(tile)"
:alt="formatTile(tile)"
/>
</button>
</div>
</div>
</div>
</div>
</section>