Files
mahjong-web/src/views/chengdu/composables/useChengduGameActions.ts
wsy182 e96c45739e feat(game): 添加成都麻将游戏页面和大厅功能
- 实现 ChengduGamePage.vue 组件,包含完整的麻将游戏界面
- 实现 HallPage.vue 组件,支持房间列表展示、创建和加入功能
- 添加 mahjong API 接口用于房间管理操作
- 集成 store 状态管理和本地存储功能
- 实现 ChengduBottomActions 等游戏控制组件
- 添加 websocket 连接和游戏会话管理逻辑
- 实现游戏倒计时、结算等功能模块
2026-04-03 20:46:50 +08:00

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