This commit is contained in:
2026-03-24 15:25:40 +08:00
parent 292a4181ce
commit 58fe43607a
7 changed files with 757 additions and 9 deletions

View File

@@ -29,6 +29,17 @@ interface ActionEventLike {
data?: unknown
}
interface PendingClaimOption {
playerId: string
actions: string[]
}
export interface ActionButtonState {
type: 'draw' | 'discard' | 'peng' | 'gang' | 'hu' | 'pass'
label: string
disabled: boolean
}
export interface SeatView {
key: SeatKey
player: RoomPlayerState | null
@@ -38,6 +49,19 @@ export interface SeatView {
subLabel: string
}
function humanizeSuit(value: string): string {
const suitMap: Record<string, string> = {
W: '万',
B: '筒',
T: '条',
wan: '万',
tong: '筒',
tiao: '条',
}
return suitMap[value] ?? value
}
export interface ChengduGameRoomModel {
auth: Ref<StoredAuth | null>
roomState: typeof activeRoomState
@@ -52,8 +76,12 @@ export interface ChengduGameRoomModel {
leaveRoomPending: Ref<boolean>
canStartGame: ComputedRef<boolean>
seatViews: ComputedRef<SeatView[]>
selectedTile: Ref<string | null>
actionButtons: ComputedRef<ActionButtonState[]>
connectWs: () => Promise<void>
sendStartGame: () => void
selectTile: (tile: string) => void
sendGameAction: (type: ActionButtonState['type']) => void
backHall: () => void
}
@@ -69,10 +97,12 @@ export function useChengduGameRoom(
const wsError = ref('')
const wsMessages = ref<string[]>([])
const startGamePending = ref(false)
const actionPending = ref(false)
const lastStartRequestId = ref('')
const leaveRoomPending = ref(false)
const lastLeaveRoomRequestId = ref('')
const leaveHallAfterAck = ref(false)
const selectedTile = ref<string | null>(null)
const roomId = computed(() => {
return typeof route.params.roomId === 'string' ? route.params.roomId : ''
@@ -121,6 +151,64 @@ export function useChengduGameRoom(
)
})
const myPlayer = computed(() => {
return roomState.value.players.find((player) => player.playerId === currentUserId.value) ?? null
})
const isMyTurn = computed(() => {
return myPlayer.value?.index === roomState.value.currentTurnIndex
})
const pendingClaimOptions = computed<PendingClaimOption[]>(() => {
return normalizePendingClaimOptions(roomState.value.game?.state?.pendingClaim)
})
const myClaimActions = computed(() => {
const claim = pendingClaimOptions.value.find((item) => item.playerId === currentUserId.value)
return new Set(claim?.actions ?? [])
})
const canDraw = computed(() => {
return roomState.value.status === 'playing' && isMyTurn.value && Boolean(roomState.value.game?.state?.needDraw)
})
const canDiscard = computed(() => {
return (
roomState.value.status === 'playing' &&
isMyTurn.value &&
!roomState.value.game?.state?.needDraw &&
roomState.value.myHand.length > 0 &&
Boolean(selectedTile.value)
)
})
const actionButtons = computed<ActionButtonState[]>(() => {
return [
{ type: 'draw', label: '摸牌', disabled: !canDraw.value || actionPending.value },
{ type: 'discard', label: '出牌', disabled: !canDiscard.value || actionPending.value },
{
type: 'peng',
label: '碰',
disabled: !myClaimActions.value.has('peng') || actionPending.value,
},
{
type: 'gang',
label: '杠',
disabled: !myClaimActions.value.has('gang') || actionPending.value,
},
{
type: 'hu',
label: '胡',
disabled: !myClaimActions.value.has('hu') || actionPending.value,
},
{
type: 'pass',
label: '过',
disabled: !myClaimActions.value.has('pass') || actionPending.value,
},
]
})
const seatViews = computed<SeatView[]>(() => {
const seats: Record<SeatKey, RoomPlayerState | null> = {
top: null,
@@ -165,7 +253,7 @@ export function useChengduGameRoom(
player,
isSelf,
isTurn: turnSeat === seat,
label: player ? (isSelf ? '你' : player.playerId) : '空位',
label: player ? (isSelf ? '你' : player.displayName || `玩家${player.index + 1}`) : '空位',
subLabel: player ? `座位 ${player.index}` : '',
}
})
@@ -230,6 +318,29 @@ export function useChengduGameRoom(
return ''
}
function normalizeActionName(value: unknown): string {
const raw = toStringOrEmpty(value).trim().toLowerCase()
if (!raw) {
return ''
}
const actionMap: Record<string, string> = {
candraw: 'draw',
draw: 'draw',
candiscard: 'discard',
discard: 'discard',
canpeng: 'peng',
peng: 'peng',
cangang: 'gang',
gang: 'gang',
canhu: 'hu',
hu: 'hu',
canpass: 'pass',
pass: 'pass',
}
return actionMap[raw.replace(/[_\-\s]/g, '')] ?? raw
}
function toSession(source: NonNullable<typeof auth.value>): AuthSession {
return {
token: source.token,
@@ -361,19 +472,198 @@ export function useChengduGameRoom(
return null
}
const playerId = toStringOrEmpty(player.playerId ?? player.player_id ?? player.user_id ?? player.id)
const playerId = toStringOrEmpty(
player.playerId ??
player.player_id ??
player.PlayerID ??
player.UserID ??
player.user_id ??
player.id,
)
if (!playerId) {
return null
}
const seatIndex = toFiniteNumber(player.index ?? player.seat ?? player.position ?? player.player_index)
const seatIndex = toFiniteNumber(
player.index ??
player.Index ??
player.seat ??
player.Seat ??
player.position ??
player.Position ??
player.player_index,
)
return {
index: seatIndex ?? fallbackIndex,
playerId,
displayName:
toStringOrEmpty(
player.playerName ??
player.player_name ??
player.PlayerName ??
player.username ??
player.nickname,
) || undefined,
ready: Boolean(player.ready),
handCount:
toFiniteNumber(player.handCount ?? player.hand_count ?? player.HandCount) ?? undefined,
melds: normalizeTileList(player.melds ?? player.Melds),
outTiles: normalizeTileList(player.outTiles ?? player.out_tiles ?? player.OutTiles),
hasHu: toBoolean(player.hasHu ?? player.has_hu ?? player.HasHu),
missingSuit:
toStringOrEmpty(player.missingSuit ?? player.missing_suit ?? player.MissingSuit) || null,
}
}
function normalizeTileList(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value
.flatMap((item) => {
if (Array.isArray(item)) {
return item.map((nested) => toStringOrEmpty(nested)).filter(Boolean)
}
const record = toRecord(item)
if (record) {
const explicit =
toStringOrEmpty(
record.tile ?? record.Tile ?? record.code ?? record.Code ?? record.name ?? record.Name,
) || ''
if (explicit) {
return [explicit]
}
const suit = toStringOrEmpty(record.suit ?? record.Suit)
const tileValue = toStringOrEmpty(record.value ?? record.Value)
if (suit && tileValue) {
return [`${humanizeSuit(suit)}${tileValue}`]
}
return []
}
return [toStringOrEmpty(item)].filter(Boolean)
})
.filter(Boolean)
}
function normalizePublicGameState(source: Record<string, unknown>): GameState | null {
const publicPlayers = (Array.isArray(source.players) ? source.players : [])
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
.sort((a, b) => a.index - b.index)
const currentTurnPlayerId = toStringOrEmpty(
source.current_turn_player ?? source.currentTurnPlayer ?? source.current_turn_player_id,
)
const currentTurnIndex =
publicPlayers.find((player) => player.playerId === currentTurnPlayerId)?.index ?? null
return {
rule: null,
state: {
phase: toStringOrEmpty(source.phase),
dealerIndex: 0,
currentTurn: currentTurnIndex ?? 0,
needDraw: toBoolean(source.need_draw ?? source.needDraw),
players: publicPlayers.map((player) => ({
playerId: player.playerId,
index: player.index,
ready: player.ready,
})),
wall: Array.from({
length: toFiniteNumber(source.wall_count ?? source.wallCount) ?? 0,
}).map((_, index) => `wall-${index}`),
lastDiscardTile:
toStringOrEmpty(source.last_discard_tile ?? source.lastDiscardTile) || null,
lastDiscardBy: toStringOrEmpty(source.last_discard_by ?? source.lastDiscardBy),
pendingClaim: toRecord(source.pending_claim ?? source.pendingClaim),
winners: Array.isArray(source.winners)
? source.winners.map((item) => toStringOrEmpty(item)).filter(Boolean)
: [],
scores: normalizeScores(source.scores),
lastDrawPlayerId: '',
lastDrawFromGang: false,
lastDrawIsLastTile: false,
huWay: '',
},
}
}
function normalizePendingClaimOptions(value: unknown): PendingClaimOption[] {
const pendingClaim = toRecord(value)
if (!pendingClaim) {
return []
}
const rawOptions =
(Array.isArray(pendingClaim.options) ? pendingClaim.options : null) ??
(Array.isArray(pendingClaim.Options) ? pendingClaim.Options : null) ??
[]
const optionsFromArray = rawOptions
.map((option) => {
const record = toRecord(option)
if (!record) {
return null
}
const playerId = toStringOrEmpty(
record.playerId ?? record.player_id ?? record.PlayerID ?? record.user_id ?? record.UserID,
)
if (!playerId) {
return null
}
const actions = new Set<string>()
for (const value of Object.values(record)) {
if (typeof value === 'boolean' && value) {
continue
}
}
for (const [key, enabled] of Object.entries(record)) {
if (typeof enabled === 'boolean' && enabled) {
const normalized = normalizeActionName(key)
if (normalized && normalized !== 'playerid' && normalized !== 'userid') {
actions.add(normalized)
}
}
}
if (Array.isArray(record.actions)) {
for (const action of record.actions) {
const normalized = normalizeActionName(action)
if (normalized) {
actions.add(normalized)
}
}
}
return { playerId, actions: [...actions] }
})
.filter((item): item is PendingClaimOption => Boolean(item))
if (optionsFromArray.length > 0) {
return optionsFromArray
}
const claimPlayerId = toStringOrEmpty(
pendingClaim.playerId ??
pendingClaim.player_id ??
pendingClaim.PlayerID ??
pendingClaim.user_id ??
pendingClaim.UserID,
)
if (!claimPlayerId) {
return []
}
const actions = Object.entries(pendingClaim)
.filter(([, enabled]) => typeof enabled === 'boolean' && enabled)
.map(([key]) => normalizeActionName(key))
.filter(Boolean)
return actions.length > 0 ? [{ playerId: claimPlayerId, actions }] : []
}
function extractCurrentTurnIndex(value: Record<string, unknown>): number | null {
const game = toRecord(value.game)
const gameState = toRecord(game?.state)
@@ -495,21 +785,44 @@ export function useChengduGameRoom(
.filter((item) => Boolean(item.playerId))
const resolvedPlayers = players.length > 0 ? players : playersFromIds
const parsedPlayerCount = toFiniteNumber(room.player_count ?? room.playerCount)
const game = normalizeGame(room.game)
const game = normalizeGame(room.game) ?? normalizePublicGameState(room)
const playersFromGame = game?.state?.players
.map((player, index) =>
normalizePlayer(
{
player_id: player.playerId,
index: player.index ?? index,
},
index,
),
)
.filter((item): item is RoomPlayerState => Boolean(item))
const finalPlayers =
resolvedPlayers.length > 0 ? resolvedPlayers : playersFromGame && playersFromGame.length > 0 ? playersFromGame : []
const derivedTurnIndex =
extractCurrentTurnIndex(room) ??
(game?.state
? finalPlayers.find((player) => player.playerId === toStringOrEmpty(room.current_turn_player ?? room.currentTurnPlayer))
?.index ?? null
: null)
return {
id,
name: toStringOrEmpty(room.name) || roomState.value.name,
gameType: toStringOrEmpty(room.gameType ?? room.game_type) || roomState.value.gameType || 'chengdu',
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id),
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id ?? room.OwnerID ?? room.ownerID),
maxPlayers,
playerCount: parsedPlayerCount ?? resolvedPlayers.length,
playerCount:
parsedPlayerCount ??
toFiniteNumber(room.player_count ?? room.playerCount ?? room.playerCount) ??
finalPlayers.length,
status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting',
createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || roomState.value.createdAt,
updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || roomState.value.updatedAt,
game: game ?? roomState.value.game,
players: resolvedPlayers,
currentTurnIndex: extractCurrentTurnIndex(room),
players: finalPlayers,
currentTurnIndex: derivedTurnIndex,
myHand: [],
}
}
@@ -607,6 +920,34 @@ export function useChengduGameRoom(
) {
startGamePending.value = false
}
if (event.status === 'error') {
actionPending.value = false
}
if (eventType === 'my_hand') {
const handPayload = payload ?? data
const handRecord = toRecord(handPayload)
const hand = normalizeTileList(handRecord?.hand ?? handRecord?.tiles ?? handRecord?.myHand)
roomState.value = {
...roomState.value,
myHand: hand,
playerCount: roomState.value.playerCount || roomState.value.players.length,
}
if (!selectedTile.value || !hand.includes(selectedTile.value)) {
selectedTile.value = hand[0] ?? null
}
actionPending.value = false
return
}
if (
['room_state', 'room_player_update', 'room_joined', 'room_member_joined', 'room_member_left'].includes(
eventType,
)
) {
actionPending.value = false
}
}
function createRequestId(prefix: string): string {
@@ -648,6 +989,41 @@ export function useChengduGameRoom(
pushWsMessage(`[client] 请求开始游戏 requestId=${requestId}`)
}
function selectTile(tile: string): void {
selectedTile.value = selectedTile.value === tile ? null : tile
}
function sendGameAction(type: ActionButtonState['type']): void {
if (!ws.value || ws.value.readyState !== WebSocket.OPEN || !currentUserId.value) {
return
}
const requestId = createRequestId(type)
const payload: Record<string, unknown> = {}
if (type === 'discard' && selectedTile.value) {
payload.tile = selectedTile.value
payload.discard_tile = selectedTile.value
payload.code = selectedTile.value
}
actionPending.value = true
const message = {
type,
sender: currentUserId.value,
target: 'room',
roomId: roomState.value.id || roomId.value,
seq: Date.now(),
requestId,
trace_id: createRequestId('trace'),
payload,
}
logWsSend(message)
ws.value.send(JSON.stringify(message))
pushWsMessage(`[client] 请求${type} requestId=${requestId}`)
}
function sendLeaveRoom(): boolean {
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
wsError.value = 'WebSocket 未连接,无法退出房间'
@@ -774,6 +1150,8 @@ export function useChengduGameRoom(
leaveRoomPending.value = false
lastLeaveRoomRequestId.value = ''
leaveHallAfterAck.value = false
actionPending.value = false
selectedTile.value = null
},
{ immediate: true },
)
@@ -826,8 +1204,12 @@ export function useChengduGameRoom(
leaveRoomPending,
canStartGame,
seatViews,
selectedTile,
actionButtons,
connectWs,
sendStartGame,
selectTile,
sendGameAction,
backHall,
}
}

View File

@@ -6,7 +6,13 @@ export type RoomStatus = 'waiting' | 'playing' | 'finished'
export interface RoomPlayerState {
index: number
playerId: string
displayName?: string
ready: boolean
handCount?: number
melds?: string[]
outTiles?: string[]
hasHu?: boolean
missingSuit?: string | null
}
export interface RuleState {
@@ -57,6 +63,7 @@ export interface RoomState {
game: GameState | null
players: RoomPlayerState[]
currentTurnIndex: number | null
myHand: string[]
}
function createInitialRoomState(): RoomState {
@@ -73,6 +80,7 @@ function createInitialRoomState(): RoomState {
game: null,
players: [],
currentTurnIndex: null,
myHand: [],
}
}
@@ -92,6 +100,7 @@ export function resetActiveRoomState(seed?: Partial<RoomState>): void {
...activeRoomState.value,
...seed,
players: seed.players ?? [],
myHand: seed.myHand ?? [],
}
}
@@ -103,10 +112,17 @@ export function mergeActiveRoomState(next: RoomState): void {
activeRoomState.value = {
...activeRoomState.value,
...next,
name: next.name || activeRoomState.value.name,
gameType: next.gameType || activeRoomState.value.gameType,
ownerId: next.ownerId || activeRoomState.value.ownerId,
status: next.status || activeRoomState.value.status,
createdAt: next.createdAt || activeRoomState.value.createdAt,
updatedAt: next.updatedAt || activeRoomState.value.updatedAt,
game: next.game ?? activeRoomState.value.game,
players: next.players.length > 0 ? next.players : activeRoomState.value.players,
currentTurnIndex:
next.currentTurnIndex !== null ? next.currentTurnIndex : activeRoomState.value.currentTurnIndex,
myHand: next.myHand.length > 0 ? next.myHand : activeRoomState.value.myHand,
}
}
@@ -122,6 +138,7 @@ export function hydrateActiveRoomFromSelection(input: {
updatedAt?: string
players?: RoomPlayerState[]
currentTurnIndex?: number | null
myHand?: string[]
}): void {
resetActiveRoomState({
id: input.roomId,
@@ -135,5 +152,6 @@ export function hydrateActiveRoomFromSelection(input: {
updatedAt: input.updatedAt ?? '',
players: input.players ?? [],
currentTurnIndex: input.currentTurnIndex ?? null,
myHand: input.myHand ?? [],
})
}

View File

@@ -27,8 +27,12 @@ const {
leaveRoomPending,
canStartGame,
seatViews,
selectedTile,
actionButtons,
connectWs,
sendStartGame,
selectTile,
sendGameAction,
backHall,
} = useChengduGameRoom(route, router)
@@ -129,7 +133,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
const score = scoreMap[playerId]
result[seat.key] = {
avatar: seat.isSelf ? '我' : String(index + 1),
name: seat.isSelf ? '你自己' : playerId,
name: seat.isSelf ? '你自己' : seat.player.displayName || `玩家${seat.player.index + 1}`,
money: typeof score === 'number' ? String(score) : '--',
dealer: seat.player.index === dealerIndex,
isTurn: seat.isTurn,
@@ -170,6 +174,23 @@ const centerTimer = computed(() => {
: '等待中'
})
const pendingClaimText = computed(() => {
const claim = roomState.value.game?.state?.pendingClaim
if (!claim) {
return '无'
}
try {
return JSON.stringify(claim)
} catch {
return '存在响应窗口'
}
})
const selectedTileText = computed(() => {
return selectedTile.value ?? '未选择'
})
function missingSuitLabel(value: string | null | undefined): string {
if (!value) {
return '未定'
@@ -270,6 +291,51 @@ onBeforeUnmount(() => {
</div>
</section>
<section class="table-panel game-table-panel action-panel">
<div class="action-grid">
<div class="action-card">
<h3>我的手牌</h3>
<p class="action-hint">当前仅渲染 `my_hand` 事件下发的真实手牌</p>
<div class="hand-wall" v-if="roomState.myHand.length > 0">
<button
v-for="(tile, index) in roomState.myHand"
:key="`${tile}-${index}`"
class="tile-chip"
type="button"
:class="{ selected: selectedTile === tile }"
@click="selectTile(tile)"
>
{{ tile }}
</button>
</div>
<p v-else class="action-empty">尚未收到 `my_hand`</p>
<p class="action-meta">已选牌{{ selectedTileText }}</p>
</div>
<div class="action-card">
<h3>对局动作</h3>
<p class="action-hint">已接入 `draw / discard / peng / gang / hu / pass` WS 发包</p>
<div class="action-buttons">
<button
v-for="action in actionButtons"
:key="action.type"
class="primary-btn action-btn"
type="button"
:disabled="action.disabled"
@click="sendGameAction(action.type)"
>
{{ action.label }}
</button>
</div>
<p class="action-meta">当前响应窗口{{ pendingClaimText }}</p>
<p class="action-meta">
最近弃牌{{ roomState.game?.state?.lastDiscardTile || '无' }}
/ {{ roomState.game?.state?.lastDiscardBy || '无' }}
</p>
</div>
</div>
</section>
<section class="table-shell">
<img class="table-desk" :src="deskImage" alt="" />
<div class="table-felt">
@@ -345,3 +411,69 @@ onBeforeUnmount(() => {
</section>
</section>
</template>
<style scoped>
.action-panel {
margin-top: 12px;
}
.action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.action-card {
background: rgba(10, 27, 22, 0.72);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 16px;
}
.action-card h3 {
margin: 0 0 8px;
}
.action-hint,
.action-meta,
.action-empty {
margin: 8px 0 0;
font-size: 13px;
color: rgba(255, 255, 255, 0.72);
word-break: break-all;
}
.hand-wall,
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.tile-chip {
min-width: 56px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(247, 239, 220, 0.9);
color: #1c1b18;
cursor: pointer;
}
.tile-chip.selected {
transform: translateY(-4px);
border-color: #f4c76a;
box-shadow: 0 10px 24px rgba(244, 199, 106, 0.25);
}
.action-btn {
min-width: 88px;
}
@media (max-width: 960px) {
.action-grid {
grid-template-columns: 1fr;
}
}
</style>