feat(game): 添加房间管理和游戏启动功能
- 添加 setActiveRoom 导入和房间状态管理功能 - 实现房间所有者判断逻辑和玩家准备状态检查 - 添加游戏启动按钮和相关权限控制 - 实现房间信息请求和响应处理机制 - 添加 WebSocket 消息规范化处理函数 - 集成 tile 数据标准化和验证逻辑 - 更新 CSS 样式以支持新的界面元素 - 修复 Vite 配置以支持外部访问 - 优化 UI 组件布局和交互反馈机制
This commit is contained in:
@@ -592,9 +592,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ready-toggle {
|
.ready-toggle {
|
||||||
position: absolute;
|
|
||||||
right: 120px;
|
|
||||||
bottom: 70px;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -615,6 +612,10 @@
|
|||||||
animation: ready-toggle-pop 180ms ease-out;
|
animation: ready-toggle-pop 180ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ready-toggle-inline {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
.ready-toggle-label {
|
.ready-toggle-label {
|
||||||
color: #e5c472;
|
color: #e5c472;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -631,6 +632,15 @@
|
|||||||
transform: translateY(1px) scale(0.96);
|
transform: translateY(1px) scale(0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ready-toggle:disabled {
|
||||||
|
opacity: 0.56;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 244, 214, 0.06),
|
||||||
|
inset 0 -1px 0 rgba(0, 0, 0, 0.18),
|
||||||
|
0 4px 10px rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes ready-toggle-pop {
|
@keyframes ready-toggle-pop {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -762,6 +772,38 @@
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.waiting-owner-tip {
|
||||||
|
position: absolute;
|
||||||
|
top: 25%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 220px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border: 1px solid rgba(220, 191, 118, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #f2d68f;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-shadow:
|
||||||
|
-1px 0 rgba(0, 0, 0, 0.38),
|
||||||
|
0 1px rgba(0, 0, 0, 0.38),
|
||||||
|
1px 0 rgba(0, 0, 0, 0.38),
|
||||||
|
0 -1px rgba(0, 0, 0, 0.38);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(14, 55, 40, 0.78), rgba(8, 36, 27, 0.82)),
|
||||||
|
radial-gradient(circle at 20% 24%, rgba(237, 214, 157, 0.08), transparent 34%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 244, 214, 0.08),
|
||||||
|
0 8px 18px rgba(0, 0, 0, 0.16);
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.bottom-control-panel {
|
.bottom-control-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -771,6 +813,12 @@
|
|||||||
padding: 8px 14px 12px;
|
padding: 8px 14px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-action-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.control-copy {
|
.control-copy {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1085,10 +1133,21 @@
|
|||||||
width: calc(100% - 40px);
|
width: calc(100% - 40px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.waiting-owner-tip {
|
||||||
|
top: 23%;
|
||||||
|
min-width: 0;
|
||||||
|
width: calc(100% - 60px);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.bottom-control-panel {
|
.bottom-control-panel {
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-action-bar {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.action-orbs {
|
.action-orbs {
|
||||||
position: static;
|
position: static;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState {
|
|||||||
createdAt: input.createdAt ?? '',
|
createdAt: input.createdAt ?? '',
|
||||||
updatedAt: input.updatedAt ?? '',
|
updatedAt: input.updatedAt ?? '',
|
||||||
players: input.players ?? [],
|
players: input.players ?? [],
|
||||||
myHand: [],
|
myHand: input.myHand ?? [],
|
||||||
game: {
|
game: input.game ?? {
|
||||||
state: {
|
state: {
|
||||||
wall: [],
|
wall: [],
|
||||||
scores: {},
|
scores: {},
|
||||||
@@ -42,4 +42,4 @@ export function setActiveRoom(input: ActiveRoomSelectionInput) {
|
|||||||
// 使用房间状态
|
// 使用房间状态
|
||||||
export function useActiveRoomState() {
|
export function useActiveRoomState() {
|
||||||
return activeRoom
|
return activeRoom
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,4 +46,6 @@ export interface ActiveRoomSelectionInput {
|
|||||||
createdAt?: string
|
createdAt?: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
players?: RoomPlayerState[]
|
players?: RoomPlayerState[]
|
||||||
}
|
myHand?: string[]
|
||||||
|
game?: ActiveRoomState['game']
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {wsClient} from '../ws/client'
|
|||||||
import {sendWsMessage} from '../ws/sender'
|
import {sendWsMessage} from '../ws/sender'
|
||||||
import {buildWsUrl} from '../ws/url'
|
import {buildWsUrl} from '../ws/url'
|
||||||
import {useGameStore} from '../store/gameStore'
|
import {useGameStore} from '../store/gameStore'
|
||||||
import {useActiveRoomState} from '../store'
|
import {setActiveRoom, useActiveRoomState} from '../store'
|
||||||
import type {PlayerState} from '../types/state'
|
import type {PlayerState} from '../types/state'
|
||||||
import type {Tile} from '../types/tile'
|
import type {Tile} from '../types/tile'
|
||||||
|
|
||||||
@@ -65,8 +65,10 @@ const wsError = ref('')
|
|||||||
const selectedTile = ref<string | null>(null)
|
const selectedTile = ref<string | null>(null)
|
||||||
const leaveRoomPending = ref(false)
|
const leaveRoomPending = ref(false)
|
||||||
const readyTogglePending = ref(false)
|
const readyTogglePending = ref(false)
|
||||||
|
const startGamePending = ref(false)
|
||||||
let clockTimer: number | null = null
|
let clockTimer: number | null = null
|
||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
|
let pendingRoomInfoRequest = false
|
||||||
|
|
||||||
const menuOpen = ref(false)
|
const menuOpen = ref(false)
|
||||||
const isTrustMode = ref(false)
|
const isTrustMode = ref(false)
|
||||||
@@ -238,8 +240,46 @@ const myReadyState = computed(() => {
|
|||||||
return Boolean(myPlayer.value?.isReady)
|
return Boolean(myPlayer.value?.isReady)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isRoomOwner = computed(() => {
|
||||||
|
const room = activeRoom.value
|
||||||
|
return Boolean(
|
||||||
|
room &&
|
||||||
|
room.roomId === gameStore.roomId &&
|
||||||
|
room.ownerId &&
|
||||||
|
loggedInUserId.value &&
|
||||||
|
room.ownerId === loggedInUserId.value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const allPlayersReady = computed(() => {
|
||||||
|
return (
|
||||||
|
gamePlayers.value.length === 4 &&
|
||||||
|
gamePlayers.value.every((player) => Boolean(player.isReady))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const showStartGameButton = computed(() => {
|
||||||
|
return gameStore.phase === 'waiting' && allPlayersReady.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const showWaitingOwnerTip = computed(() => {
|
||||||
|
return showStartGameButton.value && !isRoomOwner.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const canStartGame = computed(() => {
|
||||||
|
return showStartGameButton.value && isRoomOwner.value && !startGamePending.value
|
||||||
|
})
|
||||||
|
|
||||||
const showReadyToggle = computed(() => {
|
const showReadyToggle = computed(() => {
|
||||||
return gameStore.phase === 'waiting' && Boolean(gameStore.roomId)
|
if (gameStore.phase !== 'waiting' || !gameStore.roomId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showStartGameButton.value) {
|
||||||
|
return !isRoomOwner.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
function applyPlayerReadyState(playerId: string, ready: boolean): void {
|
function applyPlayerReadyState(playerId: string, ready: boolean): void {
|
||||||
@@ -284,6 +324,370 @@ function syncReadyStatesFromRoomUpdate(payload: RoomPlayerUpdatePayload): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWsType(type: string): string {
|
||||||
|
return type.replace(/[-\s]/g, '_').toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === 'object' ? value as Record<string, unknown> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(source: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key]
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(source: Record<string, unknown>, ...keys: string[]): number | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key]
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringArray(source: Record<string, unknown>, ...keys: string[]): string[] {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key]
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((item): item is string => typeof item === 'string')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBoolean(source: Record<string, unknown>, ...keys: string[]): boolean | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key]
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileToText(tile: Tile): string {
|
||||||
|
return `${tile.suit}${tile.value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTile(tile: unknown): Tile | null {
|
||||||
|
const source = asRecord(tile)
|
||||||
|
if (!source) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = readNumber(source, 'id')
|
||||||
|
const suit = readString(source, 'suit') as Tile['suit'] | ''
|
||||||
|
const value = readNumber(source, 'value')
|
||||||
|
if (typeof id !== 'number' || !suit || typeof value !== 'number') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suit !== 'W' && suit !== 'T' && suit !== 'B') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
suit,
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTiles(value: unknown): Tile[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => normalizeTile(item))
|
||||||
|
.filter((item): item is Tile => Boolean(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRoomInfo(): void {
|
||||||
|
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
||||||
|
const roomId = routeRoomId || gameStore.roomId || activeRoom.value?.roomId || ''
|
||||||
|
if (!roomId) {
|
||||||
|
pendingRoomInfoRequest = true
|
||||||
|
wsMessages.value.push('[client] get_room_info pending: missing roomId')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsStatus.value !== 'connected') {
|
||||||
|
pendingRoomInfoRequest = true
|
||||||
|
wsMessages.value.push(`[client] get_room_info pending: ws=${wsStatus.value}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRoomInfoRequest = false
|
||||||
|
wsMessages.value.push(`[client] get_room_info ${roomId}`)
|
||||||
|
sendWsMessage({
|
||||||
|
type: 'get_room_info',
|
||||||
|
roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: roomId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRoomInfoResponse(message: unknown): void {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedType = normalizeWsType(source.type)
|
||||||
|
if (normalizedType !== 'GET_ROOM_INFO' && normalizedType !== 'ROOM_INFO') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(source.payload) ?? source
|
||||||
|
const summary = asRecord(payload.summary) ?? asRecord(payload.room_summary) ?? null
|
||||||
|
const publicState = asRecord(payload.public) ?? asRecord(payload.public_state) ?? null
|
||||||
|
const privateState = asRecord(payload.private) ?? asRecord(payload.private_state) ?? null
|
||||||
|
const roomId =
|
||||||
|
readString(summary ?? {}, 'room_id', 'roomId') ||
|
||||||
|
readString(publicState ?? {}, 'room_id', 'roomId') ||
|
||||||
|
readString(privateState ?? {}, 'room_id', 'roomId') ||
|
||||||
|
readString(payload, 'room_id', 'roomId') ||
|
||||||
|
readString(source, 'roomId')
|
||||||
|
if (!roomId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryPlayers = Array.isArray(summary?.players) ? summary.players : []
|
||||||
|
const publicPlayers = Array.isArray(publicState?.players) ? publicState.players : []
|
||||||
|
const playerMap = new Map<string, {
|
||||||
|
roomPlayer: {
|
||||||
|
index: number
|
||||||
|
playerId: string
|
||||||
|
displayName?: string
|
||||||
|
missingSuit?: string | null
|
||||||
|
ready: boolean
|
||||||
|
hand: string[]
|
||||||
|
melds: string[]
|
||||||
|
outTiles: string[]
|
||||||
|
hasHu: boolean
|
||||||
|
}
|
||||||
|
gamePlayer: {
|
||||||
|
playerId: string
|
||||||
|
seatIndex: number
|
||||||
|
displayName?: string
|
||||||
|
avatarURL?: string
|
||||||
|
missingSuit?: string | null
|
||||||
|
isReady: boolean
|
||||||
|
handTiles: Tile[]
|
||||||
|
melds: PlayerState['melds']
|
||||||
|
discardTiles: Tile[]
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
|
||||||
|
summaryPlayers.forEach((item, fallbackIndex) => {
|
||||||
|
const player = asRecord(item)
|
||||||
|
if (!player) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerId = readString(player, 'player_id', 'PlayerID', 'id', 'user_id')
|
||||||
|
if (!playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const seatIndex = readNumber(player, 'index', 'Index', 'seat_index', 'seatIndex') ?? fallbackIndex
|
||||||
|
const displayName =
|
||||||
|
readString(player, 'player_name', 'PlayerName', 'display_name', 'displayName', 'nickname', 'username') ||
|
||||||
|
(playerId === loggedInUserId.value ? loggedInUserName.value : '')
|
||||||
|
const ready = readBoolean(player, 'ready', 'Ready') ?? false
|
||||||
|
const missingSuit = readString(player, 'missing_suit', 'MissingSuit') || null
|
||||||
|
|
||||||
|
playerMap.set(playerId, {
|
||||||
|
roomPlayer: {
|
||||||
|
index: seatIndex,
|
||||||
|
playerId,
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
missingSuit,
|
||||||
|
ready,
|
||||||
|
hand: [],
|
||||||
|
melds: [],
|
||||||
|
outTiles: [],
|
||||||
|
hasHu: false,
|
||||||
|
},
|
||||||
|
gamePlayer: {
|
||||||
|
playerId,
|
||||||
|
seatIndex,
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
avatarURL: readString(player, 'avatar_url', 'AvatarUrl', 'avatar', 'avatarUrl') || undefined,
|
||||||
|
missingSuit,
|
||||||
|
isReady: ready,
|
||||||
|
handTiles: [],
|
||||||
|
melds: [],
|
||||||
|
discardTiles: [],
|
||||||
|
score: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
publicPlayers.forEach((item, fallbackIndex) => {
|
||||||
|
const player = asRecord(item)
|
||||||
|
if (!player) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerId = readString(player, 'player_id', 'PlayerID')
|
||||||
|
if (!playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = playerMap.get(playerId)
|
||||||
|
const seatIndex =
|
||||||
|
existing?.gamePlayer.seatIndex ??
|
||||||
|
readNumber(player, 'index', 'Index', 'seat_index', 'seatIndex') ??
|
||||||
|
fallbackIndex
|
||||||
|
const displayName = existing?.gamePlayer.displayName || (playerId === loggedInUserId.value ? loggedInUserName.value : '')
|
||||||
|
const missingSuit = readString(player, 'missing_suit', 'MissingSuit') || existing?.gamePlayer.missingSuit || null
|
||||||
|
const handCount = readNumber(player, 'hand_count', 'handCount') ?? 0
|
||||||
|
const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles)
|
||||||
|
|
||||||
|
playerMap.set(playerId, {
|
||||||
|
roomPlayer: {
|
||||||
|
index: seatIndex,
|
||||||
|
playerId,
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
missingSuit,
|
||||||
|
ready: existing?.roomPlayer.ready ?? false,
|
||||||
|
hand: Array.from({length: handCount}, () => ''),
|
||||||
|
melds: [],
|
||||||
|
outTiles: outTiles.map((tile) => tileToText(tile)),
|
||||||
|
hasHu: Boolean(player.has_hu ?? player.hasHu),
|
||||||
|
},
|
||||||
|
gamePlayer: {
|
||||||
|
playerId,
|
||||||
|
seatIndex,
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
avatarURL: existing?.gamePlayer.avatarURL,
|
||||||
|
missingSuit,
|
||||||
|
isReady: existing?.gamePlayer.isReady ?? false,
|
||||||
|
handTiles: existing?.gamePlayer.handTiles ?? [],
|
||||||
|
melds: existing?.gamePlayer.melds ?? [],
|
||||||
|
discardTiles: outTiles,
|
||||||
|
score: existing?.gamePlayer.score ?? 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const privateHand = normalizeTiles(privateState?.hand)
|
||||||
|
if (loggedInUserId.value && playerMap.has(loggedInUserId.value)) {
|
||||||
|
const current = playerMap.get(loggedInUserId.value)
|
||||||
|
if (current) {
|
||||||
|
current.roomPlayer.hand = privateHand.map((tile) => tileToText(tile))
|
||||||
|
current.gamePlayer.handTiles = privateHand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = Array.from(playerMap.values()).sort((a, b) => a.gamePlayer.seatIndex - b.gamePlayer.seatIndex)
|
||||||
|
|
||||||
|
const previousPlayers = gameStore.players
|
||||||
|
const nextPlayers: typeof gameStore.players = {}
|
||||||
|
players.forEach(({gamePlayer}) => {
|
||||||
|
const previous = previousPlayers[gamePlayer.playerId]
|
||||||
|
const score = (publicState?.scores && typeof publicState.scores === 'object'
|
||||||
|
? (publicState.scores as Record<string, unknown>)[gamePlayer.playerId]
|
||||||
|
: undefined)
|
||||||
|
nextPlayers[gamePlayer.playerId] = {
|
||||||
|
playerId: gamePlayer.playerId,
|
||||||
|
seatIndex: gamePlayer.seatIndex,
|
||||||
|
displayName: gamePlayer.displayName ?? previous?.displayName,
|
||||||
|
avatarURL: gamePlayer.avatarURL ?? previous?.avatarURL,
|
||||||
|
missingSuit: gamePlayer.missingSuit ?? previous?.missingSuit,
|
||||||
|
handTiles: gamePlayer.handTiles.length > 0 ? gamePlayer.handTiles : previous?.handTiles ?? [],
|
||||||
|
melds: gamePlayer.melds.length > 0 ? gamePlayer.melds : previous?.melds ?? [],
|
||||||
|
discardTiles: gamePlayer.discardTiles.length > 0 ? gamePlayer.discardTiles : previous?.discardTiles ?? [],
|
||||||
|
score: typeof score === 'number' ? score : previous?.score ?? gamePlayer.score ?? 0,
|
||||||
|
isReady: gamePlayer.isReady,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const status =
|
||||||
|
readString(publicState ?? {}, 'status') ||
|
||||||
|
readString(summary ?? {}, 'status') ||
|
||||||
|
readString(publicState ?? {}, 'phase') ||
|
||||||
|
'waiting'
|
||||||
|
const phase =
|
||||||
|
readString(publicState ?? {}, 'phase') ||
|
||||||
|
readString(summary ?? {}, 'status') ||
|
||||||
|
'waiting'
|
||||||
|
const wallCount = readNumber(publicState ?? {}, 'wall_count', 'wallCount')
|
||||||
|
const dealerIndex = readNumber(publicState ?? {}, 'dealer_index', 'dealerIndex')
|
||||||
|
const currentTurnSeat = readNumber(publicState ?? {}, 'current_turn', 'currentTurn')
|
||||||
|
const currentTurnPlayerId = readString(publicState ?? {}, 'current_turn_player', 'currentTurnPlayer')
|
||||||
|
const currentTurn =
|
||||||
|
currentTurnSeat ??
|
||||||
|
(currentTurnPlayerId && nextPlayers[currentTurnPlayerId]
|
||||||
|
? nextPlayers[currentTurnPlayerId].seatIndex
|
||||||
|
: null)
|
||||||
|
|
||||||
|
gameStore.roomId = roomId
|
||||||
|
if (Object.keys(nextPlayers).length > 0) {
|
||||||
|
gameStore.players = nextPlayers
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseMap: Record<string, typeof gameStore.phase> = {
|
||||||
|
waiting: 'waiting',
|
||||||
|
dealing: 'dealing',
|
||||||
|
playing: 'playing',
|
||||||
|
action: 'action',
|
||||||
|
settlement: 'settlement',
|
||||||
|
finished: 'settlement',
|
||||||
|
}
|
||||||
|
gameStore.phase = phaseMap[phase] ?? gameStore.phase
|
||||||
|
if (typeof wallCount === 'number') {
|
||||||
|
gameStore.remainingTiles = wallCount
|
||||||
|
}
|
||||||
|
if (typeof dealerIndex === 'number') {
|
||||||
|
gameStore.dealerIndex = dealerIndex
|
||||||
|
}
|
||||||
|
if (typeof currentTurn === 'number') {
|
||||||
|
gameStore.currentTurn = currentTurn
|
||||||
|
}
|
||||||
|
const scores = asRecord(publicState?.scores)
|
||||||
|
if (scores) {
|
||||||
|
gameStore.scores = Object.fromEntries(
|
||||||
|
Object.entries(scores).filter(([, value]) => typeof value === 'number'),
|
||||||
|
) as Record<string, number>
|
||||||
|
}
|
||||||
|
gameStore.winners = readStringArray(publicState ?? {}, 'winners')
|
||||||
|
|
||||||
|
setActiveRoom({
|
||||||
|
roomId,
|
||||||
|
roomName: readString(summary ?? {}, 'name', 'room_name', 'roomName') || activeRoom.value?.roomName || roomName.value,
|
||||||
|
gameType: readString(summary ?? {}, 'game_type', 'gameType') || activeRoom.value?.gameType || 'chengdu',
|
||||||
|
ownerId: readString(summary ?? {}, 'owner_id', 'ownerId') || activeRoom.value?.ownerId || '',
|
||||||
|
maxPlayers: readNumber(summary ?? {}, 'max_players', 'maxPlayers') ?? activeRoom.value?.maxPlayers ?? 4,
|
||||||
|
playerCount: readNumber(summary ?? {}, 'player_count', 'playerCount') ?? players.length,
|
||||||
|
status,
|
||||||
|
createdAt: readString(summary ?? {}, 'created_at', 'createdAt') || activeRoom.value?.createdAt || '',
|
||||||
|
updatedAt: readString(summary ?? {}, 'updated_at', 'updatedAt') || activeRoom.value?.updatedAt || '',
|
||||||
|
players: players.map((item) => item.roomPlayer),
|
||||||
|
myHand: privateHand.map((tile) => tileToText(tile)),
|
||||||
|
game: {
|
||||||
|
state: {
|
||||||
|
wall: Array.from({length: wallCount ?? 0}, (_, index) => `wall-${index}`),
|
||||||
|
scores: gameStore.scores,
|
||||||
|
dealerIndex: typeof dealerIndex === 'number' ? dealerIndex : activeRoom.value?.game?.state?.dealerIndex ?? -1,
|
||||||
|
currentTurn: typeof currentTurn === 'number' ? currentTurn : activeRoom.value?.game?.state?.currentTurn ?? -1,
|
||||||
|
phase,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const networkLabel = computed(() => {
|
const networkLabel = computed(() => {
|
||||||
const map: Record<WsStatus, string> = {
|
const map: Record<WsStatus, string> = {
|
||||||
connected: '已连接',
|
connected: '已连接',
|
||||||
@@ -450,7 +854,7 @@ function toGameAction(message: unknown): GameAction | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = source.type.replace(/[-\s]/g, '_').toUpperCase()
|
const type = normalizeWsType(source.type)
|
||||||
const payload = source.payload
|
const payload = source.payload
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -509,7 +913,7 @@ function handleReadyStateResponse(message: unknown): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = source.type.replace(/[-\s]/g, '_').toUpperCase()
|
const type = normalizeWsType(source.type)
|
||||||
if (type !== 'SET_READY') {
|
if (type !== 'SET_READY') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -754,6 +1158,21 @@ function toggleReadyState(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startGame(): void {
|
||||||
|
if (!canStartGame.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startGamePending.value = true
|
||||||
|
sendWsMessage({
|
||||||
|
type: 'start_game',
|
||||||
|
roomId: gameStore.roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: gameStore.roomId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function handleLeaveRoom(): void {
|
function handleLeaveRoom(): void {
|
||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
backHall()
|
backHall()
|
||||||
@@ -824,24 +1243,33 @@ function hydrateFromActiveRoom(routeRoomId: string): void {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
||||||
|
pendingRoomInfoRequest = true
|
||||||
void ensureCurrentUserLoaded().finally(() => {
|
void ensureCurrentUserLoaded().finally(() => {
|
||||||
hydrateFromActiveRoom(routeRoomId)
|
hydrateFromActiveRoom(routeRoomId)
|
||||||
if (routeRoomId) {
|
if (routeRoomId) {
|
||||||
gameStore.roomId = routeRoomId
|
gameStore.roomId = routeRoomId
|
||||||
}
|
}
|
||||||
|
requestRoomInfo()
|
||||||
})
|
})
|
||||||
|
|
||||||
const handler = (status: WsStatus) => {
|
const handler = (status: WsStatus) => {
|
||||||
wsStatus.value = status
|
wsStatus.value = status
|
||||||
|
if (status === 'connected' && pendingRoomInfoRequest) {
|
||||||
|
requestRoomInfo()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wsClient.onMessage((msg: unknown) => {
|
wsClient.onMessage((msg: unknown) => {
|
||||||
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
|
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
|
||||||
wsMessages.value.push(`[server] ${text}`)
|
wsMessages.value.push(`[server] ${text}`)
|
||||||
|
handleRoomInfoResponse(msg)
|
||||||
handleReadyStateResponse(msg)
|
handleReadyStateResponse(msg)
|
||||||
const gameAction = toGameAction(msg)
|
const gameAction = toGameAction(msg)
|
||||||
if (gameAction) {
|
if (gameAction) {
|
||||||
dispatchGameAction(gameAction)
|
dispatchGameAction(gameAction)
|
||||||
|
if (gameAction.type === 'GAME_START') {
|
||||||
|
startGamePending.value = false
|
||||||
|
}
|
||||||
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
|
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
|
||||||
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
||||||
readyTogglePending.value = false
|
readyTogglePending.value = false
|
||||||
@@ -1000,18 +1428,33 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<WindSquare class="center-wind-square" :seat-winds="seatWinds"/>
|
<WindSquare class="center-wind-square" :seat-winds="seatWinds"/>
|
||||||
|
|
||||||
<button
|
<div v-if="showWaitingOwnerTip" class="waiting-owner-tip">
|
||||||
v-if="showReadyToggle"
|
<span>等待房主开始游戏</span>
|
||||||
class="ready-toggle"
|
</div>
|
||||||
type="button"
|
|
||||||
:disabled="readyTogglePending"
|
|
||||||
@click="toggleReadyState"
|
|
||||||
>
|
|
||||||
<span class="ready-toggle-label">{{ myReadyState ? '取 消' : '准 备' }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="bottom-control-panel">
|
<div class="bottom-control-panel">
|
||||||
|
<div v-if="showReadyToggle || showStartGameButton" class="bottom-action-bar">
|
||||||
|
<button
|
||||||
|
v-if="showReadyToggle"
|
||||||
|
class="ready-toggle ready-toggle-inline"
|
||||||
|
type="button"
|
||||||
|
:disabled="readyTogglePending"
|
||||||
|
@click="toggleReadyState"
|
||||||
|
>
|
||||||
|
<span class="ready-toggle-label">{{ myReadyState ? '取 消' : '准 备' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="showStartGameButton && isRoomOwner"
|
||||||
|
class="ready-toggle ready-toggle-inline"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canStartGame"
|
||||||
|
@click="startGame"
|
||||||
|
>
|
||||||
|
<span class="ready-toggle-label">开始游戏</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="player-hand" v-if="myHandTiles.length > 0">
|
<div class="player-hand" v-if="myHandTiles.length > 0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
return {
|
return {
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
server: {
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
proxy: {
|
proxy: {
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: wsProxyTarget,
|
target: wsProxyTarget,
|
||||||
|
|||||||
Reference in New Issue
Block a user