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:
BIN
src/assets/images/actions/hu.png
Normal file
BIN
src/assets/images/actions/hu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/images/actions/pass.png
Normal file
BIN
src/assets/images/actions/pass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/images/actions/peng.png
Normal file
BIN
src/assets/images/actions/peng.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -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
2
src/constants/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './suits'
|
||||||
|
export * from './ws-events'
|
||||||
3
src/constants/suits.ts
Normal file
3
src/constants/suits.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const MAHJONG_SUITS = ['wan', 'tong', 'tiao'] as const
|
||||||
|
|
||||||
|
export type MahjongSuit = (typeof MAHJONG_SUITS)[number]
|
||||||
6
src/constants/ws-events.ts
Normal file
6
src/constants/ws-events.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const WS_EVENT = {
|
||||||
|
roomSnapshot: 'room_snapshot',
|
||||||
|
roomState: 'room_state',
|
||||||
|
gameState: 'game_state',
|
||||||
|
myHand: 'my_hand',
|
||||||
|
} as const
|
||||||
2
src/game/chengdu/index.ts
Normal file
2
src/game/chengdu/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { useChengduGameRoom } from './useChengduGameRoom'
|
||||||
|
export type { ActionButtonState, ChengduGameRoomModel, SeatKey, SeatView } from './types'
|
||||||
71
src/game/chengdu/parser-utils.ts
Normal file
71
src/game/chengdu/parser-utils.ts
Normal 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
|
||||||
|
}
|
||||||
421
src/game/chengdu/room-normalizers.ts
Normal file
421
src/game/chengdu/room-normalizers.ts
Normal 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
60
src/game/chengdu/types.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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
1
src/game/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './chengdu'
|
||||||
2
src/models/index.ts
Normal file
2
src/models/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './tile'
|
||||||
|
export * from './room-state'
|
||||||
1
src/models/room-state/constants.ts
Normal file
1
src/models/room-state/constants.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const DEFAULT_MAX_PLAYERS = 4
|
||||||
19
src/models/room-state/engine-state.ts
Normal file
19
src/models/room-state/engine-state.ts
Normal 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
|
||||||
|
}
|
||||||
5
src/models/room-state/game-player-state.ts
Normal file
5
src/models/room-state/game-player-state.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface GamePlayerState {
|
||||||
|
playerId: string
|
||||||
|
index: number
|
||||||
|
ready: boolean
|
||||||
|
}
|
||||||
7
src/models/room-state/game-state.ts
Normal file
7
src/models/room-state/game-state.ts
Normal 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
|
||||||
|
}
|
||||||
9
src/models/room-state/index.ts
Normal file
9
src/models/room-state/index.ts
Normal 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'
|
||||||
7
src/models/room-state/player-state.ts
Normal file
7
src/models/room-state/player-state.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface PlayerState {
|
||||||
|
playerId: string
|
||||||
|
hand: string[]
|
||||||
|
melds: string[][]
|
||||||
|
outTiles: string[]
|
||||||
|
hasHu: boolean
|
||||||
|
}
|
||||||
9
src/models/room-state/room-player-state.ts
Normal file
9
src/models/room-state/room-player-state.ts
Normal 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
|
||||||
|
}
|
||||||
19
src/models/room-state/room-state.ts
Normal file
19
src/models/room-state/room-state.ts
Normal 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[]
|
||||||
|
}
|
||||||
1
src/models/room-state/room-status.ts
Normal file
1
src/models/room-state/room-status.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type RoomStatus = 'waiting' | 'playing' | 'finished'
|
||||||
5
src/models/room-state/rule-state.ts
Normal file
5
src/models/room-state/rule-state.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface RuleState {
|
||||||
|
name: string
|
||||||
|
isBloodFlow: boolean
|
||||||
|
hasHongZhong: boolean
|
||||||
|
}
|
||||||
50
src/models/tile.ts
Normal file
50
src/models/tile.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user