refactor(game): 重构游戏模块并添加WebSocket客户端
- 修改开发环境配置中的代理目标地址 - 移除旧的活动房间状态管理模块 - 移除成都麻将游戏相关的测试用例 - 添加新的游戏动作发送功能 - 实现WebSocket客户端类并支持自动重连 - 添加WebSocket消息处理器注册机制 - 创建游戏相关状态类型定义 - 添加ID生成工具函数 - 移除废弃的游戏相关模块和常量定义 - 添加WebSocket消息结构定义 - 重构游戏状态相关类型定义
This commit is contained in:
4
src/common/id.ts
Normal file
4
src/common/id.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// 生成 requestId / traceId
|
||||
export function createRequestId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './suits'
|
||||
export * from './ws-events'
|
||||
@@ -1,3 +0,0 @@
|
||||
export const MAHJONG_SUITS = ['wan', 'tong', 'tiao'] as const
|
||||
|
||||
export type MahjongSuit = (typeof MAHJONG_SUITS)[number]
|
||||
@@ -1,6 +0,0 @@
|
||||
export const WS_EVENT = {
|
||||
roomSnapshot: 'room_snapshot',
|
||||
roomState: 'room_state',
|
||||
gameState: 'game_state',
|
||||
myHand: 'my_hand',
|
||||
} as const
|
||||
@@ -1,11 +1,4 @@
|
||||
export type Suit = 'W' | 'T' | 'B'
|
||||
|
||||
export interface Tile {
|
||||
id: number
|
||||
suit: Suit
|
||||
value: number
|
||||
}
|
||||
|
||||
import type {Suit, Tile} from "../../types/tile.ts";
|
||||
|
||||
export class TileModel {
|
||||
id: number
|
||||
37
src/game/actions.ts
Normal file
37
src/game/actions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ActionButtonState } from "./types.ts"
|
||||
import { wsClient } from "@/ws/client" // 新增
|
||||
|
||||
export function sendGameAction(type: ActionButtonState['type']): void {
|
||||
// 原来是判断 ws,这里改成用 wsClient 的状态(简单处理)
|
||||
if (!currentUserId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = createRequestId(type)
|
||||
const payload: Record<string, unknown> = {}
|
||||
|
||||
if (type === 'discard' && selectedTile.value) {
|
||||
payload.tile = selectedTile.value
|
||||
payload.discard_tile = selectedTile.value
|
||||
payload.code = selectedTile.value
|
||||
}
|
||||
|
||||
actionPending.value = true
|
||||
|
||||
const message = {
|
||||
type,
|
||||
sender: currentUserId.value,
|
||||
target: 'room',
|
||||
roomId: roomState.value.id || roomId.value,
|
||||
seq: Date.now(),
|
||||
requestId,
|
||||
trace_id: createRequestId('trace'),
|
||||
payload,
|
||||
}
|
||||
|
||||
logWsSend(message)
|
||||
|
||||
wsClient.send(message) // ✅ 改这里
|
||||
|
||||
pushWsMessage(`[client] 请求${type} requestId=${requestId}`)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useChengduGameRoom } from './useChengduGameRoom'
|
||||
export type { ActionButtonState, ChengduGameRoomModel, SeatKey, SeatView } from './types'
|
||||
@@ -1,71 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
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: [],
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export * from './chengdu'
|
||||
5
src/game/types.ts
Normal file
5
src/game/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type ActionButtonState = {
|
||||
type: 'discard' | 'peng' | 'gang' | 'hu' | 'pass'
|
||||
label: string
|
||||
disabled: boolean
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||
import type { AuthSession } from '../../api/authed-request'
|
||||
import { refreshAccessToken } from '../../api/auth'
|
||||
import { getUserInfo } from '../../api/user'
|
||||
import type { AuthSession } from '../api/authed-request.ts'
|
||||
import { refreshAccessToken } from '../api/auth.ts'
|
||||
import { getUserInfo } from '../api/user.ts'
|
||||
import {
|
||||
activeRoomState,
|
||||
destroyActiveRoomState,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type RoomPlayerState,
|
||||
type RoomState,
|
||||
} from '../../store/active-room-store'
|
||||
import { readStoredAuth, writeStoredAuth } from '../../utils/auth-storage'
|
||||
import { readStoredAuth, writeStoredAuth } from '../utils/auth-storage.ts'
|
||||
import type {
|
||||
ActionButtonState,
|
||||
ActionEventLike,
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './tile'
|
||||
export * from './room-state'
|
||||
@@ -1 +0,0 @@
|
||||
export const DEFAULT_MAX_PLAYERS = 4
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface GamePlayerState {
|
||||
playerId: string
|
||||
index: number
|
||||
ready: boolean
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { EngineState } from './engine-state'
|
||||
import type { RuleState } from './rule-state'
|
||||
|
||||
export interface GameState {
|
||||
rule: RuleState | null
|
||||
state: EngineState | null
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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'
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface PlayerState {
|
||||
playerId: string
|
||||
hand: string[]
|
||||
melds: string[][]
|
||||
outTiles: string[]
|
||||
hasHu: boolean
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { PlayerState } from './player-state'
|
||||
|
||||
export interface RoomPlayerState extends PlayerState {
|
||||
index: number
|
||||
displayName?: string
|
||||
ready: boolean
|
||||
handCount?: number
|
||||
missingSuit?: string | null
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export type RoomStatus = 'waiting' | 'playing' | 'finished'
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface RuleState {
|
||||
name: string
|
||||
isBloodFlow: boolean
|
||||
hasHongZhong: boolean
|
||||
}
|
||||
15
src/modules/game/engine.ts
Normal file
15
src/modules/game/engine.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// 游戏对象
|
||||
|
||||
import type {GameState} from "../../types/state";
|
||||
|
||||
export interface Game {
|
||||
rule: Rule
|
||||
state: GameState
|
||||
}
|
||||
|
||||
// 规则配置(仅描述,不包含判定逻辑)
|
||||
export interface Rule {
|
||||
name: string // 玩法名称
|
||||
isBloodFlow: boolean // 是否血流玩法
|
||||
hasHongZhong: boolean // 是否包含红中
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
DEFAULT_MAX_PLAYERS,
|
||||
type RoomPlayerState,
|
||||
type RoomState,
|
||||
} from '../models/room-state'
|
||||
|
||||
export {
|
||||
DEFAULT_MAX_PLAYERS,
|
||||
type EngineState,
|
||||
type GamePlayerState,
|
||||
type GameState,
|
||||
type PlayerState,
|
||||
type RoomPlayerState,
|
||||
type RoomState,
|
||||
type RoomStatus,
|
||||
type RuleState,
|
||||
} from '../models/room-state'
|
||||
|
||||
function createInitialRoomState(): RoomState {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
gameType: 'chengdu',
|
||||
ownerId: '',
|
||||
maxPlayers: DEFAULT_MAX_PLAYERS,
|
||||
playerCount: 0,
|
||||
status: 'waiting',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
game: null,
|
||||
players: [],
|
||||
currentTurnIndex: null,
|
||||
myHand: [],
|
||||
}
|
||||
}
|
||||
|
||||
export const activeRoomState = ref<RoomState>(createInitialRoomState())
|
||||
|
||||
export function destroyActiveRoomState(): void {
|
||||
activeRoomState.value = createInitialRoomState()
|
||||
}
|
||||
|
||||
export function resetActiveRoomState(seed?: Partial<RoomState>): void {
|
||||
destroyActiveRoomState()
|
||||
if (!seed) {
|
||||
return
|
||||
}
|
||||
|
||||
activeRoomState.value = {
|
||||
...activeRoomState.value,
|
||||
...seed,
|
||||
players: seed.players ?? [],
|
||||
myHand: seed.myHand ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeActiveRoomState(next: RoomState): void {
|
||||
if (activeRoomState.value.id && next.id && next.id !== activeRoomState.value.id) {
|
||||
return
|
||||
}
|
||||
|
||||
activeRoomState.value = {
|
||||
...activeRoomState.value,
|
||||
...next,
|
||||
name: next.name || activeRoomState.value.name,
|
||||
gameType: next.gameType || activeRoomState.value.gameType,
|
||||
ownerId: next.ownerId || activeRoomState.value.ownerId,
|
||||
status: next.status || activeRoomState.value.status,
|
||||
createdAt: next.createdAt || activeRoomState.value.createdAt,
|
||||
updatedAt: next.updatedAt || activeRoomState.value.updatedAt,
|
||||
game: next.game ?? activeRoomState.value.game,
|
||||
players: next.players.length > 0 ? next.players : activeRoomState.value.players,
|
||||
currentTurnIndex:
|
||||
next.currentTurnIndex !== null ? next.currentTurnIndex : activeRoomState.value.currentTurnIndex,
|
||||
myHand: next.myHand.length > 0 ? next.myHand : activeRoomState.value.myHand,
|
||||
}
|
||||
}
|
||||
|
||||
export function hydrateActiveRoomFromSelection(input: {
|
||||
roomId: string
|
||||
roomName?: string
|
||||
gameType?: string
|
||||
ownerId?: string
|
||||
maxPlayers?: number
|
||||
playerCount?: number
|
||||
status?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
players?: RoomPlayerState[]
|
||||
currentTurnIndex?: number | null
|
||||
myHand?: string[]
|
||||
}): void {
|
||||
resetActiveRoomState({
|
||||
id: input.roomId,
|
||||
name: input.roomName ?? '',
|
||||
gameType: input.gameType ?? 'chengdu',
|
||||
ownerId: input.ownerId ?? '',
|
||||
maxPlayers: input.maxPlayers ?? DEFAULT_MAX_PLAYERS,
|
||||
playerCount: input.playerCount ?? 0,
|
||||
status: input.status ?? 'waiting',
|
||||
createdAt: input.createdAt ?? '',
|
||||
updatedAt: input.updatedAt ?? '',
|
||||
players: input.players ?? [],
|
||||
currentTurnIndex: input.currentTurnIndex ?? null,
|
||||
myHand: input.myHand ?? [],
|
||||
})
|
||||
}
|
||||
9
src/types/state/claimOption.ts
Normal file
9
src/types/state/claimOption.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const CLAIM_OPTIONS = {
|
||||
PENG: 'peng',
|
||||
GANG: 'gang',
|
||||
HU: 'hu',
|
||||
PASS: 'pass',
|
||||
} as const
|
||||
|
||||
export type ClaimOption =
|
||||
typeof CLAIM_OPTIONS[keyof typeof CLAIM_OPTIONS]
|
||||
9
src/types/state/gamePhase.ts
Normal file
9
src/types/state/gamePhase.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const GAME_PHASE = {
|
||||
WAITING: 'waiting',
|
||||
DEALING: 'dealing',
|
||||
PLAYING: 'playing',
|
||||
SETTLEMENT: 'settlement',
|
||||
} as const
|
||||
|
||||
export type GamePhase =
|
||||
typeof GAME_PHASE[keyof typeof GAME_PHASE]
|
||||
52
src/types/state/gamestate.ts
Normal file
52
src/types/state/gamestate.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type {Player} from "./player.ts";
|
||||
import type {Tile} from "../../models";
|
||||
import type {PendingClaim} from "./pendingClaim.ts";
|
||||
import type {HuWay} from "./huWay.ts";
|
||||
import type {GamePhase} from "./gamePhase.ts";
|
||||
|
||||
export interface GameState {
|
||||
// 当前阶段
|
||||
phase: GamePhase
|
||||
|
||||
// 庄家位置
|
||||
dealerIndex: number
|
||||
|
||||
// 当前操作玩家(座位)
|
||||
currentTurn: number
|
||||
|
||||
// 是否必须先摸牌
|
||||
needDraw: boolean
|
||||
|
||||
// 玩家列表
|
||||
players: Player[]
|
||||
|
||||
// ⚠️ 建议:只保留剩余数量(不要完整牌墙)
|
||||
remainingTiles: number
|
||||
|
||||
// 最近弃牌
|
||||
lastDiscardTile?: Tile
|
||||
|
||||
// 最近弃牌玩家
|
||||
lastDiscardBy?: string
|
||||
|
||||
// 操作响应窗口(碰/杠/胡)
|
||||
pendingClaim?: PendingClaim
|
||||
|
||||
// 胡牌玩家
|
||||
winners: string[]
|
||||
|
||||
// 分数(playerId -> score)
|
||||
scores: Record<string, number>
|
||||
|
||||
// 最近摸牌玩家
|
||||
lastDrawPlayerId: string
|
||||
|
||||
// 是否杠后补牌
|
||||
lastDrawFromGang: boolean
|
||||
|
||||
// 是否最后一张牌
|
||||
lastDrawIsLastTile: boolean
|
||||
|
||||
// 胡牌方式
|
||||
huWay: HuWay
|
||||
}
|
||||
12
src/types/state/huWay.ts
Normal file
12
src/types/state/huWay.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// 胡牌类型常量(用于避免直接写字符串)
|
||||
export const HU_WAY = {
|
||||
PING_HU: 'pinghu', // 平胡
|
||||
QI_DUI: 'qidui', // 七对
|
||||
PENG_PENG_HU: 'pengpenghu', // 碰碰胡
|
||||
QING_YI_SE: 'qingyise', // 清一色
|
||||
UNKNOWN: 'unknown', // 未知 / 未判定
|
||||
} as const
|
||||
|
||||
// 胡牌类型(从常量中推导)
|
||||
export type HuWay =
|
||||
typeof HU_WAY[keyof typeof HU_WAY]
|
||||
7
src/types/state/index.ts
Normal file
7
src/types/state/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './gamestate'
|
||||
export * from './player'
|
||||
export * from './meld'
|
||||
export * from './pendingClaim'
|
||||
export * from './claimOption'
|
||||
export * from './gamePhase'
|
||||
export * from './huWay'
|
||||
17
src/types/state/meld.ts
Normal file
17
src/types/state/meld.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type {Tile} from "../../models";
|
||||
|
||||
export type Meld =
|
||||
| {
|
||||
type: 'peng'
|
||||
tiles: Tile[]
|
||||
fromPlayerId: string
|
||||
}
|
||||
| {
|
||||
type: 'ming_gang'
|
||||
tiles: Tile[]
|
||||
fromPlayerId: string
|
||||
}
|
||||
| {
|
||||
type: 'an_gang'
|
||||
tiles: Tile[]
|
||||
}
|
||||
13
src/types/state/pendingClaim.ts
Normal file
13
src/types/state/pendingClaim.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type {Tile} from "../../models";
|
||||
import type {ClaimOption} from "./claimOption.ts";
|
||||
|
||||
export interface PendingClaim {
|
||||
// 当前被响应的牌
|
||||
tile: Tile
|
||||
|
||||
// 出牌人
|
||||
fromPlayerId: string
|
||||
|
||||
// 当前玩家可执行操作
|
||||
options: ClaimOption[]
|
||||
}
|
||||
22
src/types/state/player.ts
Normal file
22
src/types/state/player.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type {Tile} from "../../models";
|
||||
import type {Meld} from "./meld.ts";
|
||||
|
||||
export interface Player{
|
||||
playerId: string
|
||||
seatIndex: number
|
||||
|
||||
// 手牌(只有自己有完整数据,后端可控制)
|
||||
handTiles: Tile[]
|
||||
|
||||
// 副露(碰/杠)
|
||||
melds: Meld[]
|
||||
|
||||
// 出牌区
|
||||
discardTiles: Tile[]
|
||||
|
||||
// 分数
|
||||
score: number
|
||||
|
||||
// 是否准备
|
||||
isReady: boolean
|
||||
}
|
||||
9
src/types/tile.ts
Normal file
9
src/types/tile.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type Suit = 'W' | 'T' | 'B'
|
||||
|
||||
export interface Tile {
|
||||
id: number
|
||||
suit: Suit
|
||||
value: number
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import RightPlayerCard from '../components/game/RightPlayerCard.vue'
|
||||
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
|
||||
import LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
|
||||
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
|
||||
import {type SeatKey, useChengduGameRoom} from '../game/chengdu'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
136
src/ws/client.ts
Normal file
136
src/ws/client.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
type WsStatus = 'idle' | 'connecting' | 'connected' | 'closed' | 'error'
|
||||
|
||||
type MessageHandler = (data: any) => void
|
||||
type StatusHandler = (status: WsStatus) => void
|
||||
type ErrorHandler = (err: string) => void
|
||||
|
||||
class WsClient {
|
||||
private ws: WebSocket | null = null
|
||||
private url: string = ''
|
||||
private token: string = '' // 保存token
|
||||
|
||||
private status: WsStatus = 'idle'
|
||||
|
||||
private messageHandlers: MessageHandler[] = []
|
||||
private statusHandlers: StatusHandler[] = []
|
||||
private errorHandlers: ErrorHandler[] = []
|
||||
|
||||
private reconnectTimer: number | null = null
|
||||
private reconnectDelay = 2000
|
||||
|
||||
|
||||
// 构造带token的url
|
||||
private buildUrl(): string {
|
||||
if (!this.token) return this.url
|
||||
|
||||
const hasQuery = this.url.includes('?')
|
||||
const connector = hasQuery ? '&' : '?'
|
||||
|
||||
return `${this.url}${connector}token=${encodeURIComponent(this.token)}`
|
||||
}
|
||||
|
||||
|
||||
// 连接
|
||||
connect(url: string, token?: string) {
|
||||
if (this.ws && this.status === 'connected') {
|
||||
return
|
||||
}
|
||||
|
||||
this.url = url
|
||||
if (token !== undefined) {
|
||||
this.token = token // 保存token(用于重连)
|
||||
}
|
||||
|
||||
this.setStatus('connecting')
|
||||
|
||||
const finalUrl = this.buildUrl() // 使用带token的url
|
||||
this.ws = new WebSocket(finalUrl)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.setStatus('connected')
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
this.messageHandlers.forEach(fn => fn(data))
|
||||
} catch (e) {
|
||||
this.messageHandlers.forEach(fn => fn(event.data))
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.setStatus('error')
|
||||
this.emitError('WebSocket error')
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.setStatus('closed')
|
||||
this.tryReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 发送
|
||||
send(data: any) {
|
||||
if (this.ws && this.status === 'connected') {
|
||||
this.ws.send(JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 关闭
|
||||
close() {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this.clearReconnect()
|
||||
}
|
||||
|
||||
|
||||
// 订阅
|
||||
onMessage(handler: MessageHandler) {
|
||||
this.messageHandlers.push(handler)
|
||||
}
|
||||
|
||||
onStatusChange(handler: StatusHandler) {
|
||||
this.statusHandlers.push(handler)
|
||||
}
|
||||
|
||||
onError(handler: ErrorHandler) {
|
||||
this.errorHandlers.push(handler)
|
||||
}
|
||||
|
||||
|
||||
// 内部方法
|
||||
private setStatus(status: WsStatus) {
|
||||
this.status = status
|
||||
this.statusHandlers.forEach(fn => fn(status))
|
||||
}
|
||||
|
||||
private emitError(msg: string) {
|
||||
this.errorHandlers.forEach(fn => fn(msg))
|
||||
}
|
||||
|
||||
private tryReconnect() {
|
||||
this.clearReconnect()
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
if (this.url) {
|
||||
this.connect(this.url) // 重连自动带token
|
||||
}
|
||||
}, this.reconnectDelay)
|
||||
}
|
||||
|
||||
private clearReconnect() {
|
||||
if (this.reconnectTimer !== null) {
|
||||
window.clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 单例导出
|
||||
export const wsClient = new WsClient()
|
||||
25
src/ws/handler.ts
Normal file
25
src/ws/handler.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {wsClient} from './client'
|
||||
|
||||
type Handler = (msg: any) => void
|
||||
|
||||
const handlerMap: Record<string, Handler> = {}
|
||||
|
||||
|
||||
// 注册 handler
|
||||
export function registerHandler(type: string, handler: Handler) {
|
||||
handlerMap[type] = handler
|
||||
}
|
||||
|
||||
|
||||
// 初始化监听
|
||||
export function initWsHandler() {
|
||||
wsClient.onMessage((msg) => {
|
||||
const handler = handlerMap[msg.type]
|
||||
|
||||
if (handler) {
|
||||
handler(msg)
|
||||
} else {
|
||||
console.warn('[WS] 未处理消息:', msg.type, msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
31
src/ws/message.ts
Normal file
31
src/ws/message.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// 通用消息结构
|
||||
/**
|
||||
* 客户端 → 服务端(请求 / 操作)
|
||||
*/
|
||||
export interface ActionMessage<T = any> {
|
||||
type: string
|
||||
sender?: string
|
||||
target?: string
|
||||
roomId?: string
|
||||
seq?: number
|
||||
status?: string
|
||||
requestId?: string
|
||||
trace_id?: string
|
||||
|
||||
payload?: T
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端 → 客户端(事件 / 推送)
|
||||
*/
|
||||
export interface ActionEvent<T = any> {
|
||||
type: string
|
||||
target?: string
|
||||
roomId?: string
|
||||
seq?: number
|
||||
status?: string
|
||||
requestId?: string
|
||||
trace_id?: string
|
||||
|
||||
payload?: T
|
||||
}
|
||||
13
src/ws/url.ts
Normal file
13
src/ws/url.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws'
|
||||
|
||||
export function buildWsUrl(token: string): string {
|
||||
const baseUrl = /^wss?:\/\//.test(WS_BASE_URL)
|
||||
? new URL(WS_BASE_URL)
|
||||
: new URL(
|
||||
WS_BASE_URL.startsWith('/') ? WS_BASE_URL : `/${WS_BASE_URL}`,
|
||||
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
|
||||
)
|
||||
|
||||
baseUrl.searchParams.set('token', token)
|
||||
return baseUrl.toString()
|
||||
}
|
||||
Reference in New Issue
Block a user