feat(game): 添加房间玩家状态同步功能

- 定义 RoomPlayerUpdatePayload 接口用于处理房间状态更新
- 在游戏动作中新增 ROOM_PLAYER_UPDATE 类型支持
- 实现游戏状态管理器中的房间玩家更新逻辑
- 重构成都麻将页面以使用新的状态管理机制
- 添加从 WebSocket 消息转换为游戏动作的功能
- 更新房间离开时的 WebSocket 消息发送逻辑
- 优化玩家手牌显示和选择逻辑
- 调整房间状态显示逻辑以匹配新状态模型
- 修复座位索引计算和庄家标识逻辑
- 更新全局样式中的图标按钮样式
- 替换大厅页面的刷新图标为 SVG 图像
- 升级 pnpm 包管理器版本
- 扩展玩家状态类型定义以支持显示名称和缺门信息
This commit is contained in:
2026-03-25 17:26:18 +08:00
parent 2737971608
commit 66834d8a7a
10 changed files with 352 additions and 82 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import deskImage from '../assets/images/desk/desk_01.png'
import wanIcon from '../assets/images/flowerClolor/wan.png'
import tongIcon from '../assets/images/flowerClolor/tong.png'
@@ -16,56 +16,37 @@ import TopPlayerCard from '../components/game/TopPlayerCard.vue'
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 } from '../game/seat'
import { readStoredAuth } from '../utils/auth-storage'
import type { WsStatus } from '../ws/client'
import { wsClient } from '../ws/client'
import { buildWsUrl } from '../ws/url'
import type {ActiveRoomState, RoomPlayerState} from "../store/state.ts";
import {readActiveRoomSnapshot} from "../store/storage.ts";
interface SeatViewModel {
key: SeatKey
player?: RoomPlayerState
isSelf: boolean
isTurn: boolean
}
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
import type {SeatKey} from '../game/seat'
import type {GameAction} from '../game/actions'
import {dispatchGameAction} from '../game/dispatcher'
import {readStoredAuth} from '../utils/auth-storage'
import type {WsStatus} from '../ws/client'
import {wsClient} from '../ws/client'
import {sendWsMessage} from '../ws/sender'
import {buildWsUrl} from '../ws/url'
import {useGameStore} from '../store/gameStore'
import {useActiveRoomState} from '../store'
import type {PlayerState} from '../types/state'
import type {Tile} from '../types/tile'
const gameStore = useGameStore()
const activeRoom = useActiveRoomState()
const route = useRoute()
const router = useRouter()
const auth = ref(readStoredAuth())
function createFallbackRoomState(): ActiveRoomState {
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
const routeRoomName = typeof route.query.roomName === 'string' ? route.query.roomName : ''
return {
roomId: routeRoomId,
roomName: routeRoomName,
gameType: 'chengdu',
ownerId: '',
maxPlayers: 4,
playerCount: 0,
status: 'waiting',
createdAt: '',
updatedAt: '',
players: [],
myHand: [],
game: {
state: {
wall: [],
scores: {},
dealerIndex: -1,
currentTurn: -1,
phase: 'waiting',
},
},
}
type DisplayPlayer = PlayerState & {
displayName?: string
missingSuit?: string | null
}
const activeRoom = ref<ActiveRoomState>(readActiveRoomSnapshot() ?? createFallbackRoomState())
interface SeatViewModel {
key: SeatKey
player?: DisplayPlayer
isSelf: boolean
isTurn: boolean
}
const now = ref(Date.now())
const wsStatus = ref<WsStatus>('idle')
@@ -97,32 +78,70 @@ const loggedInUserName = computed(() => {
return auth.value?.user?.nickname || auth.value?.user?.username || ''
})
const myPlayer = computed(() => {
return gameStore.players[loggedInUserId.value]
})
const myHandTiles = computed(() => {
return myPlayer.value?.handTiles ?? []
})
const remainingTiles = computed(() => {
return gameStore.remainingTiles
})
const gamePlayers = computed<DisplayPlayer[]>(() => {
return Object.values(gameStore.players).sort((a, b) => a.seatIndex - b.seatIndex) as DisplayPlayer[]
})
const roomName = computed(() => {
const queryRoomName = typeof route.query.roomName === 'string' ? route.query.roomName : ''
return queryRoomName || activeRoom.value.roomName || ''
const activeRoomName =
activeRoom.value && activeRoom.value.roomId === gameStore.roomId
? activeRoom.value.roomName
: ''
return queryRoomName || activeRoomName || `房间 ${gameStore.roomId || '--'}`
})
const roomState = computed(() => {
const status = gameStore.phase === 'waiting' ? 'waiting' : gameStore.phase === 'settlement' ? 'finished' : 'playing'
const wall = Array.from({length: remainingTiles.value}, (_, index) => `wall-${index}`)
const maxPlayers =
activeRoom.value && activeRoom.value.roomId === gameStore.roomId
? activeRoom.value.maxPlayers
: 4
return {
...activeRoom.value,
name: activeRoom.value.roomName,
roomId: gameStore.roomId,
name: roomName.value,
playerCount: gamePlayers.value.length,
maxPlayers,
status,
game: {
state: {
wall,
dealerIndex: gameStore.dealerIndex,
currentTurn: gameStore.currentTurn,
phase: gameStore.phase,
},
},
}
})
const seatViews = computed<SeatViewModel[]>(() => {
const players = roomState.value.players ?? []
const players = gamePlayers.value
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
const selfIndex = players.findIndex((player) => player.playerId === loggedInUserId.value)
const currentTurn = roomState.value.game?.state?.currentTurn
const selfSeatIndex = myPlayer.value?.seatIndex ?? players.find((player) => player.playerId === loggedInUserId.value)?.seatIndex ?? 0
const currentTurn = gameStore.currentTurn
return players.slice(0, 4).map((player, index) => {
const relativeIndex = selfIndex >= 0 ? (index - selfIndex + 4) % 4 : index
return players.slice(0, 4).map((player) => {
const relativeIndex = (player.seatIndex - selfSeatIndex + 4) % 4
const seatKey = tableOrder[relativeIndex] ?? 'top'
return {
key: seatKey,
player,
isSelf: player.playerId === loggedInUserId.value,
isTurn: typeof currentTurn === 'number' && player.index === currentTurn,
isTurn: player.seatIndex === currentTurn,
}
})
})
@@ -130,14 +149,14 @@ const seatViews = computed<SeatViewModel[]>(() => {
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
const currentPhaseText = computed(() => {
const phase = roomState.value.game?.state?.phase
const map: Record<string, string> = {
waiting: '等待中',
dealing: '发牌中',
playing: '对局中',
finished: '已结束',
action: '操作中',
settlement: '已结算',
}
return phase ? (map[phase] ?? phase) : '等待中'
return map[gameStore.phase] ?? gameStore.phase
})
const roomStatusText = computed(() => {
@@ -172,7 +191,7 @@ const formattedClock = computed(() => {
})
const wallBacks = computed<Record<SeatKey, string[]>>(() => {
const wallSize = roomState.value.game?.state?.wall?.length ?? 0
const wallSize = remainingTiles.value
const perSide = Math.max(6, Math.ceil((wallSize || 48) / 4 / 2))
return {
@@ -207,11 +226,11 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
continue
}
const displayName = seat.player.displayName || `玩家${seat.player.index + 1}`
const displayName = seat.player.displayName || `玩家${seat.player.seatIndex + 1}`
result[seat.key] = {
avatar: seat.isSelf ? '我' : String(index + 1),
name: seat.isSelf ? '你自己' : displayName,
dealer: seat.player.index === dealerIndex,
dealer: seat.player.seatIndex === dealerIndex,
isTurn: seat.isTurn,
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
}
@@ -295,6 +314,69 @@ function selectTile(tile: string): void {
selectedTile.value = selectedTile.value === tile ? null : tile
}
function formatTile(tile: Tile): string {
return `${tile.suit}${tile.value}`
}
function toGameAction(message: unknown): GameAction | null {
if (!message || typeof message !== 'object') {
return null
}
const source = message as Record<string, unknown>
if (typeof source.type !== 'string') {
return null
}
const type = source.type.replace(/[-\s]/g, '_').toUpperCase()
const payload = source.payload
switch (type) {
case 'GAME_INIT':
if (payload && typeof payload === 'object') {
return {type: 'GAME_INIT', payload: payload as GameAction['payload']}
}
return null
case 'GAME_START':
if (payload && typeof payload === 'object') {
return {type: 'GAME_START', payload: payload as GameAction['payload']}
}
return null
case 'DRAW_TILE':
if (payload && typeof payload === 'object') {
return {type: 'DRAW_TILE', payload: payload as GameAction['payload']}
}
return null
case 'PLAY_TILE':
if (payload && typeof payload === 'object') {
return {type: 'PLAY_TILE', payload: payload as GameAction['payload']}
}
return null
case 'PENDING_CLAIM':
if (payload && typeof payload === 'object') {
return {type: 'PENDING_CLAIM', payload: payload as GameAction['payload']}
}
return null
case 'CLAIM_RESOLVED':
if (payload && typeof payload === 'object') {
return {type: 'CLAIM_RESOLVED', payload: payload as GameAction['payload']}
}
return null
case 'ROOM_PLAYER_UPDATE':
if (payload && typeof payload === 'object') {
return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameAction['payload']}
}
return null
case 'ROOM_MEMBER_JOINED':
if (payload && typeof payload === 'object') {
return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameAction['payload']}
}
return null
default:
return null
}
}
function ensureWsConnected(): void {
const token = auth.value?.token
if (!token) {
@@ -319,6 +401,14 @@ function reconnectWs(): void {
function backHall(): void {
leaveRoomPending.value = true
const roomId = gameStore.roomId
sendWsMessage({
type: 'leave_room',
roomId,
payload: {
room_id: roomId,
},
})
wsClient.close()
void router.push('/hall').finally(() => {
leaveRoomPending.value = false
@@ -349,20 +439,54 @@ function handleGlobalEsc(event: KeyboardEvent): void {
}
}
function hydrateFromActiveRoom(routeRoomId: string): void {
const room = activeRoom.value
if (!room) {
return
}
const targetRoomId = routeRoomId || room.roomId
if (!targetRoomId || room.roomId !== targetRoomId) {
return
}
gameStore.roomId = room.roomId
const phaseMap: Record<string, typeof gameStore.phase> = {
waiting: 'waiting',
playing: 'playing',
finished: 'settlement',
}
gameStore.phase = phaseMap[room.status] ?? gameStore.phase
const nextPlayers: Record<string, PlayerState> = {}
for (const player of room.players) {
if (!player.playerId) {
continue
}
const previous = gameStore.players[player.playerId]
nextPlayers[player.playerId] = {
playerId: player.playerId,
seatIndex: player.index,
displayName: player.displayName || player.playerId,
missingSuit: player.missingSuit ?? previous?.missingSuit,
isReady: player.ready,
handTiles: previous?.handTiles ?? [],
melds: previous?.melds ?? [],
discardTiles: previous?.discardTiles ?? [],
score: previous?.score ?? 0,
}
}
gameStore.players = nextPlayers
}
onMounted(() => {
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
if (routeRoomId && activeRoom.value.roomId !== routeRoomId) {
activeRoom.value = {
...activeRoom.value,
roomId: routeRoomId,
}
}
if (!activeRoom.value.roomName && typeof route.query.roomName === 'string') {
activeRoom.value = {
...activeRoom.value,
roomName: route.query.roomName,
}
hydrateFromActiveRoom(routeRoomId)
if (routeRoomId) {
gameStore.roomId = routeRoomId
}
const handler = (status: WsStatus) => {
@@ -372,6 +496,10 @@ onMounted(() => {
wsClient.onMessage((msg: unknown) => {
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
wsMessages.value.push(`[server] ${text}`)
const gameAction = toGameAction(msg)
if (gameAction) {
dispatchGameAction(gameAction)
}
})
wsClient.onError((message: string) => {
wsError.value = message
@@ -511,16 +639,16 @@ onBeforeUnmount(() => {
<div class="bottom-control-panel">
<div class="player-hand" v-if="roomState.myHand.length > 0">
<div class="player-hand" v-if="myHandTiles.length > 0">
<button
v-for="(tile, index) in roomState.myHand"
:key="`${tile}-${index}`"
v-for="tile in myHandTiles"
:key="tile.id"
class="tile-chip"
:class="{ selected: selectedTile === tile }"
:class="{ selected: selectedTile === formatTile(tile) }"
type="button"
@click="selectTile(tile)"
@click="selectTile(formatTile(tile))"
>
{{ tile }}
{{ formatTile(tile) }}
</button>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { AuthExpiredError, type AuthSession } from '../api/authed-request'
import { createRoom, joinRoom, listRooms, type RoomItem } from '../api/mahjong'
import { getUserInfo, type UserInfo } from '../api/user'
import refreshIcon from '../assets/images/icons/refresh.svg'
import { setActiveRoom } from '../store'
import type { RoomPlayerState } from '../store/state'
import type { StoredAuth } from '../types/session'
@@ -424,7 +425,9 @@ onMounted(async () => {
<article class="panel room-list-panel">
<div class="room-panel-header">
<h2>房间列表</h2>
<button class="icon-btn" type="button" :disabled="roomLoading" @click="refreshRooms">🔄</button>
<button class="icon-btn" type="button" :disabled="roomLoading" @click="refreshRooms">
<img class="icon-btn-image" :src="refreshIcon" alt="刷新房间列表" />
</button>
</div>
<div v-if="roomLoading" class="empty-state">正在加载房间...</div>