refactor(game): 移除废弃的房间状态管理文件并优化游戏页面

- 删除 src/state/active-room.ts 文件及其相关导入引用
- 更新 ChengduGamePage.vue 中的导入路径从 features/chengdu-game/useChengduGameRoom 到 game/chengdu
- 移除 ChengduGamePage.vue 中不再需要的状态变量如 roomId、startGamePending 等
- 简化 roomStatusText 计算属性逻辑,移除 "等待中" 默认值
- 调整 phaseLabelMap 映射,移除 "摸牌" 阶段显示
- 删除多个废弃的计算属性如 centerTimer、selectedTileText、pendingClaimText 等
- 移除 actionTheme 函数及相关的按钮样式绑定
- 清理游戏场景中的装饰元素如 diamond outline、scene watermark、center desk 等
- 更新 HallPage.vue 中的导入路径到 store/active-room-store
- 添加缺失的玩家数据字段如 hand、melds、outTiles、hasHu
- 调整 CSS 样式包括工具栏位置、动画角度和时钟位置等视觉优化
This commit is contained in:
2026-03-24 22:09:03 +08:00
parent 3219639b04
commit 7316588d9e
28 changed files with 757 additions and 670 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -99,8 +99,8 @@
.top-left-tools { .top-left-tools {
position: absolute; position: absolute;
top: 28px; top: 40px;
left: 28px; left: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
@@ -120,6 +120,7 @@
} }
.menu-trigger-icon { .menu-trigger-icon {
left: 40px;
display: inline-block; display: inline-block;
transform-origin: center; transform-origin: center;
} }
@@ -271,7 +272,7 @@
transform: rotate(0deg); transform: rotate(0deg);
} }
100% { 100% {
transform: rotate(22deg); transform: rotate(90deg);
} }
} }
@@ -298,8 +299,8 @@
.top-right-clock { .top-right-clock {
position: absolute; position: absolute;
top: 30px; top: 40px;
right: 36px; right: 40px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;

2
src/constants/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './suits'
export * from './ws-events'

3
src/constants/suits.ts Normal file
View File

@@ -0,0 +1,3 @@
export const MAHJONG_SUITS = ['wan', 'tong', 'tiao'] as const
export type MahjongSuit = (typeof MAHJONG_SUITS)[number]

View File

@@ -0,0 +1,6 @@
export const WS_EVENT = {
roomSnapshot: 'room_snapshot',
roomState: 'room_state',
gameState: 'game_state',
myHand: 'my_hand',
} as const

View File

@@ -0,0 +1,2 @@
export { useChengduGameRoom } from './useChengduGameRoom'
export type { ActionButtonState, ChengduGameRoomModel, SeatKey, SeatView } from './types'

View File

@@ -0,0 +1,71 @@
export function humanizeSuit(value: string): string {
const suitMap: Record<string, string> = {
W: '万',
B: '筒',
T: '条',
}
return suitMap[value] ?? value
}
export function toRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
export function toStringOrEmpty(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return ''
}
export function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
export function toBoolean(value: unknown): boolean {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
return value !== 0
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
return normalized === '1' || normalized === 'true' || normalized === 'yes'
}
return false
}
export 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
}

View File

@@ -0,0 +1,421 @@
import {
DEFAULT_MAX_PLAYERS,
type GameState,
type RoomPlayerState,
type RoomState,
} from '../../store/active-room-store'
import { type PendingClaimOption } from './types'
import {
humanizeSuit,
normalizeActionName,
toBoolean,
toFiniteNumber,
toRecord,
toStringOrEmpty,
} from './parser-utils'
function normalizeScores(value: unknown): Record<string, number> {
const record = toRecord(value)
if (!record) {
return {}
}
const scores: Record<string, number> = {}
for (const [key, score] of Object.entries(record)) {
const parsed = toFiniteNumber(score)
if (parsed !== null) {
scores[key] = parsed
}
}
return scores
}
export 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 normalizeMeldGroups(value: unknown): string[][] {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => {
if (Array.isArray(item)) {
return normalizeTileList(item)
}
const record = toRecord(item)
if (record) {
const nested = record.tiles ?? record.Tiles ?? record.meld ?? record.Meld
if (nested) {
return normalizeTileList(nested)
}
}
return normalizeTileList([item])
})
.filter((group) => group.length > 0)
}
function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null {
const player = toRecord(input)
if (!player) {
return null
}
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.Index ??
player.seat ??
player.Seat ??
player.position ??
player.Position ??
player.player_index,
)
const hand = normalizeTileList(player.hand ?? player.Hand)
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) ??
(hand.length > 0 ? hand.length : undefined),
hand,
melds: normalizeMeldGroups(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 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: '',
},
}
}
export 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 [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)
const keys = [
gameState?.currentTurn,
gameState?.current_turn,
gameState?.currentTurnIndex,
gameState?.current_turn_index,
value.currentTurnIndex,
value.current_turn_index,
value.currentPlayerIndex,
value.current_player_index,
value.turnIndex,
value.turn_index,
value.activePlayerIndex,
value.active_player_index,
]
for (const key of keys) {
const parsed = toFiniteNumber(key)
if (parsed !== null) {
return parsed
}
}
return null
}
function normalizeGame(input: unknown): GameState | null {
const game = toRecord(input)
if (!game) {
return null
}
const rule = toRecord(game.rule)
const rawState = toRecord(game.state)
const playersRaw =
(Array.isArray(rawState?.players) ? rawState?.players : null) ??
(Array.isArray(rawState?.playerStates) ? rawState?.playerStates : null) ??
[]
const normalizedPlayers = playersRaw
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
return {
rule: rule
? {
name: toStringOrEmpty(rule.name),
isBloodFlow: toBoolean(rule.isBloodFlow ?? rule.is_blood_flow),
hasHongZhong: toBoolean(rule.hasHongZhong ?? rule.has_hong_zhong),
}
: null,
state: rawState
? {
phase: toStringOrEmpty(rawState.phase),
dealerIndex: toFiniteNumber(rawState.dealerIndex ?? rawState.dealer_index) ?? 0,
currentTurn: toFiniteNumber(rawState.currentTurn ?? rawState.current_turn) ?? 0,
needDraw: toBoolean(rawState.needDraw ?? rawState.need_draw),
players: normalizedPlayers,
wall: Array.isArray(rawState.wall)
? rawState.wall.map((item) => toStringOrEmpty(item)).filter(Boolean)
: [],
lastDiscardTile: toStringOrEmpty(rawState.lastDiscardTile ?? rawState.last_discard_tile) || null,
lastDiscardBy: toStringOrEmpty(rawState.lastDiscardBy ?? rawState.last_discard_by),
pendingClaim: toRecord(rawState.pendingClaim ?? rawState.pending_claim),
winners: Array.isArray(rawState.winners)
? rawState.winners.map((item) => toStringOrEmpty(item)).filter(Boolean)
: [],
scores: normalizeScores(rawState.scores),
lastDrawPlayerId: toStringOrEmpty(rawState.lastDrawPlayerID ?? rawState.last_draw_player_id),
lastDrawFromGang: toBoolean(rawState.lastDrawFromGang ?? rawState.last_draw_from_gang),
lastDrawIsLastTile: toBoolean(rawState.lastDrawIsLastTile ?? rawState.last_draw_is_last_tile),
huWay: toStringOrEmpty(rawState.huWay ?? rawState.hu_way),
}
: null,
}
}
export function normalizeRoom(input: unknown, currentRoomState: RoomState): RoomState | null {
const source = toRecord(input)
if (!source) {
return null
}
let room = source
let id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
if (!id) {
const nestedRoom = toRecord(room.data)
if (nestedRoom) {
room = nestedRoom
id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
}
}
if (!id) {
return null
}
const maxPlayers =
toFiniteNumber(room.maxPlayers ?? room.max_players) ?? currentRoomState.maxPlayers ?? DEFAULT_MAX_PLAYERS
const playersRaw =
(Array.isArray(room.players) ? room.players : null) ??
(Array.isArray(room.playerList) ? room.playerList : null) ??
(Array.isArray(room.player_list) ? room.player_list : null) ??
[]
const playerIdsRaw =
(Array.isArray(room.player_ids) ? room.player_ids : null) ??
(Array.isArray(room.playerIds) ? room.playerIds : null) ??
[]
const players = playersRaw
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
.sort((a, b) => a.index - b.index)
const playersFromIds = playerIdsRaw
.map((item, index) => ({
index,
playerId: toStringOrEmpty(item),
ready: false,
hand: [],
melds: [],
outTiles: [],
hasHu: false,
}))
.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) ?? 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) || currentRoomState.name,
gameType: toStringOrEmpty(room.gameType ?? room.game_type) || currentRoomState.gameType || 'chengdu',
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id ?? room.OwnerID ?? room.ownerID),
maxPlayers,
playerCount:
parsedPlayerCount ??
toFiniteNumber(room.player_count ?? room.playerCount ?? room.playerCount) ??
finalPlayers.length,
status: toStringOrEmpty(room.status) || currentRoomState.status || 'waiting',
createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || currentRoomState.createdAt,
updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || currentRoomState.updatedAt,
game: game ?? currentRoomState.game,
players: finalPlayers,
currentTurnIndex: derivedTurnIndex,
myHand: [],
}
}

60
src/game/chengdu/types.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { ComputedRef, Ref } from 'vue'
import type { StoredAuth } from '../../types/session'
import type { RoomPlayerState } from '../../store/active-room-store'
import { activeRoomState } from '../../store/active-room-store'
export type SeatKey = 'top' | 'right' | 'bottom' | 'left'
export interface ActionEventLike {
type?: unknown
status?: unknown
requestId?: unknown
request_id?: unknown
roomId?: unknown
room_id?: unknown
payload?: unknown
data?: unknown
}
export 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
isSelf: boolean
isTurn: boolean
label: string
subLabel: string
}
export interface ChengduGameRoomModel {
auth: Ref<StoredAuth | null>
roomState: typeof activeRoomState
roomId: ComputedRef<string>
roomName: ComputedRef<string>
currentUserId: ComputedRef<string>
loggedInUserName: ComputedRef<string>
wsStatus: Ref<'disconnected' | 'connecting' | 'connected'>
wsError: Ref<string>
wsMessages: Ref<string[]>
startGamePending: Ref<boolean>
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
}

View File

@@ -1,87 +1,34 @@
import { computed, onBeforeUnmount, onMounted, ref, watch, type ComputedRef, type Ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router' import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
import type { AuthSession } from '../../api/authed-request' import type { AuthSession } from '../../api/authed-request'
import { refreshAccessToken } from '../../api/auth' import { refreshAccessToken } from '../../api/auth'
import { getUserInfo } from '../../api/user' import { getUserInfo } from '../../api/user'
import { import {
DEFAULT_MAX_PLAYERS,
activeRoomState, activeRoomState,
destroyActiveRoomState, destroyActiveRoomState,
mergeActiveRoomState, mergeActiveRoomState,
resetActiveRoomState, resetActiveRoomState,
type GameState,
type RoomPlayerState, type RoomPlayerState,
type RoomState, type RoomState,
} from '../../state/active-room' } from '../../store/active-room-store'
import { readStoredAuth, writeStoredAuth } from '../../utils/auth-storage' import { readStoredAuth, writeStoredAuth } from '../../utils/auth-storage'
import type { StoredAuth } from '../../types/session' import type {
ActionButtonState,
export type SeatKey = 'top' | 'right' | 'bottom' | 'left' ActionEventLike,
ChengduGameRoomModel,
interface ActionEventLike { PendingClaimOption,
type?: unknown SeatKey,
status?: unknown SeatView,
requestId?: unknown } from './types'
request_id?: unknown import { toRecord, toStringOrEmpty } from './parser-utils'
roomId?: unknown import { normalizePendingClaimOptions, normalizeRoom, normalizeTileList } from './room-normalizers'
room_id?: unknown
payload?: unknown
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
isSelf: boolean
isTurn: boolean
label: string
subLabel: string
}
function humanizeSuit(value: string): string {
const suitMap: Record<string, string> = {
W: '万',
B: '筒',
T: '条',
}
return suitMap[value] ?? value
}
export interface ChengduGameRoomModel {
auth: Ref<StoredAuth | null>
roomState: typeof activeRoomState
roomId: ComputedRef<string>
roomName: ComputedRef<string>
currentUserId: ComputedRef<string>
loggedInUserName: ComputedRef<string>
wsStatus: Ref<'disconnected' | 'connecting' | 'connected'>
wsError: Ref<string>
wsMessages: Ref<string[]>
startGamePending: Ref<boolean>
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
}
export type {
ActionButtonState,
ChengduGameRoomModel,
SeatKey,
SeatView,
} from './types'
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws' const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws'
export function useChengduGameRoom( export function useChengduGameRoom(
@@ -221,6 +168,10 @@ export function useChengduGameRoom(
index: 0, index: 0,
playerId: currentUserId.value, playerId: currentUserId.value,
ready: false, ready: false,
hand: [],
melds: [],
outTiles: [],
hasHu: false,
}) })
} }
@@ -281,16 +232,16 @@ export function useChengduGameRoom(
} }
function logWsSend(message: unknown): void { function logWsSend(message: unknown): void {
console.log('[WS][client] 发送:', message) console.log('[WS][client] 鍙戦€?', message)
} }
function logWsReceive(kind: string, payload?: unknown): void { function logWsReceive(kind: string, payload?: unknown): void {
const now = new Date().toLocaleTimeString() const now = new Date().toLocaleTimeString()
if (payload === undefined) { if (payload === undefined) {
console.log(`[WS][${now}] 收到${kind}`) console.log(`[WS][${now}] 鏀跺埌${kind}`)
return return
} }
console.log(`[WS][${now}] 收到${kind}:`, payload) console.log(`[WS][${now}] 鏀跺埌${kind}:`, payload)
} }
function disconnectWs(): void { function disconnectWs(): void {
@@ -301,43 +252,6 @@ export function useChengduGameRoom(
wsStatus.value = 'disconnected' wsStatus.value = 'disconnected'
} }
function toRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
function toStringOrEmpty(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
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 { function toSession(source: NonNullable<typeof auth.value>): AuthSession {
return { return {
token: source.token, token: source.token,
@@ -384,7 +298,7 @@ export function useChengduGameRoom(
} }
writeStoredAuth(auth.value) writeStoredAuth(auth.value)
} catch { } catch {
wsError.value = '获取当前用户 ID 失败,部分操作可能不可用' wsError.value = '鑾峰彇褰撳墠鐢ㄦ埛 ID 澶辫触锛岄儴鍒嗘搷浣滃彲鑳戒笉鍙敤'
} }
} }
@@ -422,407 +336,6 @@ export function useChengduGameRoom(
} }
} }
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function toBoolean(value: unknown): boolean {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
return value !== 0
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
return normalized === '1' || normalized === 'true' || normalized === 'yes'
}
return false
}
function normalizeScores(value: unknown): Record<string, number> {
const record = toRecord(value)
if (!record) {
return {}
}
const scores: Record<string, number> = {}
for (const [key, score] of Object.entries(record)) {
const parsed = toFiniteNumber(score)
if (parsed !== null) {
scores[key] = parsed
}
}
return scores
}
function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null {
const player = toRecord(input)
if (!player) {
return null
}
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.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)
const keys = [
gameState?.currentTurn,
gameState?.current_turn,
gameState?.currentTurnIndex,
gameState?.current_turn_index,
value.currentTurnIndex,
value.current_turn_index,
value.currentPlayerIndex,
value.current_player_index,
value.turnIndex,
value.turn_index,
value.activePlayerIndex,
value.active_player_index,
]
for (const key of keys) {
const parsed = toFiniteNumber(key)
if (parsed !== null) {
return parsed
}
}
return null
}
function normalizeGame(input: unknown): GameState | null {
const game = toRecord(input)
if (!game) {
return null
}
const rule = toRecord(game.rule)
const rawState = toRecord(game.state)
const playersRaw =
(Array.isArray(rawState?.players) ? rawState?.players : null) ??
(Array.isArray(rawState?.playerStates) ? rawState?.playerStates : null) ??
[]
const normalizedPlayers = playersRaw
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
return {
rule: rule
? {
name: toStringOrEmpty(rule.name),
isBloodFlow: toBoolean(rule.isBloodFlow ?? rule.is_blood_flow),
hasHongZhong: toBoolean(rule.hasHongZhong ?? rule.has_hong_zhong),
}
: null,
state: rawState
? {
phase: toStringOrEmpty(rawState.phase),
dealerIndex: toFiniteNumber(rawState.dealerIndex ?? rawState.dealer_index) ?? 0,
currentTurn: toFiniteNumber(rawState.currentTurn ?? rawState.current_turn) ?? 0,
needDraw: toBoolean(rawState.needDraw ?? rawState.need_draw),
players: normalizedPlayers,
wall: Array.isArray(rawState.wall)
? rawState.wall.map((item) => toStringOrEmpty(item)).filter(Boolean)
: [],
lastDiscardTile: toStringOrEmpty(rawState.lastDiscardTile ?? rawState.last_discard_tile) || null,
lastDiscardBy: toStringOrEmpty(rawState.lastDiscardBy ?? rawState.last_discard_by),
pendingClaim: toRecord(rawState.pendingClaim ?? rawState.pending_claim),
winners: Array.isArray(rawState.winners)
? rawState.winners.map((item) => toStringOrEmpty(item)).filter(Boolean)
: [],
scores: normalizeScores(rawState.scores),
lastDrawPlayerId: toStringOrEmpty(rawState.lastDrawPlayerID ?? rawState.last_draw_player_id),
lastDrawFromGang: toBoolean(rawState.lastDrawFromGang ?? rawState.last_draw_from_gang),
lastDrawIsLastTile: toBoolean(rawState.lastDrawIsLastTile ?? rawState.last_draw_is_last_tile),
huWay: toStringOrEmpty(rawState.huWay ?? rawState.hu_way),
}
: null,
}
}
function normalizeRoom(input: unknown): RoomState | null {
const source = toRecord(input)
if (!source) {
return null
}
let room = source
let id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
if (!id) {
const nestedRoom = toRecord(room.data)
if (nestedRoom) {
room = nestedRoom
id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
}
}
if (!id) {
return null
}
const maxPlayers =
toFiniteNumber(room.maxPlayers ?? room.max_players) ?? roomState.value.maxPlayers ?? DEFAULT_MAX_PLAYERS
const playersRaw =
(Array.isArray(room.players) ? room.players : null) ??
(Array.isArray(room.playerList) ? room.playerList : null) ??
(Array.isArray(room.player_list) ? room.player_list : null) ??
[]
const playerIdsRaw =
(Array.isArray(room.player_ids) ? room.player_ids : null) ??
(Array.isArray(room.playerIds) ? room.playerIds : null) ??
[]
const players = playersRaw
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
.sort((a, b) => a.index - b.index)
const playersFromIds = playerIdsRaw
.map((item, index) => ({
index,
playerId: toStringOrEmpty(item),
ready: false,
}))
.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) ?? 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 ?? room.OwnerID ?? room.ownerID),
maxPlayers,
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: finalPlayers,
currentTurnIndex: derivedTurnIndex,
myHand: [],
}
}
function mergeRoomState(next: RoomState): void { function mergeRoomState(next: RoomState): void {
if (roomId.value && next.id !== roomId.value) { if (roomId.value && next.id !== roomId.value) {
return return
@@ -903,7 +416,7 @@ export function useChengduGameRoom(
candidates.push(event) candidates.push(event)
for (const candidate of candidates) { for (const candidate of candidates) {
const normalized = normalizeRoom(candidate) const normalized = normalizeRoom(candidate, roomState.value)
if (normalized) { if (normalized) {
mergeRoomState(normalized) mergeRoomState(normalized)
break break
@@ -1210,3 +723,4 @@ export function useChengduGameRoom(
backHall, backHall,
} }
} }

1
src/game/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './chengdu'

2
src/models/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './tile'
export * from './room-state'

View File

@@ -0,0 +1 @@
export const DEFAULT_MAX_PLAYERS = 4

View File

@@ -0,0 +1,19 @@
import type { GamePlayerState } from './game-player-state'
export interface EngineState {
phase: string
dealerIndex: number
currentTurn: number
needDraw: boolean
players: GamePlayerState[]
wall: string[]
lastDiscardTile: string | null
lastDiscardBy: string
pendingClaim: Record<string, unknown> | null
winners: string[]
scores: Record<string, number>
lastDrawPlayerId: string
lastDrawFromGang: boolean
lastDrawIsLastTile: boolean
huWay: string
}

View File

@@ -0,0 +1,5 @@
export interface GamePlayerState {
playerId: string
index: number
ready: boolean
}

View File

@@ -0,0 +1,7 @@
import type { EngineState } from './engine-state'
import type { RuleState } from './rule-state'
export interface GameState {
rule: RuleState | null
state: EngineState | null
}

View File

@@ -0,0 +1,9 @@
export { DEFAULT_MAX_PLAYERS } from './constants'
export type { RoomStatus } from './room-status'
export type { PlayerState } from './player-state'
export type { RoomPlayerState } from './room-player-state'
export type { RuleState } from './rule-state'
export type { GamePlayerState } from './game-player-state'
export type { EngineState } from './engine-state'
export type { GameState } from './game-state'
export type { RoomState } from './room-state'

View File

@@ -0,0 +1,7 @@
export interface PlayerState {
playerId: string
hand: string[]
melds: string[][]
outTiles: string[]
hasHu: boolean
}

View File

@@ -0,0 +1,9 @@
import type { PlayerState } from './player-state'
export interface RoomPlayerState extends PlayerState {
index: number
displayName?: string
ready: boolean
handCount?: number
missingSuit?: string | null
}

View File

@@ -0,0 +1,19 @@
import type { GameState } from './game-state'
import type { RoomPlayerState } from './room-player-state'
import type { RoomStatus } from './room-status'
export interface RoomState {
id: string
name: string
gameType: string
ownerId: string
maxPlayers: number
playerCount: number
status: RoomStatus | string
createdAt: string
updatedAt: string
game: GameState | null
players: RoomPlayerState[]
currentTurnIndex: number | null
myHand: string[]
}

View File

@@ -0,0 +1 @@
export type RoomStatus = 'waiting' | 'playing' | 'finished'

View File

@@ -0,0 +1,5 @@
export interface RuleState {
name: string
isBloodFlow: boolean
hasHongZhong: boolean
}

50
src/models/tile.ts Normal file
View File

@@ -0,0 +1,50 @@
export type Suit = 'W' | 'T' | 'B'
export interface Tile {
id: number
suit: Suit
value: number
}
export class TileModel {
id: number
suit: Suit
value: number
constructor(tile: Tile) {
this.id = tile.id
this.suit = tile.suit
this.value = tile.value
}
/** 花色中文 */
get suitName(): string {
const map: Record<Suit, string> = {
W: '万',
T: '筒',
B: '条',
}
return map[this.suit]
}
/** 显示文本 */
toString(): string {
return `${this.suitName}${this.value}[#${this.id}]`
}
/** 是否同一张牌和后端一致按ID */
equals(other: TileModel): boolean {
return this.id === other.id
}
/** 排序权重(用于手牌排序) */
get sortValue(): number {
const suitOrder: Record<Suit, number> = {
W: 0,
T: 1,
B: 2,
}
return suitOrder[this.suit] * 10 + this.value
}
}

View File

@@ -1,70 +1,21 @@
import { ref } from 'vue' import { ref } from 'vue'
import {
DEFAULT_MAX_PLAYERS,
type RoomPlayerState,
type RoomState,
} from '../models/room-state'
export const DEFAULT_MAX_PLAYERS = 4 export {
export type RoomStatus = 'waiting' | 'playing' | 'finished' DEFAULT_MAX_PLAYERS,
type EngineState,
export interface RoomPlayerState { type GamePlayerState,
index: number type GameState,
playerId: string type PlayerState,
displayName?: string type RoomPlayerState,
ready: boolean type RoomState,
handCount?: number type RoomStatus,
melds?: string[] type RuleState,
outTiles?: string[] } from '../models/room-state'
hasHu?: boolean
missingSuit?: string | null
}
export interface RuleState {
name: string
isBloodFlow: boolean
hasHongZhong: boolean
}
export interface GamePlayerState {
playerId: string
index: number
ready: boolean
}
export interface EngineState {
phase: string
dealerIndex: number
currentTurn: number
needDraw: boolean
players: GamePlayerState[]
wall: string[]
lastDiscardTile: string | null
lastDiscardBy: string
pendingClaim: Record<string, unknown> | null
winners: string[]
scores: Record<string, number>
lastDrawPlayerId: string
lastDrawFromGang: boolean
lastDrawIsLastTile: boolean
huWay: string
}
export interface GameState {
rule: RuleState | null
state: EngineState | null
}
export interface RoomState {
id: string
name: string
gameType: string
ownerId: string
maxPlayers: number
playerCount: number
status: RoomStatus | string
createdAt: string
updatedAt: string
game: GameState | null
players: RoomPlayerState[]
currentTurnIndex: number | null
myHand: string[]
}
function createInitialRoomState(): RoomState { function createInitialRoomState(): RoomState {
return { return {

View File

@@ -17,28 +17,22 @@ import RightPlayerCard from '../components/game/RightPlayerCard.vue'
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue' import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
import LeftPlayerCard from '../components/game/LeftPlayerCard.vue' import LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
import type {SeatPlayerCardModel} from '../components/game/seat-player-card' import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
import {type SeatKey, useChengduGameRoom} from '../features/chengdu-game/useChengduGameRoom' import {type SeatKey, useChengduGameRoom} from '../game/chengdu'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { const {
roomState, roomState,
roomId,
roomName, roomName,
loggedInUserName, loggedInUserName,
wsStatus, wsStatus,
wsError, wsError,
wsMessages, wsMessages,
startGamePending,
leaveRoomPending, leaveRoomPending,
canStartGame,
seatViews, seatViews,
selectedTile, selectedTile,
actionButtons,
connectWs, connectWs,
sendStartGame,
selectTile, selectTile,
sendGameAction,
backHall, backHall,
} = useChengduGameRoom(route, router) } = useChengduGameRoom(route, router)
@@ -57,7 +51,6 @@ const roomStatusText = computed(() => {
if (roomState.value.status === 'finished') { if (roomState.value.status === 'finished') {
return '已结束' return '已结束'
} }
return '等待中'
}) })
const currentPhaseText = computed(() => { const currentPhaseText = computed(() => {
@@ -68,7 +61,6 @@ const currentPhaseText = computed(() => {
const phaseLabelMap: Record<string, string> = { const phaseLabelMap: Record<string, string> = {
dealing: '发牌', dealing: '发牌',
draw: '摸牌',
discard: '出牌', discard: '出牌',
action: '响应', action: '响应',
settle: '结算', settle: '结算',
@@ -152,30 +144,6 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
return result return result
}) })
const centerTimer = computed(() => {
const wallLeft = roomState.value.game?.state?.wall.length
if (typeof wallLeft === 'number' && Number.isFinite(wallLeft)) {
return String(wallLeft).padStart(2, '0')
}
return String(roomState.value.playerCount).padStart(2, '0')
})
const selectedTileText = computed(() => selectedTile.value ?? '未选择')
const pendingClaimText = computed(() => {
const claim = roomState.value.game?.state?.pendingClaim
if (!claim) {
return '无'
}
try {
return JSON.stringify(claim)
} catch {
return '有待响应动作'
}
})
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse()) const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
const floatingMissingSuit = computed(() => { const floatingMissingSuit = computed(() => {
@@ -193,10 +161,6 @@ const floatingMissingSuit = computed(() => {
}) })
function missingSuitLabel(value: string | null | undefined): string { function missingSuitLabel(value: string | null | undefined): string {
if (!value) {
return '未定'
}
const suitMap: Record<string, string> = { const suitMap: Record<string, string> = {
wan: '万', wan: '万',
tong: '筒', tong: '筒',
@@ -217,16 +181,6 @@ function getBackImage(seat: SeatKey): string {
return imageMap[seat] return imageMap[seat]
} }
function actionTheme(type: string): 'gold' | 'jade' | 'blue' {
if (type === 'hu' || type === 'gang') {
return 'gold'
}
if (type === 'pass') {
return 'jade'
}
return 'blue'
}
function toggleMenu(): void { function toggleMenu(): void {
menuTriggerActive.value = true menuTriggerActive.value = true
if (menuTriggerTimer !== null) { if (menuTriggerTimer !== null) {
@@ -316,7 +270,6 @@ onBeforeUnmount(() => {
<div class="table-surface"></div> <div class="table-surface"></div>
<div class="inner-outline outer"></div> <div class="inner-outline outer"></div>
<div class="inner-outline mid"></div> <div class="inner-outline mid"></div>
<div class="inner-outline diamond"></div>
<div class="top-left-tools"> <div class="top-left-tools">
<div class="menu-trigger-wrap"> <div class="menu-trigger-wrap">
@@ -368,11 +321,6 @@ onBeforeUnmount(() => {
<span>{{ formattedClock }}</span> <span>{{ formattedClock }}</span>
</div> </div>
<div class="scene-watermark">
<strong>四川麻将</strong>
<span>{{ roomState.name || roomName || '成都麻将房' }}</span>
<small>底注 6 亿 · 封顶 32 </small>
</div>
<TopPlayerCard :player="seatDecor.top"/> <TopPlayerCard :player="seatDecor.top"/>
<RightPlayerCard :player="seatDecor.right"/> <RightPlayerCard :player="seatDecor.right"/>
@@ -392,14 +340,6 @@ onBeforeUnmount(() => {
<img v-for="key in wallBacks.left" :key="key" :src="getBackImage('left')" alt=""/> <img v-for="key in wallBacks.left" :key="key" :src="getBackImage('left')" alt=""/>
</div> </div>
<div class="center-desk">
<span class="wind north"></span>
<span class="wind west">西</span>
<strong>{{ centerTimer }}</strong>
<span class="wind south"></span>
<span class="wind east"></span>
</div>
<div class="floating-status top"> <div class="floating-status top">
<img v-if="floatingMissingSuit.top" :src="floatingMissingSuit.top" alt=""/> <img v-if="floatingMissingSuit.top" :src="floatingMissingSuit.top" alt=""/>
<span>{{ seatDecor.top.missingSuitLabel }}</span> <span>{{ seatDecor.top.missingSuitLabel }}</span>
@@ -413,30 +353,8 @@ onBeforeUnmount(() => {
<span>{{ seatDecor.right.missingSuitLabel }}</span> <span>{{ seatDecor.right.missingSuitLabel }}</span>
</div> </div>
<div class="claim-banner">
<span>{{ roomStatusText }}</span>
<strong>{{ currentPhaseText }}</strong>
</div>
<div class="bottom-control-panel"> <div class="bottom-control-panel">
<div class="control-copy">
<p>房间 {{ roomId || '--' }}</p>
<small>当前选择{{ selectedTileText }} · 待响应{{ pendingClaimText }}</small>
</div>
<div class="action-orbs">
<button
v-for="action in actionButtons"
:key="action.type"
class="orb-button"
:class="`theme-${actionTheme(action.type)}`"
type="button"
:disabled="action.disabled"
@click="sendGameAction(action.type)"
>
{{ action.label }}
</button>
</div>
<div class="player-hand" v-if="roomState.myHand.length > 0"> <div class="player-hand" v-if="roomState.myHand.length > 0">
<button <button
@@ -450,7 +368,6 @@ onBeforeUnmount(() => {
{{ tile }} {{ tile }}
</button> </button>
</div> </div>
<p v-else class="empty-hand">等待服务端下发 `my_hand`</p>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -4,8 +4,8 @@ import { useRouter } from 'vue-router'
import { AuthExpiredError, type AuthSession } from '../api/authed-request' import { AuthExpiredError, type AuthSession } from '../api/authed-request'
import { createRoom, joinRoom, listRooms, type RoomItem } from '../api/mahjong' import { createRoom, joinRoom, listRooms, type RoomItem } from '../api/mahjong'
import { getUserInfo, type UserInfo } from '../api/user' import { getUserInfo, type UserInfo } from '../api/user'
import { hydrateActiveRoomFromSelection } from '../state/active-room' import { hydrateActiveRoomFromSelection } from '../store/active-room-store'
import type { RoomPlayerState } from '../state/active-room' import type { RoomPlayerState } from '../store/active-room-store'
import type { StoredAuth } from '../types/session' import type { StoredAuth } from '../types/session'
import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage' import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
@@ -129,6 +129,10 @@ function mapRoomPlayers(room: RoomItem): RoomPlayerState[] {
index: Number.isFinite(item.index) ? item.index : fallbackIndex, index: Number.isFinite(item.index) ? item.index : fallbackIndex,
playerId: item.player_id, playerId: item.player_id,
ready: Boolean(item.ready), ready: Boolean(item.ready),
hand: [],
melds: [],
outTiles: [],
hasHu: false,
})) }))
.filter((item) => Boolean(item.playerId)) .filter((item) => Boolean(item.playerId))
} }