- 实现 ChengduGamePage.vue 组件,包含完整的麻将游戏界面 - 实现 HallPage.vue 组件,支持房间列表展示、创建和加入功能 - 添加 mahjong API 接口用于房间管理操作 - 集成 store 状态管理和本地存储功能 - 实现 ChengduBottomActions 等游戏控制组件 - 添加 websocket 连接和游戏会话管理逻辑 - 实现游戏倒计时、结算等功能模块
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
import {computed} from 'vue'
|
|
import {sendWsMessage} from '../../../ws/sender'
|
|
import {formatTile} from './useChengduTableView'
|
|
import type {ClaimOptionState} from '../../../types/state'
|
|
import type {Tile} from '../../../types/tile'
|
|
import type {DisplayPlayer} from '../types'
|
|
|
|
interface UseChengduGameActionsOptions {
|
|
gameStore: {
|
|
roomId: string
|
|
phase: string
|
|
currentTurn: number
|
|
needDraw: boolean
|
|
currentRound: number
|
|
totalRounds: number
|
|
pendingClaim?: {
|
|
tile?: Tile
|
|
options: ClaimOptionState[]
|
|
}
|
|
}
|
|
roomMeta: { value: { roomId: string; ownerId: string } | null }
|
|
gamePlayers: { value: DisplayPlayer[] }
|
|
myPlayer: { value: DisplayPlayer | undefined }
|
|
session: any
|
|
}
|
|
|
|
export function useChengduGameActions(options: UseChengduGameActionsOptions) {
|
|
const isLastRound = computed(
|
|
() => options.gameStore.currentRound >= options.gameStore.totalRounds && options.gameStore.totalRounds > 0,
|
|
)
|
|
|
|
const myReadyState = computed(() => Boolean(options.myPlayer.value?.isReady))
|
|
|
|
const isRoomOwner = computed(() => {
|
|
const room = options.roomMeta.value
|
|
return Boolean(
|
|
room &&
|
|
room.roomId === options.gameStore.roomId &&
|
|
room.ownerId &&
|
|
options.session.loggedInUserId.value &&
|
|
room.ownerId === options.session.loggedInUserId.value,
|
|
)
|
|
})
|
|
|
|
const allPlayersReady = computed(
|
|
() => options.gamePlayers.value.length === 4 && options.gamePlayers.value.every((player) => Boolean(player.isReady)),
|
|
)
|
|
|
|
const hasRoundStarted = computed(() =>
|
|
options.gamePlayers.value.some(
|
|
(player) =>
|
|
player.handCount > 0 ||
|
|
player.handTiles.length > 0 ||
|
|
player.melds.length > 0 ||
|
|
player.discardTiles.length > 0,
|
|
),
|
|
)
|
|
|
|
const showStartGameButton = computed(
|
|
() => options.gameStore.phase === 'waiting' && allPlayersReady.value && !hasRoundStarted.value,
|
|
)
|
|
const showWaitingOwnerTip = computed(() => showStartGameButton.value && !isRoomOwner.value)
|
|
const canStartGame = computed(
|
|
() => showStartGameButton.value && isRoomOwner.value && !options.session.startGamePending.value,
|
|
)
|
|
|
|
const showReadyToggle = computed(() => {
|
|
if (options.gameStore.phase !== 'waiting' || !options.gameStore.roomId || hasRoundStarted.value) {
|
|
return false
|
|
}
|
|
if (showStartGameButton.value) {
|
|
return !isRoomOwner.value
|
|
}
|
|
return true
|
|
})
|
|
|
|
const showDingQueChooser = computed(() => {
|
|
const player = options.myPlayer.value
|
|
if (!player || options.gameStore.phase === 'settlement') {
|
|
return false
|
|
}
|
|
return player.handTiles.length > 0 && !player.missingSuit
|
|
})
|
|
|
|
const selectedDiscardTile = computed(() => {
|
|
const player = options.myPlayer.value
|
|
if (!player || options.session.selectedDiscardTileId.value === null) {
|
|
return null
|
|
}
|
|
return player.handTiles.find((tile) => tile.id === options.session.selectedDiscardTileId.value) ?? null
|
|
})
|
|
|
|
const hasMissingSuitTiles = computed(() => {
|
|
const player = options.myPlayer.value
|
|
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
|
|
if (!player || !missingSuit) {
|
|
return false
|
|
}
|
|
return player.handTiles.some((tile) => tile.suit === missingSuit)
|
|
})
|
|
|
|
function missingSuitLabel(value: string | null | undefined): string {
|
|
const suitMap: Record<string, string> = {
|
|
w: '万',
|
|
t: '筒',
|
|
b: '条',
|
|
wan: '万',
|
|
tong: '筒',
|
|
tiao: '条',
|
|
}
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
return suitMap[value.trim().toLowerCase()] ?? value
|
|
}
|
|
|
|
const discardBlockedReason = computed(() => {
|
|
const player = options.myPlayer.value
|
|
if (!player || !options.gameStore.roomId) {
|
|
return '未进入房间'
|
|
}
|
|
if (options.session.wsStatus.value !== 'connected') {
|
|
return 'WebSocket 未连接'
|
|
}
|
|
if (showDingQueChooser.value) {
|
|
return '请先完成定缺'
|
|
}
|
|
if (options.gameStore.phase !== 'playing') {
|
|
return '当前不是出牌阶段'
|
|
}
|
|
if (player.seatIndex !== options.gameStore.currentTurn) {
|
|
return '未轮到你出牌'
|
|
}
|
|
if (options.gameStore.needDraw) {
|
|
return '请先摸牌'
|
|
}
|
|
if (options.gameStore.pendingClaim) {
|
|
return '等待当前操作结算'
|
|
}
|
|
if (player.handTiles.length === 0) {
|
|
return '当前没有可出的手牌'
|
|
}
|
|
if (options.session.discardPending.value) {
|
|
return '正在提交出牌'
|
|
}
|
|
return ''
|
|
})
|
|
|
|
function discardTileBlockedReason(tile: Tile): string {
|
|
if (discardBlockedReason.value) {
|
|
return discardBlockedReason.value
|
|
}
|
|
|
|
const player = options.myPlayer.value
|
|
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
|
|
if (player && missingSuit && hasMissingSuitTiles.value && tile.suit !== missingSuit) {
|
|
return `当前必须先打${missingSuitLabel(missingSuit)}牌`
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
const canConfirmDiscard = computed(() => {
|
|
const tile = selectedDiscardTile.value
|
|
return Boolean(tile && !discardTileBlockedReason(tile))
|
|
})
|
|
|
|
const confirmDiscardLabel = computed(() => {
|
|
const tile = selectedDiscardTile.value
|
|
return tile ? `出牌 ${formatTile(tile)}` : '出牌'
|
|
})
|
|
|
|
const canDrawTile = computed(() => {
|
|
const player = options.myPlayer.value
|
|
if (!player || !options.gameStore.roomId) {
|
|
return false
|
|
}
|
|
return (
|
|
options.gameStore.phase === 'playing' &&
|
|
options.gameStore.needDraw &&
|
|
player.seatIndex === options.gameStore.currentTurn
|
|
)
|
|
})
|
|
|
|
const myClaimState = computed(() => options.gameStore.pendingClaim)
|
|
const visibleClaimOptions = computed<ClaimOptionState[]>(() => {
|
|
const current = myClaimState.value?.options ?? []
|
|
const order: ClaimOptionState[] = ['hu', 'gang', 'peng', 'pass']
|
|
return order.filter((option) => current.includes(option))
|
|
})
|
|
const showClaimActions = computed(() => visibleClaimOptions.value.length > 0)
|
|
|
|
const canSelfHu = computed(() => {
|
|
const player = options.myPlayer.value
|
|
if (!player || !options.gameStore.roomId || options.session.wsStatus.value !== 'connected') {
|
|
return false
|
|
}
|
|
if (
|
|
showDingQueChooser.value ||
|
|
options.gameStore.phase !== 'playing' ||
|
|
options.gameStore.needDraw ||
|
|
options.gameStore.pendingClaim
|
|
) {
|
|
return false
|
|
}
|
|
if (player.seatIndex !== options.gameStore.currentTurn || options.session.turnActionPending.value) {
|
|
return false
|
|
}
|
|
return options.session.selfTurnAllowActions.value.includes('hu')
|
|
})
|
|
|
|
const canSelfGang = computed(() => {
|
|
const player = options.myPlayer.value
|
|
if (!player || !options.gameStore.roomId || options.session.wsStatus.value !== 'connected') {
|
|
return false
|
|
}
|
|
if (
|
|
showDingQueChooser.value ||
|
|
options.gameStore.phase !== 'playing' ||
|
|
options.gameStore.needDraw ||
|
|
options.gameStore.pendingClaim
|
|
) {
|
|
return false
|
|
}
|
|
if (player.seatIndex !== options.gameStore.currentTurn || options.session.turnActionPending.value) {
|
|
return false
|
|
}
|
|
return options.session.selfTurnAllowActions.value.includes('gang')
|
|
})
|
|
|
|
function toggleReadyState(): void {
|
|
if (options.session.readyTogglePending.value) {
|
|
return
|
|
}
|
|
const nextReady = !myReadyState.value
|
|
options.session.readyTogglePending.value = true
|
|
sendWsMessage({
|
|
type: 'set_ready',
|
|
roomId: options.gameStore.roomId,
|
|
payload: {
|
|
ready: nextReady,
|
|
isReady: nextReady,
|
|
},
|
|
})
|
|
}
|
|
|
|
function startGame(): void {
|
|
if (!canStartGame.value) {
|
|
return
|
|
}
|
|
options.session.startGamePending.value = true
|
|
sendWsMessage({
|
|
type: 'start_game',
|
|
roomId: options.gameStore.roomId,
|
|
payload: {
|
|
room_id: options.gameStore.roomId,
|
|
},
|
|
})
|
|
}
|
|
|
|
function nextRound(): void {
|
|
if (
|
|
options.session.nextRoundPending.value ||
|
|
!options.gameStore.roomId ||
|
|
options.gameStore.phase !== 'settlement'
|
|
) {
|
|
return
|
|
}
|
|
options.session.settlementOverlayDismissed.value = true
|
|
options.session.nextRoundPending.value = true
|
|
sendWsMessage({
|
|
type: 'next_round',
|
|
roomId: options.gameStore.roomId,
|
|
payload: {},
|
|
})
|
|
}
|
|
|
|
function chooseDingQue(suit: Tile['suit']): void {
|
|
if (options.session.dingQuePending.value || !showDingQueChooser.value) {
|
|
return
|
|
}
|
|
options.session.dingQuePending.value = true
|
|
sendWsMessage({
|
|
type: 'ding_que',
|
|
roomId: options.gameStore.roomId,
|
|
payload: {suit},
|
|
})
|
|
}
|
|
|
|
function selectDiscardTile(tile: Tile): void {
|
|
const blockedReason = discardTileBlockedReason(tile)
|
|
if (blockedReason) {
|
|
options.session.wsError.value = blockedReason
|
|
options.session.wsMessages.value.push(`[client-blocked] select ${formatTile(tile)}: ${blockedReason}`)
|
|
options.session.selectedDiscardTileId.value = null
|
|
return
|
|
}
|
|
|
|
options.session.wsError.value = ''
|
|
options.session.selectedDiscardTileId.value =
|
|
options.session.selectedDiscardTileId.value === tile.id ? null : tile.id
|
|
}
|
|
|
|
function confirmDiscard(): void {
|
|
const tile = selectedDiscardTile.value
|
|
if (!tile) {
|
|
return
|
|
}
|
|
|
|
const blockedReason = discardTileBlockedReason(tile)
|
|
if (blockedReason || !options.gameStore.roomId) {
|
|
if (blockedReason) {
|
|
options.session.wsError.value = blockedReason
|
|
options.session.wsMessages.value.push(`[client-blocked] discard ${formatTile(tile)}: ${blockedReason}`)
|
|
}
|
|
return
|
|
}
|
|
|
|
options.session.wsError.value = ''
|
|
options.session.markDiscardPendingWithFallback()
|
|
sendWsMessage({
|
|
type: 'discard',
|
|
roomId: options.gameStore.roomId,
|
|
payload: {
|
|
tile: {
|
|
id: tile.id,
|
|
suit: tile.suit,
|
|
value: tile.value,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
function drawTile(): void {
|
|
if (!canDrawTile.value) {
|
|
return
|
|
}
|
|
sendWsMessage({
|
|
type: 'draw',
|
|
roomId: options.gameStore.roomId,
|
|
payload: {
|
|
room_id: options.gameStore.roomId,
|
|
},
|
|
})
|
|
}
|
|
|
|
function submitSelfGang(): void {
|
|
if (!options.gameStore.roomId || !canSelfGang.value || options.session.turnActionPending.value) {
|
|
return
|
|
}
|
|
options.session.markTurnActionPending('gang')
|
|
sendWsMessage({
|
|
type: 'gang',
|
|
roomId: options.gameStore.roomId,
|
|
payload: {
|
|
room_id: options.gameStore.roomId,
|
|
},
|
|
})
|
|
}
|
|
|
|
function submitSelfHu(): void {
|
|
if (!options.gameStore.roomId || !canSelfHu.value || options.session.turnActionPending.value) {
|
|
return
|
|
}
|
|
options.session.markTurnActionPending('hu')
|
|
sendWsMessage({
|
|
type: 'hu',
|
|
roomId: options.gameStore.roomId,
|
|
payload: {
|
|
room_id: options.gameStore.roomId,
|
|
},
|
|
})
|
|
}
|
|
|
|
function submitClaim(action: ClaimOptionState): void {
|
|
if (
|
|
options.session.claimActionPending.value ||
|
|
!options.gameStore.roomId ||
|
|
!visibleClaimOptions.value.includes(action)
|
|
) {
|
|
return
|
|
}
|
|
const claimTile = options.gameStore.pendingClaim?.tile
|
|
options.session.claimActionPending.value = true
|
|
sendWsMessage({
|
|
type: action,
|
|
roomId: options.gameStore.roomId,
|
|
payload: {
|
|
room_id: options.gameStore.roomId,
|
|
...(action !== 'pass' && claimTile
|
|
? {
|
|
tile: {
|
|
id: claimTile.id,
|
|
suit: claimTile.suit,
|
|
value: claimTile.value,
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
})
|
|
}
|
|
|
|
return {
|
|
isLastRound,
|
|
myReadyState,
|
|
isRoomOwner,
|
|
showStartGameButton,
|
|
showWaitingOwnerTip,
|
|
canStartGame,
|
|
showReadyToggle,
|
|
showDingQueChooser,
|
|
selectedDiscardTile,
|
|
discardBlockedReason,
|
|
discardTileBlockedReason,
|
|
canConfirmDiscard,
|
|
confirmDiscardLabel,
|
|
canDrawTile,
|
|
visibleClaimOptions,
|
|
showClaimActions,
|
|
canSelfHu,
|
|
canSelfGang,
|
|
toggleReadyState,
|
|
startGame,
|
|
nextRound,
|
|
chooseDingQue,
|
|
selectDiscardTile,
|
|
confirmDiscard,
|
|
drawTile,
|
|
submitSelfGang,
|
|
submitSelfHu,
|
|
submitClaim,
|
|
}
|
|
}
|