feat(game): 添加房间玩家状态同步功能
- 定义 RoomPlayerUpdatePayload 接口用于处理房间状态更新 - 在游戏动作中新增 ROOM_PLAYER_UPDATE 类型支持 - 实现游戏状态管理器中的房间玩家更新逻辑 - 重构成都麻将页面以使用新的状态管理机制 - 添加从 WebSocket 消息转换为游戏动作的功能 - 更新房间离开时的 WebSocket 消息发送逻辑 - 优化玩家手牌显示和选择逻辑 - 调整房间状态显示逻辑以匹配新状态模型 - 修复座位索引计算和庄家标识逻辑 - 更新全局样式中的图标按钮样式 - 替换大厅页面的刷新图标为 SVG 图像 - 升级 pnpm 包管理器版本 - 扩展玩家状态类型定义以支持显示名称和缺门信息
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@9.0.0",
|
"packageManager": "pnpm@10.28.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
|
|||||||
1
src/assets/images/icons/avatar.svg
Normal file
1
src/assets/images/icons/avatar.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1774428253072" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3685" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M819.2 729.088V757.76c0 33.792-27.648 61.44-61.44 61.44H266.24c-33.792 0-61.44-27.648-61.44-61.44v-28.672c0-74.752 87.04-119.808 168.96-155.648 3.072-1.024 5.12-2.048 8.192-4.096 6.144-3.072 13.312-3.072 19.456 1.024C434.176 591.872 472.064 604.16 512 604.16c39.936 0 77.824-12.288 110.592-32.768 6.144-4.096 13.312-4.096 19.456-1.024 3.072 1.024 5.12 2.048 8.192 4.096 81.92 34.816 168.96 79.872 168.96 154.624z" fill="#FFFFFF" p-id="3686"></path><path d="M359.424 373.76a168.96 152.576 90 1 0 305.152 0 168.96 152.576 90 1 0-305.152 0Z" fill="#FFFFFF" p-id="3687"></path></svg>
|
||||||
|
After Width: | Height: | Size: 912 B |
1
src/assets/images/icons/refresh.svg
Normal file
1
src/assets/images/icons/refresh.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1774424368718" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1633" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M864 509.12a72 72 0 0 0-72 72A269.76 269.76 0 1 1 512 311.68v141.44c0 17.6 11.84 24 26.56 14.4l298.88-199.04a19.84 19.84 0 0 0 0-35.52l-298.88-199.04C523.84 24 512 32 512 48v119.68a413.44 413.44 0 1 0 424 413.44A72 72 0 0 0 864 509.12z" fill="#ffffff" p-id="1634"></path></svg>
|
||||||
|
After Width: | Height: | Size: 610 B |
@@ -335,6 +335,9 @@ button:disabled {
|
|||||||
.icon-btn {
|
.icon-btn {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
border: 1px solid rgba(176, 216, 194, 0.35);
|
border: 1px solid rgba(176, 216, 194, 0.35);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #d3efdf;
|
color: #d3efdf;
|
||||||
@@ -342,6 +345,16 @@ button:disabled {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-btn-image {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:disabled .icon-btn-image {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
.room-list {
|
.room-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
import type {GameState, PendingClaimState} from "../types/state";
|
import type {GameState, PendingClaimState} from "../types/state";
|
||||||
import type {Tile} from "../types/tile.ts";
|
import type {Tile} from "../types/tile.ts";
|
||||||
|
|
||||||
|
export interface RoomPlayerUpdatePayload {
|
||||||
|
room_id?: string
|
||||||
|
status?: string
|
||||||
|
player_count?: number
|
||||||
|
player_ids?: string[]
|
||||||
|
players?: Array<{
|
||||||
|
Index?: number
|
||||||
|
index?: number
|
||||||
|
PlayerID?: string
|
||||||
|
player_id?: string
|
||||||
|
PlayerName?: string
|
||||||
|
player_name?: string
|
||||||
|
Ready?: boolean
|
||||||
|
ready?: boolean
|
||||||
|
MissingSuit?: string | null
|
||||||
|
missing_suit?: string | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 游戏动作定义(只描述“发生了什么”)
|
* 游戏动作定义(只描述“发生了什么”)
|
||||||
@@ -53,3 +72,9 @@ export type GameAction =
|
|||||||
action: 'peng' | 'gang' | 'hu' | 'pass'
|
action: 'peng' | 'gang' | 'hu' | 'pass'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 房间玩家更新(等待房间人数变化)
|
||||||
|
| {
|
||||||
|
type: 'ROOM_PLAYER_UPDATE'
|
||||||
|
payload: RoomPlayerUpdatePayload
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,5 +29,13 @@ export function dispatchGameAction(action: GameAction) {
|
|||||||
case 'CLAIM_RESOLVED':
|
case 'CLAIM_RESOLVED':
|
||||||
store.clearPendingClaim()
|
store.clearPendingClaim()
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'ROOM_PLAYER_UPDATE':
|
||||||
|
store.onRoomPlayerUpdate(action.payload)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid game action')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type GameState,
|
type GameState,
|
||||||
type PendingClaimState,
|
type PendingClaimState,
|
||||||
} from '../types/state'
|
} from '../types/state'
|
||||||
|
import type { RoomPlayerUpdatePayload } from '../game/actions'
|
||||||
|
|
||||||
import type { Tile } from '../types/tile'
|
import type { Tile } from '../types/tile'
|
||||||
|
|
||||||
@@ -91,6 +92,94 @@ export const useGameStore = defineStore('game', {
|
|||||||
this.phase = GAME_PHASE.ACTION
|
this.phase = GAME_PHASE.ACTION
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onRoomPlayerUpdate(payload: RoomPlayerUpdatePayload) {
|
||||||
|
if (typeof payload.room_id === 'string' && payload.room_id) {
|
||||||
|
this.roomId = payload.room_id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload.status === 'string' && payload.status) {
|
||||||
|
const phaseMap: Record<string, GameState['phase']> = {
|
||||||
|
waiting: GAME_PHASE.WAITING,
|
||||||
|
dealing: GAME_PHASE.DEALING,
|
||||||
|
playing: GAME_PHASE.PLAYING,
|
||||||
|
action: GAME_PHASE.ACTION,
|
||||||
|
settlement: GAME_PHASE.SETTLEMENT,
|
||||||
|
}
|
||||||
|
this.phase = phaseMap[payload.status] ?? this.phase
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPlayerList =
|
||||||
|
Array.isArray(payload.players) || Array.isArray(payload.player_ids)
|
||||||
|
if (!hasPlayerList) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPlayers: GameState['players'] = {}
|
||||||
|
const players = Array.isArray(payload.players) ? payload.players : []
|
||||||
|
const playerIds = Array.isArray(payload.player_ids) ? payload.player_ids : []
|
||||||
|
|
||||||
|
players.forEach((raw, index) => {
|
||||||
|
const playerId =
|
||||||
|
(typeof raw.PlayerID === 'string' && raw.PlayerID) ||
|
||||||
|
(typeof raw.player_id === 'string' && raw.player_id) ||
|
||||||
|
playerIds[index]
|
||||||
|
if (!playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = this.players[playerId]
|
||||||
|
const seatRaw = raw.Index ?? raw.index ?? index
|
||||||
|
const seatIndex =
|
||||||
|
typeof seatRaw === 'number' && Number.isFinite(seatRaw) ? seatRaw : index
|
||||||
|
const readyRaw = raw.Ready ?? raw.ready
|
||||||
|
const displayNameRaw = raw.PlayerName ?? raw.player_name
|
||||||
|
const missingSuitRaw = raw.MissingSuit ?? raw.missing_suit
|
||||||
|
|
||||||
|
nextPlayers[playerId] = {
|
||||||
|
playerId,
|
||||||
|
seatIndex,
|
||||||
|
displayName:
|
||||||
|
typeof displayNameRaw === 'string' && displayNameRaw
|
||||||
|
? displayNameRaw
|
||||||
|
: previous?.displayName,
|
||||||
|
missingSuit:
|
||||||
|
typeof missingSuitRaw === 'string' || missingSuitRaw === null
|
||||||
|
? missingSuitRaw
|
||||||
|
: previous?.missingSuit,
|
||||||
|
handTiles: previous?.handTiles ?? [],
|
||||||
|
melds: previous?.melds ?? [],
|
||||||
|
discardTiles: previous?.discardTiles ?? [],
|
||||||
|
score: previous?.score ?? 0,
|
||||||
|
isReady:
|
||||||
|
typeof readyRaw === 'boolean'
|
||||||
|
? readyRaw
|
||||||
|
: (previous?.isReady ?? false),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (players.length === 0) {
|
||||||
|
playerIds.forEach((playerId, index) => {
|
||||||
|
if (typeof playerId !== 'string' || !playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const previous = this.players[playerId]
|
||||||
|
nextPlayers[playerId] = {
|
||||||
|
playerId,
|
||||||
|
seatIndex: previous?.seatIndex ?? index,
|
||||||
|
displayName: previous?.displayName ?? playerId,
|
||||||
|
missingSuit: previous?.missingSuit,
|
||||||
|
handTiles: previous?.handTiles ?? [],
|
||||||
|
melds: previous?.melds ?? [],
|
||||||
|
discardTiles: previous?.discardTiles ?? [],
|
||||||
|
score: previous?.score ?? 0,
|
||||||
|
isReady: previous?.isReady ?? false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.players = nextPlayers
|
||||||
|
},
|
||||||
|
|
||||||
// 清理操作窗口
|
// 清理操作窗口
|
||||||
clearPendingClaim() {
|
clearPendingClaim() {
|
||||||
this.pendingClaim = undefined
|
this.pendingClaim = undefined
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { MeldState } from './meldState.ts'
|
|||||||
export interface PlayerState {
|
export interface PlayerState {
|
||||||
playerId: string
|
playerId: string
|
||||||
seatIndex: number
|
seatIndex: number
|
||||||
|
displayName?: string
|
||||||
|
missingSuit?: string | null
|
||||||
|
|
||||||
// 手牌(只有自己有完整数据,后端可控制)
|
// 手牌(只有自己有完整数据,后端可控制)
|
||||||
handTiles: Tile[]
|
handTiles: Tile[]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import deskImage from '../assets/images/desk/desk_01.png'
|
import deskImage from '../assets/images/desk/desk_01.png'
|
||||||
import wanIcon from '../assets/images/flowerClolor/wan.png'
|
import wanIcon from '../assets/images/flowerClolor/wan.png'
|
||||||
import tongIcon from '../assets/images/flowerClolor/tong.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 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 } from '../game/seat'
|
import type {SeatKey} from '../game/seat'
|
||||||
import { readStoredAuth } from '../utils/auth-storage'
|
import type {GameAction} from '../game/actions'
|
||||||
import type { WsStatus } from '../ws/client'
|
import {dispatchGameAction} from '../game/dispatcher'
|
||||||
import { wsClient } from '../ws/client'
|
import {readStoredAuth} from '../utils/auth-storage'
|
||||||
import { buildWsUrl } from '../ws/url'
|
import type {WsStatus} from '../ws/client'
|
||||||
import type {ActiveRoomState, RoomPlayerState} from "../store/state.ts";
|
import {wsClient} from '../ws/client'
|
||||||
import {readActiveRoomSnapshot} from "../store/storage.ts";
|
import {sendWsMessage} from '../ws/sender'
|
||||||
|
import {buildWsUrl} from '../ws/url'
|
||||||
|
import {useGameStore} from '../store/gameStore'
|
||||||
interface SeatViewModel {
|
import {useActiveRoomState} from '../store'
|
||||||
key: SeatKey
|
import type {PlayerState} from '../types/state'
|
||||||
player?: RoomPlayerState
|
import type {Tile} from '../types/tile'
|
||||||
isSelf: boolean
|
|
||||||
isTurn: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const gameStore = useGameStore()
|
||||||
|
const activeRoom = useActiveRoomState()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = ref(readStoredAuth())
|
const auth = ref(readStoredAuth())
|
||||||
|
|
||||||
function createFallbackRoomState(): ActiveRoomState {
|
type DisplayPlayer = PlayerState & {
|
||||||
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
displayName?: string
|
||||||
const routeRoomName = typeof route.query.roomName === 'string' ? route.query.roomName : ''
|
missingSuit?: string | null
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeRoom = ref<ActiveRoomState>(readActiveRoomSnapshot() ?? createFallbackRoomState())
|
interface SeatViewModel {
|
||||||
|
key: SeatKey
|
||||||
|
player?: DisplayPlayer
|
||||||
|
isSelf: boolean
|
||||||
|
isTurn: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
const wsStatus = ref<WsStatus>('idle')
|
const wsStatus = ref<WsStatus>('idle')
|
||||||
@@ -97,32 +78,70 @@ const loggedInUserName = computed(() => {
|
|||||||
return auth.value?.user?.nickname || auth.value?.user?.username || ''
|
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 roomName = computed(() => {
|
||||||
const queryRoomName = typeof route.query.roomName === 'string' ? route.query.roomName : ''
|
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 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 {
|
return {
|
||||||
...activeRoom.value,
|
roomId: gameStore.roomId,
|
||||||
name: activeRoom.value.roomName,
|
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 seatViews = computed<SeatViewModel[]>(() => {
|
||||||
const players = roomState.value.players ?? []
|
const players = gamePlayers.value
|
||||||
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
|
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
|
||||||
const selfIndex = players.findIndex((player) => player.playerId === loggedInUserId.value)
|
const selfSeatIndex = myPlayer.value?.seatIndex ?? players.find((player) => player.playerId === loggedInUserId.value)?.seatIndex ?? 0
|
||||||
const currentTurn = roomState.value.game?.state?.currentTurn
|
const currentTurn = gameStore.currentTurn
|
||||||
|
|
||||||
return players.slice(0, 4).map((player, index) => {
|
return players.slice(0, 4).map((player) => {
|
||||||
const relativeIndex = selfIndex >= 0 ? (index - selfIndex + 4) % 4 : index
|
const relativeIndex = (player.seatIndex - selfSeatIndex + 4) % 4
|
||||||
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
||||||
return {
|
return {
|
||||||
key: seatKey,
|
key: seatKey,
|
||||||
player,
|
player,
|
||||||
isSelf: player.playerId === loggedInUserId.value,
|
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 rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
|
||||||
|
|
||||||
const currentPhaseText = computed(() => {
|
const currentPhaseText = computed(() => {
|
||||||
const phase = roomState.value.game?.state?.phase
|
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
waiting: '等待中',
|
waiting: '等待中',
|
||||||
dealing: '发牌中',
|
dealing: '发牌中',
|
||||||
playing: '对局中',
|
playing: '对局中',
|
||||||
finished: '已结束',
|
action: '操作中',
|
||||||
|
settlement: '已结算',
|
||||||
}
|
}
|
||||||
return phase ? (map[phase] ?? phase) : '等待中'
|
return map[gameStore.phase] ?? gameStore.phase
|
||||||
})
|
})
|
||||||
|
|
||||||
const roomStatusText = computed(() => {
|
const roomStatusText = computed(() => {
|
||||||
@@ -172,7 +191,7 @@ const formattedClock = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const wallBacks = computed<Record<SeatKey, string[]>>(() => {
|
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))
|
const perSide = Math.max(6, Math.ceil((wallSize || 48) / 4 / 2))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -207,11 +226,11 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = seat.player.displayName || `玩家${seat.player.index + 1}`
|
const displayName = seat.player.displayName || `玩家${seat.player.seatIndex + 1}`
|
||||||
result[seat.key] = {
|
result[seat.key] = {
|
||||||
avatar: seat.isSelf ? '我' : String(index + 1),
|
avatar: seat.isSelf ? '我' : String(index + 1),
|
||||||
name: seat.isSelf ? '你自己' : displayName,
|
name: seat.isSelf ? '你自己' : displayName,
|
||||||
dealer: seat.player.index === dealerIndex,
|
dealer: seat.player.seatIndex === dealerIndex,
|
||||||
isTurn: seat.isTurn,
|
isTurn: seat.isTurn,
|
||||||
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
||||||
}
|
}
|
||||||
@@ -295,6 +314,69 @@ function selectTile(tile: string): void {
|
|||||||
selectedTile.value = selectedTile.value === tile ? null : tile
|
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 {
|
function ensureWsConnected(): void {
|
||||||
const token = auth.value?.token
|
const token = auth.value?.token
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -319,6 +401,14 @@ function reconnectWs(): void {
|
|||||||
|
|
||||||
function backHall(): void {
|
function backHall(): void {
|
||||||
leaveRoomPending.value = true
|
leaveRoomPending.value = true
|
||||||
|
const roomId = gameStore.roomId
|
||||||
|
sendWsMessage({
|
||||||
|
type: 'leave_room',
|
||||||
|
roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: roomId,
|
||||||
|
},
|
||||||
|
})
|
||||||
wsClient.close()
|
wsClient.close()
|
||||||
void router.push('/hall').finally(() => {
|
void router.push('/hall').finally(() => {
|
||||||
leaveRoomPending.value = false
|
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(() => {
|
onMounted(() => {
|
||||||
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
||||||
if (routeRoomId && activeRoom.value.roomId !== routeRoomId) {
|
hydrateFromActiveRoom(routeRoomId)
|
||||||
activeRoom.value = {
|
if (routeRoomId) {
|
||||||
...activeRoom.value,
|
gameStore.roomId = routeRoomId
|
||||||
roomId: routeRoomId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!activeRoom.value.roomName && typeof route.query.roomName === 'string') {
|
|
||||||
activeRoom.value = {
|
|
||||||
...activeRoom.value,
|
|
||||||
roomName: route.query.roomName,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handler = (status: WsStatus) => {
|
const handler = (status: WsStatus) => {
|
||||||
@@ -372,6 +496,10 @@ onMounted(() => {
|
|||||||
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}`)
|
||||||
|
const gameAction = toGameAction(msg)
|
||||||
|
if (gameAction) {
|
||||||
|
dispatchGameAction(gameAction)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
wsClient.onError((message: string) => {
|
wsClient.onError((message: string) => {
|
||||||
wsError.value = message
|
wsError.value = message
|
||||||
@@ -511,16 +639,16 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div class="bottom-control-panel">
|
<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
|
<button
|
||||||
v-for="(tile, index) in roomState.myHand"
|
v-for="tile in myHandTiles"
|
||||||
:key="`${tile}-${index}`"
|
:key="tile.id"
|
||||||
class="tile-chip"
|
class="tile-chip"
|
||||||
:class="{ selected: selectedTile === tile }"
|
:class="{ selected: selectedTile === formatTile(tile) }"
|
||||||
type="button"
|
type="button"
|
||||||
@click="selectTile(tile)"
|
@click="selectTile(formatTile(tile))"
|
||||||
>
|
>
|
||||||
{{ tile }}
|
{{ formatTile(tile) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 refreshIcon from '../assets/images/icons/refresh.svg'
|
||||||
import { setActiveRoom } from '../store'
|
import { setActiveRoom } from '../store'
|
||||||
import type { RoomPlayerState } from '../store/state'
|
import type { RoomPlayerState } from '../store/state'
|
||||||
import type { StoredAuth } from '../types/session'
|
import type { StoredAuth } from '../types/session'
|
||||||
@@ -424,7 +425,9 @@ onMounted(async () => {
|
|||||||
<article class="panel room-list-panel">
|
<article class="panel room-list-panel">
|
||||||
<div class="room-panel-header">
|
<div class="room-panel-header">
|
||||||
<h2>房间列表</h2>
|
<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>
|
||||||
|
|
||||||
<div v-if="roomLoading" class="empty-state">正在加载房间...</div>
|
<div v-if="roomLoading" class="empty-state">正在加载房间...</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user