- 实现 ChengduGamePage.vue 组件,包含完整的麻将游戏界面 - 实现 HallPage.vue 组件,支持房间列表展示、创建和加入功能 - 添加 mahjong API 接口用于房间管理操作 - 集成 store 状态管理和本地存储功能 - 实现 ChengduBottomActions 等游戏控制组件 - 添加 websocket 连接和游戏会话管理逻辑 - 实现游戏倒计时、结算等功能模块
284 lines
7.6 KiB
TypeScript
284 lines
7.6 KiB
TypeScript
import type {
|
|
ClaimOptionState,
|
|
MeldState,
|
|
PendingClaimState,
|
|
PlayerState,
|
|
Tile,
|
|
} from '../../types/state'
|
|
|
|
export function normalizeWsType(type: string): string {
|
|
return type.replace(/[-\s]/g, '_').toUpperCase()
|
|
}
|
|
|
|
export function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null
|
|
}
|
|
|
|
export function readString(source: Record<string, unknown>, ...keys: string[]): string {
|
|
for (const key of keys) {
|
|
const value = source[key]
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return value
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
export function readNumber(source: Record<string, unknown>, ...keys: string[]): number | null {
|
|
for (const key of keys) {
|
|
const value = source[key]
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function normalizeTimestampMs(value: number | null): number | null {
|
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
return null
|
|
}
|
|
return value >= 1_000_000_000_000 ? value : value * 1000
|
|
}
|
|
|
|
export function readStringArray(source: Record<string, unknown>, ...keys: string[]): string[] {
|
|
for (const key of keys) {
|
|
const value = source[key]
|
|
if (Array.isArray(value)) {
|
|
return value.filter((item): item is string => typeof item === 'string')
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
|
|
export function readBoolean(source: Record<string, unknown>, ...keys: string[]): boolean | null {
|
|
for (const key of keys) {
|
|
const value = source[key]
|
|
if (typeof value === 'boolean') {
|
|
return value
|
|
}
|
|
if (typeof value === 'number') {
|
|
if (value === 1) {
|
|
return true
|
|
}
|
|
if (value === 0) {
|
|
return false
|
|
}
|
|
}
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().toLowerCase()
|
|
if (normalized === 'true' || normalized === '1') {
|
|
return true
|
|
}
|
|
if (normalized === 'false' || normalized === '0') {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function readMissingSuit(source: Record<string, unknown> | null | undefined): string | null {
|
|
if (!source) {
|
|
return null
|
|
}
|
|
return readString(source, 'missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit') || null
|
|
}
|
|
|
|
export function readMissingSuitWithPresence(
|
|
source: Record<string, unknown> | null | undefined,
|
|
): { present: boolean; value: string | null } {
|
|
if (!source) {
|
|
return { present: false, value: null }
|
|
}
|
|
|
|
const keys = ['missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit']
|
|
const hasMissingSuitField = keys.some((key) => Object.prototype.hasOwnProperty.call(source, key))
|
|
if (!hasMissingSuitField) {
|
|
return { present: false, value: null }
|
|
}
|
|
|
|
return { present: true, value: readMissingSuit(source) }
|
|
}
|
|
|
|
export function tileToText(tile: Tile): string {
|
|
return `${tile.suit}${tile.value}`
|
|
}
|
|
|
|
export function readPlayerTurnPlayerId(payload: Record<string, unknown>): string {
|
|
return (
|
|
(typeof payload.player_id === 'string' && payload.player_id) ||
|
|
(typeof payload.playerId === 'string' && payload.playerId) ||
|
|
(typeof payload.PlayerID === 'string' && payload.PlayerID) ||
|
|
''
|
|
)
|
|
}
|
|
|
|
export function readPlayerTurnAllowActions(payload: Record<string, unknown>): string[] {
|
|
const source =
|
|
payload.allow_actions ??
|
|
payload.allowActions ??
|
|
payload.AllowActions ??
|
|
payload.available_actions ??
|
|
payload.availableActions ??
|
|
payload.AvailableActions
|
|
if (!Array.isArray(source)) {
|
|
return []
|
|
}
|
|
|
|
const actions = source
|
|
.filter((item): item is string => typeof item === 'string')
|
|
.map((item) => item.trim().toLowerCase())
|
|
.filter((item) => item.length > 0)
|
|
return Array.from(new Set(actions))
|
|
}
|
|
|
|
export function normalizeTile(tile: unknown): Tile | null {
|
|
const source = asRecord(tile)
|
|
if (!source) {
|
|
return null
|
|
}
|
|
|
|
const id = readNumber(source, 'id')
|
|
const suit = readString(source, 'suit') as Tile['suit'] | ''
|
|
const value = readNumber(source, 'value')
|
|
if (typeof id !== 'number' || !suit || typeof value !== 'number') {
|
|
return null
|
|
}
|
|
|
|
if (suit !== 'W' && suit !== 'T' && suit !== 'B') {
|
|
return null
|
|
}
|
|
|
|
return { id, suit, value }
|
|
}
|
|
|
|
export function normalizeTiles(value: unknown): Tile[] {
|
|
if (!Array.isArray(value)) {
|
|
return []
|
|
}
|
|
|
|
return value.map((item) => normalizeTile(item)).filter((item): item is Tile => Boolean(item))
|
|
}
|
|
|
|
export function normalizePendingClaim(
|
|
gameState: Record<string, unknown> | null | undefined,
|
|
loggedInUserId: string,
|
|
): PendingClaimState | undefined {
|
|
if (!gameState || !loggedInUserId) {
|
|
return undefined
|
|
}
|
|
|
|
const pendingClaim = asRecord(gameState.pending_claim ?? gameState.pendingClaim)
|
|
if (!pendingClaim) {
|
|
return undefined
|
|
}
|
|
|
|
const selfOptions = asRecord(pendingClaim[loggedInUserId])
|
|
if (!selfOptions) {
|
|
return undefined
|
|
}
|
|
|
|
const options: ClaimOptionState[] = []
|
|
if (readBoolean(selfOptions, 'hu')) {
|
|
options.push('hu')
|
|
}
|
|
if (readBoolean(selfOptions, 'gang')) {
|
|
options.push('gang')
|
|
}
|
|
if (readBoolean(selfOptions, 'peng')) {
|
|
options.push('peng')
|
|
}
|
|
if (options.length === 0) {
|
|
return undefined
|
|
}
|
|
|
|
options.push('pass')
|
|
|
|
return {
|
|
tile: normalizeTile(gameState.last_discard_tile ?? gameState.lastDiscardTile) ?? undefined,
|
|
fromPlayerId: readString(gameState, 'last_discard_by', 'lastDiscardBy') || undefined,
|
|
options,
|
|
}
|
|
}
|
|
|
|
export function normalizeMeldType(value: unknown, concealed = false): MeldState['type'] | null {
|
|
if (typeof value !== 'string') {
|
|
return concealed ? 'an_gang' : null
|
|
}
|
|
|
|
const normalized = value.replace(/[-\s]/g, '_').toLowerCase()
|
|
if (normalized === 'peng') {
|
|
return 'peng'
|
|
}
|
|
if (normalized === 'ming_gang' || normalized === 'gang' || normalized === 'gang_open') {
|
|
return concealed ? 'an_gang' : 'ming_gang'
|
|
}
|
|
if (normalized === 'an_gang' || normalized === 'angang' || normalized === 'concealed_gang') {
|
|
return 'an_gang'
|
|
}
|
|
|
|
return concealed ? 'an_gang' : null
|
|
}
|
|
|
|
export function normalizeMelds(value: unknown): PlayerState['melds'] {
|
|
if (!Array.isArray(value)) {
|
|
return []
|
|
}
|
|
|
|
return value
|
|
.map((item) => {
|
|
if (Array.isArray(item)) {
|
|
const tiles = normalizeTiles(item)
|
|
if (tiles.length === 3) {
|
|
return { type: 'peng', tiles, fromPlayerId: '' } satisfies MeldState
|
|
}
|
|
if (tiles.length === 4) {
|
|
return { type: 'ming_gang', tiles, fromPlayerId: '' } satisfies MeldState
|
|
}
|
|
return null
|
|
}
|
|
|
|
const source = asRecord(item)
|
|
if (!source) {
|
|
return null
|
|
}
|
|
|
|
const tiles = normalizeTiles(
|
|
source.tiles ??
|
|
source.meld_tiles ??
|
|
source.meldTiles ??
|
|
source.cards ??
|
|
source.card_list,
|
|
)
|
|
if (tiles.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const concealed =
|
|
readBoolean(source, 'concealed', 'is_concealed', 'isConcealed', 'hidden', 'is_hidden') ?? false
|
|
const explicitType = normalizeMeldType(
|
|
source.type ?? source.meld_type ?? source.meldType ?? source.kind,
|
|
concealed,
|
|
)
|
|
const type =
|
|
explicitType ??
|
|
(tiles.length === 4 ? (concealed ? 'an_gang' : 'ming_gang') : tiles.length === 3 ? 'peng' : null)
|
|
|
|
if (type === 'peng' || type === 'ming_gang') {
|
|
return {
|
|
type,
|
|
tiles,
|
|
fromPlayerId: readString(source, 'from_player_id', 'fromPlayerId'),
|
|
} satisfies MeldState
|
|
}
|
|
|
|
if (type === 'an_gang') {
|
|
return { type, tiles } satisfies MeldState
|
|
}
|
|
|
|
return null
|
|
})
|
|
.filter((item): item is MeldState => Boolean(item))
|
|
}
|