feat(game): 添加成都麻将游戏页面和大厅功能
- 实现 ChengduGamePage.vue 组件,包含完整的麻将游戏界面 - 实现 HallPage.vue 组件,支持房间列表展示、创建和加入功能 - 添加 mahjong API 接口用于房间管理操作 - 集成 store 状态管理和本地存储功能 - 实现 ChengduBottomActions 等游戏控制组件 - 添加 websocket 连接和游戏会话管理逻辑 - 实现游戏倒计时、结算等功能模块
This commit is contained in:
430
src/views/chengdu/composables/useChengduTableView.ts
Normal file
430
src/views/chengdu/composables/useChengduTableView.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { computed } from 'vue'
|
||||
import eastWind from '../../../assets/images/direction/dong.png'
|
||||
import southWind from '../../../assets/images/direction/nan.png'
|
||||
import westWind from '../../../assets/images/direction/xi.png'
|
||||
import northWind from '../../../assets/images/direction/bei.png'
|
||||
import type { SeatPlayerCardModel } from '../../../components/game/seat-player-card'
|
||||
import { getTileImage as getBottomTileImage } from '../../../config/bottomTileMap'
|
||||
import { getTileImage as getLeftTileImage } from '../../../config/leftTileMap'
|
||||
import { getTileImage as getRightTileImage } from '../../../config/rightTileMap'
|
||||
import { getTileImage as getTopTileImage } from '../../../config/topTileMap'
|
||||
import type { SeatKey } from '../../../game/seat'
|
||||
import type { Tile } from '../../../types/tile'
|
||||
import type {
|
||||
DeskSeatState,
|
||||
DisplayPlayer,
|
||||
HandSuitLabel,
|
||||
TableTileImageType,
|
||||
TableViewDeps,
|
||||
TableViewResult,
|
||||
WallSeatState,
|
||||
WallTileItem,
|
||||
} from '../types'
|
||||
|
||||
const handSuitOrder: Record<Tile['suit'], number> = { W: 0, T: 1, B: 2 }
|
||||
const handSuitLabelMap: Record<Tile['suit'], HandSuitLabel> = { W: '万', T: '筒', B: '条' }
|
||||
|
||||
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:
|
||||
return tile ? getBottomTileImage(tile, imageType, 'bottom') : ''
|
||||
}
|
||||
}
|
||||
|
||||
export function missingSuitLabel(value: string | null | undefined): string {
|
||||
const suitMap: Record<string, string> = {
|
||||
w: '万',
|
||||
t: '筒',
|
||||
b: '条',
|
||||
wan: '万',
|
||||
tong: '筒',
|
||||
tiao: '条',
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase()
|
||||
return suitMap[normalized] ?? value
|
||||
}
|
||||
|
||||
export function formatTile(tile: Tile): string {
|
||||
return `${tile.suit}${tile.value}`
|
||||
}
|
||||
|
||||
function emptyWallSeat(): WallSeatState {
|
||||
return { tiles: [] }
|
||||
}
|
||||
|
||||
function emptyDeskSeat(): DeskSeatState {
|
||||
return { tiles: [], hasHu: false }
|
||||
}
|
||||
|
||||
export function useChengduTableView(deps: TableViewDeps): TableViewResult {
|
||||
const visibleHandTileGroups = computed(() => {
|
||||
const grouped = new Map<HandSuitLabel, Tile[]>()
|
||||
|
||||
deps.myHandTiles.value
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const suitDiff = handSuitOrder[left.suit] - handSuitOrder[right.suit]
|
||||
if (suitDiff !== 0) {
|
||||
return suitDiff
|
||||
}
|
||||
|
||||
const valueDiff = left.value - right.value
|
||||
if (valueDiff !== 0) {
|
||||
return valueDiff
|
||||
}
|
||||
|
||||
return left.id - right.id
|
||||
})
|
||||
.forEach((tile) => {
|
||||
const label = handSuitLabelMap[tile.suit]
|
||||
const current = grouped.get(label) ?? []
|
||||
current.push(tile)
|
||||
grouped.set(label, current)
|
||||
})
|
||||
|
||||
return (['万', '筒', '条'] as HandSuitLabel[])
|
||||
.map((suit) => ({
|
||||
suit,
|
||||
tiles: grouped.get(suit) ?? [],
|
||||
}))
|
||||
.filter((group) => group.tiles.length > 0)
|
||||
})
|
||||
|
||||
const sortedVisibleHandTiles = computed(() => visibleHandTileGroups.value.flatMap((group) => group.tiles))
|
||||
|
||||
const roomName = computed(() => {
|
||||
const activeRoomName =
|
||||
deps.roomMeta.value && deps.roomMeta.value.roomId === deps.gameStore.roomId
|
||||
? deps.roomMeta.value.roomName
|
||||
: ''
|
||||
return deps.routeRoomName.value || activeRoomName || `房间 ${deps.gameStore.roomId || '--'}`
|
||||
})
|
||||
|
||||
const roomState = computed(() => {
|
||||
const status =
|
||||
deps.gameStore.phase === 'waiting'
|
||||
? 'waiting'
|
||||
: deps.gameStore.phase === 'settlement'
|
||||
? 'finished'
|
||||
: 'playing'
|
||||
const wall = Array.from({ length: deps.gameStore.remainingTiles }, (_, index) => `wall-${index}`)
|
||||
const maxPlayers =
|
||||
deps.roomMeta.value && deps.roomMeta.value.roomId === deps.gameStore.roomId
|
||||
? deps.roomMeta.value.maxPlayers
|
||||
: 4
|
||||
|
||||
return {
|
||||
roomId: deps.gameStore.roomId,
|
||||
name: roomName.value,
|
||||
playerCount: deps.gamePlayers.value.length,
|
||||
maxPlayers,
|
||||
status,
|
||||
game: {
|
||||
state: {
|
||||
wall,
|
||||
dealerIndex: deps.gameStore.dealerIndex,
|
||||
currentTurn: deps.gameStore.currentTurn,
|
||||
phase: deps.gameStore.phase,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const seatViews = computed(() => {
|
||||
const players = deps.gamePlayers.value
|
||||
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
|
||||
const selfSeatIndex =
|
||||
deps.myPlayer.value?.seatIndex ??
|
||||
players.find((player) => player.playerId === deps.loggedInUserId.value)?.seatIndex ??
|
||||
0
|
||||
|
||||
return players.slice(0, 4).map((player) => {
|
||||
const relativeIndex = (selfSeatIndex - player.seatIndex + 4) % 4
|
||||
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
||||
return {
|
||||
key: seatKey,
|
||||
player,
|
||||
isSelf: player.playerId === deps.loggedInUserId.value,
|
||||
isTurn: player.seatIndex === deps.gameStore.currentTurn,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const seatWinds = computed<Record<SeatKey, string>>(() => {
|
||||
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
|
||||
const players = deps.gamePlayers.value
|
||||
const selfSeatIndex =
|
||||
deps.myPlayer.value?.seatIndex ??
|
||||
players.find((player) => player.playerId === deps.loggedInUserId.value)?.seatIndex ??
|
||||
0
|
||||
|
||||
const directionBySeatIndex = [eastWind, southWind, westWind, northWind]
|
||||
const result: Record<SeatKey, string> = {
|
||||
top: northWind,
|
||||
right: eastWind,
|
||||
bottom: southWind,
|
||||
left: westWind,
|
||||
}
|
||||
|
||||
for (let absoluteSeat = 0; absoluteSeat < 4; absoluteSeat += 1) {
|
||||
const relativeIndex = (selfSeatIndex - absoluteSeat + 4) % 4
|
||||
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
||||
result[seatKey] = directionBySeatIndex[absoluteSeat] ?? northWind
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const currentTurnSeat = computed<SeatKey | ''>(() => seatViews.value.find((seat) => seat.isTurn)?.key ?? '')
|
||||
|
||||
const currentPhaseText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
waiting: '等待中',
|
||||
dealing: '发牌中',
|
||||
playing: '对局中',
|
||||
action: '操作中',
|
||||
settlement: '已结算',
|
||||
}
|
||||
return map[deps.gameStore.phase] ?? deps.gameStore.phase
|
||||
})
|
||||
|
||||
const roomStatusText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
waiting: '等待玩家',
|
||||
playing: '游戏中',
|
||||
finished: '已结束',
|
||||
}
|
||||
const status = roomState.value.status
|
||||
return map[status] ?? status ?? '--'
|
||||
})
|
||||
|
||||
const roundText = computed(() => {
|
||||
if (deps.gameStore.totalRounds > 0) {
|
||||
return `${deps.gameStore.currentRound}/${deps.gameStore.totalRounds}`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const settlementPlayers = computed(() => {
|
||||
const winnerSet = new Set(deps.gameStore.winners)
|
||||
return Object.values(deps.gameStore.players as Record<string, DisplayPlayer>)
|
||||
.map((player) => ({
|
||||
playerId: player.playerId,
|
||||
displayName: player.displayName || `玩家${player.seatIndex + 1}`,
|
||||
score: deps.gameStore.scores[player.playerId] ?? 0,
|
||||
isWinner: winnerSet.has(player.playerId),
|
||||
seatIndex: player.seatIndex,
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
})
|
||||
|
||||
const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
|
||||
const emptyState: Record<SeatKey, WallSeatState> = {
|
||||
top: emptyWallSeat(),
|
||||
right: emptyWallSeat(),
|
||||
bottom: emptyWallSeat(),
|
||||
left: emptyWallSeat(),
|
||||
}
|
||||
|
||||
if (deps.gameStore.phase === 'waiting' && deps.myHandTiles.value.length === 0) {
|
||||
return emptyState
|
||||
}
|
||||
|
||||
for (const seat of seatViews.value) {
|
||||
if (!seat.player) {
|
||||
continue
|
||||
}
|
||||
|
||||
const seatTiles: WallTileItem[] = []
|
||||
const targetSeat = seat.key
|
||||
|
||||
if (seat.isSelf) {
|
||||
const missingSuit = seat.player.missingSuit as Tile['suit'] | null | undefined
|
||||
sortedVisibleHandTiles.value.forEach((tile, index) => {
|
||||
const src = buildWallTileImage(targetSeat, tile, 'hand')
|
||||
if (!src) {
|
||||
return
|
||||
}
|
||||
const previousTile = index > 0 ? sortedVisibleHandTiles.value[index - 1] : undefined
|
||||
const isMissingSuitGroupStart = Boolean(
|
||||
missingSuit &&
|
||||
tile.suit === missingSuit &&
|
||||
(!previousTile || previousTile.suit !== tile.suit),
|
||||
)
|
||||
|
||||
seatTiles.push({
|
||||
key: `hand-${tile.id}-${index}`,
|
||||
src,
|
||||
alt: formatTile(tile),
|
||||
imageType: 'hand',
|
||||
showLackTag: isMissingSuitGroupStart,
|
||||
suit: tile.suit,
|
||||
tile,
|
||||
})
|
||||
})
|
||||
} 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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emptyState[targetSeat] = { tiles: seatTiles }
|
||||
}
|
||||
|
||||
return emptyState
|
||||
})
|
||||
|
||||
const deskSeats = computed<Record<SeatKey, DeskSeatState>>(() => {
|
||||
const emptyState: Record<SeatKey, DeskSeatState> = {
|
||||
top: emptyDeskSeat(),
|
||||
right: emptyDeskSeat(),
|
||||
bottom: emptyDeskSeat(),
|
||||
left: emptyDeskSeat(),
|
||||
}
|
||||
|
||||
if (deps.gameStore.phase === 'waiting' && deps.myHandTiles.value.length === 0) {
|
||||
return emptyState
|
||||
}
|
||||
|
||||
for (const seat of seatViews.value) {
|
||||
if (!seat.player) {
|
||||
continue
|
||||
}
|
||||
|
||||
const seatTiles: WallTileItem[] = []
|
||||
const targetSeat = seat.key
|
||||
|
||||
seat.player.discardTiles.forEach((tile, index) => {
|
||||
const src = buildWallTileImage(targetSeat, tile, 'exposed')
|
||||
if (!src) {
|
||||
return
|
||||
}
|
||||
|
||||
seatTiles.push({
|
||||
key: `discard-${tile.id}-${index}`,
|
||||
src,
|
||||
alt: formatTile(tile),
|
||||
imageType: 'exposed',
|
||||
suit: tile.suit,
|
||||
})
|
||||
})
|
||||
|
||||
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: `desk-${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`,
|
||||
src,
|
||||
alt: formatTile(tile),
|
||||
imageType,
|
||||
isGroupStart: tileIndex === 0,
|
||||
suit: tile.suit,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
emptyState[targetSeat] = {
|
||||
tiles: seatTiles,
|
||||
hasHu: seat.player.hasHu,
|
||||
}
|
||||
}
|
||||
|
||||
return emptyState
|
||||
})
|
||||
|
||||
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
||||
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
|
||||
const defaultMissingSuitLabel = missingSuitLabel(null)
|
||||
|
||||
const emptySeat = (): SeatPlayerCardModel => ({
|
||||
avatarUrl: '',
|
||||
name: '空位',
|
||||
dealer: false,
|
||||
isTurn: false,
|
||||
isReady: false,
|
||||
isTrustee: false,
|
||||
missingSuitLabel: defaultMissingSuitLabel,
|
||||
})
|
||||
|
||||
const result: Record<SeatKey, SeatPlayerCardModel> = {
|
||||
top: emptySeat(),
|
||||
right: emptySeat(),
|
||||
bottom: emptySeat(),
|
||||
left: emptySeat(),
|
||||
}
|
||||
|
||||
for (const seat of seatViews.value) {
|
||||
if (!seat.player) {
|
||||
continue
|
||||
}
|
||||
|
||||
const displayName = seat.player.displayName || `玩家${seat.player.seatIndex + 1}`
|
||||
const avatarUrl = seat.isSelf
|
||||
? deps.localCachedAvatarUrl.value || seat.player.avatarURL || ''
|
||||
: seat.player.avatarURL || ''
|
||||
const selfDisplayName = seat.player.displayName || deps.loggedInUserName.value || '你自己'
|
||||
|
||||
result[seat.key] = {
|
||||
avatarUrl,
|
||||
name: Array.from(seat.isSelf ? selfDisplayName : displayName)
|
||||
.slice(0, 4)
|
||||
.join(''),
|
||||
dealer: seat.player.seatIndex === dealerIndex,
|
||||
isTurn: seat.isTurn,
|
||||
isReady: Boolean(seat.player.isReady),
|
||||
isTrustee: Boolean(seat.player.isTrustee),
|
||||
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return {
|
||||
roomName,
|
||||
roomState,
|
||||
seatViews,
|
||||
seatWinds,
|
||||
currentTurnSeat,
|
||||
currentPhaseText,
|
||||
roomStatusText,
|
||||
roundText,
|
||||
visibleHandTileGroups,
|
||||
sortedVisibleHandTiles,
|
||||
wallSeats,
|
||||
deskSeats,
|
||||
seatDecor,
|
||||
settlementPlayers,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user