update
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
|
||||||
|
.tmp/
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.2",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vue-tsc": "^3.1.5"
|
"vue-tsc": "^3.1.5"
|
||||||
|
|||||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -24,6 +24,9 @@ importers:
|
|||||||
'@vue/tsconfig':
|
'@vue/tsconfig':
|
||||||
specifier: ^0.8.1
|
specifier: ^0.8.1
|
||||||
version: 0.8.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3))
|
version: 0.8.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3))
|
||||||
|
playwright:
|
||||||
|
specifier: ^1.58.2
|
||||||
|
version: 1.58.2
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.9.3
|
specifier: ~5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -448,6 +451,11 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -474,6 +482,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
playwright-core@1.58.2:
|
||||||
|
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.58.2:
|
||||||
|
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@@ -869,6 +887,9 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
fsevents@2.3.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -886,6 +907,14 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@4.0.3: {}
|
picomatch@4.0.3: {}
|
||||||
|
|
||||||
|
playwright-core@1.58.2: {}
|
||||||
|
|
||||||
|
playwright@1.58.2:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.58.2
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.11
|
nanoid: 3.3.11
|
||||||
|
|||||||
183
scripts/open-four-players.mjs
Normal file
183
scripts/open-four-players.mjs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { chromium } from 'playwright'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
|
||||||
|
const baseUrl = 'http://127.0.0.1:5173'
|
||||||
|
const chromePath = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
|
||||||
|
const rootDir = process.cwd()
|
||||||
|
const runtimeDir = path.join(rootDir, '.tmp', 'manual-four-players', String(Date.now()))
|
||||||
|
|
||||||
|
const players = [
|
||||||
|
{ username: 'pwtest04', loginId: '13812345681', password: 'play123', owner: true },
|
||||||
|
{ username: 'pwtest01', loginId: '13812345678', password: 'play123', owner: false },
|
||||||
|
{ username: 'pwtest02', loginId: '13812345679', password: 'play123', owner: false },
|
||||||
|
{ username: 'pwtest03', loginId: '13812345680', password: 'play123', owner: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
console.log(`[setup] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchPlayer(player) {
|
||||||
|
const profileDir = path.join(runtimeDir, player.username)
|
||||||
|
await fs.mkdir(profileDir, { recursive: true })
|
||||||
|
log(`launch browser for ${player.username}`)
|
||||||
|
|
||||||
|
const context = await chromium.launchPersistentContext(profileDir, {
|
||||||
|
headless: false,
|
||||||
|
executablePath: chromePath,
|
||||||
|
args: ['--no-first-run', '--no-default-browser-check'],
|
||||||
|
viewport: { width: 1400, height: 960 },
|
||||||
|
})
|
||||||
|
|
||||||
|
let page = context.pages()[0]
|
||||||
|
if (!page) {
|
||||||
|
page = await context.newPage()
|
||||||
|
}
|
||||||
|
await page.goto(`${baseUrl}/login`, { waitUntil: 'domcontentloaded' })
|
||||||
|
return { ...player, context, page, profileDir }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page, player) {
|
||||||
|
log(`login ${player.username}`)
|
||||||
|
await page.goto(`${baseUrl}/login`, { waitUntil: 'domcontentloaded' })
|
||||||
|
await page.getByRole('textbox', { name: '登录ID' }).fill(player.loginId)
|
||||||
|
await page.getByRole('textbox', { name: '密码' }).fill(player.password)
|
||||||
|
const submitButton = page.locator('form').getByRole('button', { name: '登录' })
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL('**/hall', { timeout: 15000 }),
|
||||||
|
submitButton.click(),
|
||||||
|
])
|
||||||
|
await page.getByText(`用户名:${player.username}`).waitFor({ timeout: 15000 })
|
||||||
|
log(`logged in ${player.username}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRoom(page) {
|
||||||
|
const roomName = `manual-${Date.now()}`
|
||||||
|
log(`create room ${roomName}`)
|
||||||
|
await page.getByRole('button', { name: '创建房间' }).click()
|
||||||
|
await page.getByRole('textbox', { name: '房间名' }).fill(roomName)
|
||||||
|
await page.getByRole('button', { name: '创建', exact: true }).click()
|
||||||
|
const roomText = await page.getByText(/房间ID:/).textContent()
|
||||||
|
const roomId = roomText?.replace('房间ID:', '').trim()
|
||||||
|
if (!roomId) {
|
||||||
|
throw new Error('Failed to read room id')
|
||||||
|
}
|
||||||
|
return { roomId, roomName }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ownerEnterRoom(page, roomId) {
|
||||||
|
log(`owner enter room ${roomId}`)
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(`**/game/chengdu/${roomId}*`, { timeout: 15000 }),
|
||||||
|
page.getByRole('button', { name: '进入房间' }).click(),
|
||||||
|
])
|
||||||
|
log(`owner entered room ${roomId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ownerStartGame(page) {
|
||||||
|
log('owner start game')
|
||||||
|
const startButton = page.getByRole('button', { name: '开始游戏' })
|
||||||
|
await startButton.waitFor({ timeout: 15000 })
|
||||||
|
await startButton.click()
|
||||||
|
log('owner clicked start game')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function joinRoom(page, roomId, username) {
|
||||||
|
log(`join room ${roomId} as ${username}`)
|
||||||
|
await page.goto(`${baseUrl}/hall`, { waitUntil: 'domcontentloaded' })
|
||||||
|
await page.getByRole('textbox', { name: '输入 room_id' }).fill(roomId)
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(`**/game/chengdu/${roomId}*`, { timeout: 15000 }),
|
||||||
|
page.getByRole('button', { name: '加入' }).click(),
|
||||||
|
])
|
||||||
|
log(`joined room ${roomId} as ${username}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshotPage(session) {
|
||||||
|
const bodyText = ((await session.page.locator('body').textContent()) ?? '').replace(/\s+/g, ' ').trim()
|
||||||
|
return {
|
||||||
|
username: session.username,
|
||||||
|
loginId: session.loginId,
|
||||||
|
url: session.page.url(),
|
||||||
|
started: bodyText.includes('对局中') || bodyText.includes('牌局进行中'),
|
||||||
|
bodyPreview: bodyText.slice(0, 240),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await fs.mkdir(runtimeDir, { recursive: true })
|
||||||
|
log(`runtimeDir ${runtimeDir}`)
|
||||||
|
|
||||||
|
const sessions = []
|
||||||
|
for (const player of players) {
|
||||||
|
sessions.push(await launchPlayer(player))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const session of sessions) {
|
||||||
|
await login(session.page, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = sessions.find((session) => session.owner)
|
||||||
|
if (!owner) {
|
||||||
|
throw new Error('Owner session missing')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { roomId } = await createRoom(owner.page)
|
||||||
|
log(`room ready ${roomId}`)
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!session.owner) {
|
||||||
|
await joinRoom(session.page, roomId, session.username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ownerEnterRoom(owner.page, roomId)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||||
|
await ownerStartGame(owner.page)
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 8000))
|
||||||
|
|
||||||
|
const playersSnapshot = []
|
||||||
|
for (const session of sessions) {
|
||||||
|
let snapshot = await snapshotPage(session)
|
||||||
|
if (!snapshot.started) {
|
||||||
|
log(`reload game page for ${session.username}`)
|
||||||
|
await session.page.reload({ waitUntil: 'domcontentloaded' })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||||
|
snapshot = await snapshotPage(session)
|
||||||
|
}
|
||||||
|
playersSnapshot.push(snapshot)
|
||||||
|
await session.page.bringToFront()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
roomId,
|
||||||
|
roomUrl: `${baseUrl}/game/chengdu/${roomId}`,
|
||||||
|
players: playersSnapshot,
|
||||||
|
}, null, 2))
|
||||||
|
|
||||||
|
if (process.env.KEEP_ALIVE === '1') {
|
||||||
|
log('setup finished; keeping browsers alive for manual testing')
|
||||||
|
await new Promise(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
await session.context.close()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
if (process.env.KEEP_ALIVE !== '1') {
|
||||||
|
for (const session of sessions) {
|
||||||
|
try {
|
||||||
|
await session.context.close()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main()
|
||||||
@@ -29,6 +29,17 @@ interface ActionEventLike {
|
|||||||
data?: unknown
|
data?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingClaimOption {
|
||||||
|
playerId: string
|
||||||
|
actions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionButtonState {
|
||||||
|
type: 'draw' | 'discard' | 'peng' | 'gang' | 'hu' | 'pass'
|
||||||
|
label: string
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface SeatView {
|
export interface SeatView {
|
||||||
key: SeatKey
|
key: SeatKey
|
||||||
player: RoomPlayerState | null
|
player: RoomPlayerState | null
|
||||||
@@ -38,6 +49,19 @@ export interface SeatView {
|
|||||||
subLabel: string
|
subLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function humanizeSuit(value: string): string {
|
||||||
|
const suitMap: Record<string, string> = {
|
||||||
|
W: '万',
|
||||||
|
B: '筒',
|
||||||
|
T: '条',
|
||||||
|
wan: '万',
|
||||||
|
tong: '筒',
|
||||||
|
tiao: '条',
|
||||||
|
}
|
||||||
|
|
||||||
|
return suitMap[value] ?? value
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChengduGameRoomModel {
|
export interface ChengduGameRoomModel {
|
||||||
auth: Ref<StoredAuth | null>
|
auth: Ref<StoredAuth | null>
|
||||||
roomState: typeof activeRoomState
|
roomState: typeof activeRoomState
|
||||||
@@ -52,8 +76,12 @@ export interface ChengduGameRoomModel {
|
|||||||
leaveRoomPending: Ref<boolean>
|
leaveRoomPending: Ref<boolean>
|
||||||
canStartGame: ComputedRef<boolean>
|
canStartGame: ComputedRef<boolean>
|
||||||
seatViews: ComputedRef<SeatView[]>
|
seatViews: ComputedRef<SeatView[]>
|
||||||
|
selectedTile: Ref<string | null>
|
||||||
|
actionButtons: ComputedRef<ActionButtonState[]>
|
||||||
connectWs: () => Promise<void>
|
connectWs: () => Promise<void>
|
||||||
sendStartGame: () => void
|
sendStartGame: () => void
|
||||||
|
selectTile: (tile: string) => void
|
||||||
|
sendGameAction: (type: ActionButtonState['type']) => void
|
||||||
backHall: () => void
|
backHall: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +97,12 @@ export function useChengduGameRoom(
|
|||||||
const wsError = ref('')
|
const wsError = ref('')
|
||||||
const wsMessages = ref<string[]>([])
|
const wsMessages = ref<string[]>([])
|
||||||
const startGamePending = ref(false)
|
const startGamePending = ref(false)
|
||||||
|
const actionPending = ref(false)
|
||||||
const lastStartRequestId = ref('')
|
const lastStartRequestId = ref('')
|
||||||
const leaveRoomPending = ref(false)
|
const leaveRoomPending = ref(false)
|
||||||
const lastLeaveRoomRequestId = ref('')
|
const lastLeaveRoomRequestId = ref('')
|
||||||
const leaveHallAfterAck = ref(false)
|
const leaveHallAfterAck = ref(false)
|
||||||
|
const selectedTile = ref<string | null>(null)
|
||||||
|
|
||||||
const roomId = computed(() => {
|
const roomId = computed(() => {
|
||||||
return typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
return typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
||||||
@@ -121,6 +151,64 @@ export function useChengduGameRoom(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const myPlayer = computed(() => {
|
||||||
|
return roomState.value.players.find((player) => player.playerId === currentUserId.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMyTurn = computed(() => {
|
||||||
|
return myPlayer.value?.index === roomState.value.currentTurnIndex
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingClaimOptions = computed<PendingClaimOption[]>(() => {
|
||||||
|
return normalizePendingClaimOptions(roomState.value.game?.state?.pendingClaim)
|
||||||
|
})
|
||||||
|
|
||||||
|
const myClaimActions = computed(() => {
|
||||||
|
const claim = pendingClaimOptions.value.find((item) => item.playerId === currentUserId.value)
|
||||||
|
return new Set(claim?.actions ?? [])
|
||||||
|
})
|
||||||
|
|
||||||
|
const canDraw = computed(() => {
|
||||||
|
return roomState.value.status === 'playing' && isMyTurn.value && Boolean(roomState.value.game?.state?.needDraw)
|
||||||
|
})
|
||||||
|
|
||||||
|
const canDiscard = computed(() => {
|
||||||
|
return (
|
||||||
|
roomState.value.status === 'playing' &&
|
||||||
|
isMyTurn.value &&
|
||||||
|
!roomState.value.game?.state?.needDraw &&
|
||||||
|
roomState.value.myHand.length > 0 &&
|
||||||
|
Boolean(selectedTile.value)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const actionButtons = computed<ActionButtonState[]>(() => {
|
||||||
|
return [
|
||||||
|
{ type: 'draw', label: '摸牌', disabled: !canDraw.value || actionPending.value },
|
||||||
|
{ type: 'discard', label: '出牌', disabled: !canDiscard.value || actionPending.value },
|
||||||
|
{
|
||||||
|
type: 'peng',
|
||||||
|
label: '碰',
|
||||||
|
disabled: !myClaimActions.value.has('peng') || actionPending.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'gang',
|
||||||
|
label: '杠',
|
||||||
|
disabled: !myClaimActions.value.has('gang') || actionPending.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'hu',
|
||||||
|
label: '胡',
|
||||||
|
disabled: !myClaimActions.value.has('hu') || actionPending.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'pass',
|
||||||
|
label: '过',
|
||||||
|
disabled: !myClaimActions.value.has('pass') || actionPending.value,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const seatViews = computed<SeatView[]>(() => {
|
const seatViews = computed<SeatView[]>(() => {
|
||||||
const seats: Record<SeatKey, RoomPlayerState | null> = {
|
const seats: Record<SeatKey, RoomPlayerState | null> = {
|
||||||
top: null,
|
top: null,
|
||||||
@@ -165,7 +253,7 @@ export function useChengduGameRoom(
|
|||||||
player,
|
player,
|
||||||
isSelf,
|
isSelf,
|
||||||
isTurn: turnSeat === seat,
|
isTurn: turnSeat === seat,
|
||||||
label: player ? (isSelf ? '你' : player.playerId) : '空位',
|
label: player ? (isSelf ? '你' : player.displayName || `玩家${player.index + 1}`) : '空位',
|
||||||
subLabel: player ? `座位 ${player.index}` : '',
|
subLabel: player ? `座位 ${player.index}` : '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -230,6 +318,29 @@ export function useChengduGameRoom(
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeActionName(value: unknown): string {
|
||||||
|
const raw = toStringOrEmpty(value).trim().toLowerCase()
|
||||||
|
if (!raw) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const actionMap: Record<string, string> = {
|
||||||
|
candraw: 'draw',
|
||||||
|
draw: 'draw',
|
||||||
|
candiscard: 'discard',
|
||||||
|
discard: 'discard',
|
||||||
|
canpeng: 'peng',
|
||||||
|
peng: 'peng',
|
||||||
|
cangang: 'gang',
|
||||||
|
gang: 'gang',
|
||||||
|
canhu: 'hu',
|
||||||
|
hu: 'hu',
|
||||||
|
canpass: 'pass',
|
||||||
|
pass: 'pass',
|
||||||
|
}
|
||||||
|
|
||||||
|
return actionMap[raw.replace(/[_\-\s]/g, '')] ?? raw
|
||||||
|
}
|
||||||
|
|
||||||
function toSession(source: NonNullable<typeof auth.value>): AuthSession {
|
function toSession(source: NonNullable<typeof auth.value>): AuthSession {
|
||||||
return {
|
return {
|
||||||
token: source.token,
|
token: source.token,
|
||||||
@@ -361,19 +472,198 @@ export function useChengduGameRoom(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerId = toStringOrEmpty(player.playerId ?? player.player_id ?? player.user_id ?? player.id)
|
const playerId = toStringOrEmpty(
|
||||||
|
player.playerId ??
|
||||||
|
player.player_id ??
|
||||||
|
player.PlayerID ??
|
||||||
|
player.UserID ??
|
||||||
|
player.user_id ??
|
||||||
|
player.id,
|
||||||
|
)
|
||||||
if (!playerId) {
|
if (!playerId) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const seatIndex = toFiniteNumber(player.index ?? player.seat ?? player.position ?? player.player_index)
|
const seatIndex = toFiniteNumber(
|
||||||
|
player.index ??
|
||||||
|
player.Index ??
|
||||||
|
player.seat ??
|
||||||
|
player.Seat ??
|
||||||
|
player.position ??
|
||||||
|
player.Position ??
|
||||||
|
player.player_index,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
index: seatIndex ?? fallbackIndex,
|
index: seatIndex ?? fallbackIndex,
|
||||||
playerId,
|
playerId,
|
||||||
|
displayName:
|
||||||
|
toStringOrEmpty(
|
||||||
|
player.playerName ??
|
||||||
|
player.player_name ??
|
||||||
|
player.PlayerName ??
|
||||||
|
player.username ??
|
||||||
|
player.nickname,
|
||||||
|
) || undefined,
|
||||||
ready: Boolean(player.ready),
|
ready: Boolean(player.ready),
|
||||||
|
handCount:
|
||||||
|
toFiniteNumber(player.handCount ?? player.hand_count ?? player.HandCount) ?? undefined,
|
||||||
|
melds: normalizeTileList(player.melds ?? player.Melds),
|
||||||
|
outTiles: normalizeTileList(player.outTiles ?? player.out_tiles ?? player.OutTiles),
|
||||||
|
hasHu: toBoolean(player.hasHu ?? player.has_hu ?? player.HasHu),
|
||||||
|
missingSuit:
|
||||||
|
toStringOrEmpty(player.missingSuit ?? player.missing_suit ?? player.MissingSuit) || null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTileList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.flatMap((item) => {
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
return item.map((nested) => toStringOrEmpty(nested)).filter(Boolean)
|
||||||
|
}
|
||||||
|
const record = toRecord(item)
|
||||||
|
if (record) {
|
||||||
|
const explicit =
|
||||||
|
toStringOrEmpty(
|
||||||
|
record.tile ?? record.Tile ?? record.code ?? record.Code ?? record.name ?? record.Name,
|
||||||
|
) || ''
|
||||||
|
if (explicit) {
|
||||||
|
return [explicit]
|
||||||
|
}
|
||||||
|
|
||||||
|
const suit = toStringOrEmpty(record.suit ?? record.Suit)
|
||||||
|
const tileValue = toStringOrEmpty(record.value ?? record.Value)
|
||||||
|
if (suit && tileValue) {
|
||||||
|
return [`${humanizeSuit(suit)}${tileValue}`]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [toStringOrEmpty(item)].filter(Boolean)
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePublicGameState(source: Record<string, unknown>): GameState | null {
|
||||||
|
const publicPlayers = (Array.isArray(source.players) ? source.players : [])
|
||||||
|
.map((item, index) => normalizePlayer(item, index))
|
||||||
|
.filter((item): item is RoomPlayerState => Boolean(item))
|
||||||
|
.sort((a, b) => a.index - b.index)
|
||||||
|
|
||||||
|
const currentTurnPlayerId = toStringOrEmpty(
|
||||||
|
source.current_turn_player ?? source.currentTurnPlayer ?? source.current_turn_player_id,
|
||||||
|
)
|
||||||
|
const currentTurnIndex =
|
||||||
|
publicPlayers.find((player) => player.playerId === currentTurnPlayerId)?.index ?? null
|
||||||
|
|
||||||
|
return {
|
||||||
|
rule: null,
|
||||||
|
state: {
|
||||||
|
phase: toStringOrEmpty(source.phase),
|
||||||
|
dealerIndex: 0,
|
||||||
|
currentTurn: currentTurnIndex ?? 0,
|
||||||
|
needDraw: toBoolean(source.need_draw ?? source.needDraw),
|
||||||
|
players: publicPlayers.map((player) => ({
|
||||||
|
playerId: player.playerId,
|
||||||
|
index: player.index,
|
||||||
|
ready: player.ready,
|
||||||
|
})),
|
||||||
|
wall: Array.from({
|
||||||
|
length: toFiniteNumber(source.wall_count ?? source.wallCount) ?? 0,
|
||||||
|
}).map((_, index) => `wall-${index}`),
|
||||||
|
lastDiscardTile:
|
||||||
|
toStringOrEmpty(source.last_discard_tile ?? source.lastDiscardTile) || null,
|
||||||
|
lastDiscardBy: toStringOrEmpty(source.last_discard_by ?? source.lastDiscardBy),
|
||||||
|
pendingClaim: toRecord(source.pending_claim ?? source.pendingClaim),
|
||||||
|
winners: Array.isArray(source.winners)
|
||||||
|
? source.winners.map((item) => toStringOrEmpty(item)).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
scores: normalizeScores(source.scores),
|
||||||
|
lastDrawPlayerId: '',
|
||||||
|
lastDrawFromGang: false,
|
||||||
|
lastDrawIsLastTile: false,
|
||||||
|
huWay: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePendingClaimOptions(value: unknown): PendingClaimOption[] {
|
||||||
|
const pendingClaim = toRecord(value)
|
||||||
|
if (!pendingClaim) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawOptions =
|
||||||
|
(Array.isArray(pendingClaim.options) ? pendingClaim.options : null) ??
|
||||||
|
(Array.isArray(pendingClaim.Options) ? pendingClaim.Options : null) ??
|
||||||
|
[]
|
||||||
|
|
||||||
|
const optionsFromArray = rawOptions
|
||||||
|
.map((option) => {
|
||||||
|
const record = toRecord(option)
|
||||||
|
if (!record) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const playerId = toStringOrEmpty(
|
||||||
|
record.playerId ?? record.player_id ?? record.PlayerID ?? record.user_id ?? record.UserID,
|
||||||
|
)
|
||||||
|
if (!playerId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const actions = new Set<string>()
|
||||||
|
for (const value of Object.values(record)) {
|
||||||
|
if (typeof value === 'boolean' && value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, enabled] of Object.entries(record)) {
|
||||||
|
if (typeof enabled === 'boolean' && enabled) {
|
||||||
|
const normalized = normalizeActionName(key)
|
||||||
|
if (normalized && normalized !== 'playerid' && normalized !== 'userid') {
|
||||||
|
actions.add(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(record.actions)) {
|
||||||
|
for (const action of record.actions) {
|
||||||
|
const normalized = normalizeActionName(action)
|
||||||
|
if (normalized) {
|
||||||
|
actions.add(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { playerId, actions: [...actions] }
|
||||||
|
})
|
||||||
|
.filter((item): item is PendingClaimOption => Boolean(item))
|
||||||
|
|
||||||
|
if (optionsFromArray.length > 0) {
|
||||||
|
return optionsFromArray
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimPlayerId = toStringOrEmpty(
|
||||||
|
pendingClaim.playerId ??
|
||||||
|
pendingClaim.player_id ??
|
||||||
|
pendingClaim.PlayerID ??
|
||||||
|
pendingClaim.user_id ??
|
||||||
|
pendingClaim.UserID,
|
||||||
|
)
|
||||||
|
if (!claimPlayerId) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = Object.entries(pendingClaim)
|
||||||
|
.filter(([, enabled]) => typeof enabled === 'boolean' && enabled)
|
||||||
|
.map(([key]) => normalizeActionName(key))
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
return actions.length > 0 ? [{ playerId: claimPlayerId, actions }] : []
|
||||||
|
}
|
||||||
|
|
||||||
function extractCurrentTurnIndex(value: Record<string, unknown>): number | null {
|
function extractCurrentTurnIndex(value: Record<string, unknown>): number | null {
|
||||||
const game = toRecord(value.game)
|
const game = toRecord(value.game)
|
||||||
const gameState = toRecord(game?.state)
|
const gameState = toRecord(game?.state)
|
||||||
@@ -495,21 +785,44 @@ export function useChengduGameRoom(
|
|||||||
.filter((item) => Boolean(item.playerId))
|
.filter((item) => Boolean(item.playerId))
|
||||||
const resolvedPlayers = players.length > 0 ? players : playersFromIds
|
const resolvedPlayers = players.length > 0 ? players : playersFromIds
|
||||||
const parsedPlayerCount = toFiniteNumber(room.player_count ?? room.playerCount)
|
const parsedPlayerCount = toFiniteNumber(room.player_count ?? room.playerCount)
|
||||||
const game = normalizeGame(room.game)
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
name: toStringOrEmpty(room.name) || roomState.value.name,
|
name: toStringOrEmpty(room.name) || roomState.value.name,
|
||||||
gameType: toStringOrEmpty(room.gameType ?? room.game_type) || roomState.value.gameType || 'chengdu',
|
gameType: toStringOrEmpty(room.gameType ?? room.game_type) || roomState.value.gameType || 'chengdu',
|
||||||
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id),
|
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id ?? room.OwnerID ?? room.ownerID),
|
||||||
maxPlayers,
|
maxPlayers,
|
||||||
playerCount: parsedPlayerCount ?? resolvedPlayers.length,
|
playerCount:
|
||||||
|
parsedPlayerCount ??
|
||||||
|
toFiniteNumber(room.player_count ?? room.playerCount ?? room.playerCount) ??
|
||||||
|
finalPlayers.length,
|
||||||
status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting',
|
status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting',
|
||||||
createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || roomState.value.createdAt,
|
createdAt: toStringOrEmpty(room.createdAt ?? room.created_at) || roomState.value.createdAt,
|
||||||
updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || roomState.value.updatedAt,
|
updatedAt: toStringOrEmpty(room.updatedAt ?? room.updated_at) || roomState.value.updatedAt,
|
||||||
game: game ?? roomState.value.game,
|
game: game ?? roomState.value.game,
|
||||||
players: resolvedPlayers,
|
players: finalPlayers,
|
||||||
currentTurnIndex: extractCurrentTurnIndex(room),
|
currentTurnIndex: derivedTurnIndex,
|
||||||
|
myHand: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,6 +920,34 @@ export function useChengduGameRoom(
|
|||||||
) {
|
) {
|
||||||
startGamePending.value = false
|
startGamePending.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.status === 'error') {
|
||||||
|
actionPending.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType === 'my_hand') {
|
||||||
|
const handPayload = payload ?? data
|
||||||
|
const handRecord = toRecord(handPayload)
|
||||||
|
const hand = normalizeTileList(handRecord?.hand ?? handRecord?.tiles ?? handRecord?.myHand)
|
||||||
|
roomState.value = {
|
||||||
|
...roomState.value,
|
||||||
|
myHand: hand,
|
||||||
|
playerCount: roomState.value.playerCount || roomState.value.players.length,
|
||||||
|
}
|
||||||
|
if (!selectedTile.value || !hand.includes(selectedTile.value)) {
|
||||||
|
selectedTile.value = hand[0] ?? null
|
||||||
|
}
|
||||||
|
actionPending.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
['room_state', 'room_player_update', 'room_joined', 'room_member_joined', 'room_member_left'].includes(
|
||||||
|
eventType,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
actionPending.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRequestId(prefix: string): string {
|
function createRequestId(prefix: string): string {
|
||||||
@@ -648,6 +989,41 @@ export function useChengduGameRoom(
|
|||||||
pushWsMessage(`[client] 请求开始游戏 requestId=${requestId}`)
|
pushWsMessage(`[client] 请求开始游戏 requestId=${requestId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectTile(tile: string): void {
|
||||||
|
selectedTile.value = selectedTile.value === tile ? null : tile
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendGameAction(type: ActionButtonState['type']): void {
|
||||||
|
if (!ws.value || ws.value.readyState !== WebSocket.OPEN || !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)
|
||||||
|
ws.value.send(JSON.stringify(message))
|
||||||
|
pushWsMessage(`[client] 请求${type} requestId=${requestId}`)
|
||||||
|
}
|
||||||
|
|
||||||
function sendLeaveRoom(): boolean {
|
function sendLeaveRoom(): boolean {
|
||||||
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
|
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
|
||||||
wsError.value = 'WebSocket 未连接,无法退出房间'
|
wsError.value = 'WebSocket 未连接,无法退出房间'
|
||||||
@@ -774,6 +1150,8 @@ export function useChengduGameRoom(
|
|||||||
leaveRoomPending.value = false
|
leaveRoomPending.value = false
|
||||||
lastLeaveRoomRequestId.value = ''
|
lastLeaveRoomRequestId.value = ''
|
||||||
leaveHallAfterAck.value = false
|
leaveHallAfterAck.value = false
|
||||||
|
actionPending.value = false
|
||||||
|
selectedTile.value = null
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -826,8 +1204,12 @@ export function useChengduGameRoom(
|
|||||||
leaveRoomPending,
|
leaveRoomPending,
|
||||||
canStartGame,
|
canStartGame,
|
||||||
seatViews,
|
seatViews,
|
||||||
|
selectedTile,
|
||||||
|
actionButtons,
|
||||||
connectWs,
|
connectWs,
|
||||||
sendStartGame,
|
sendStartGame,
|
||||||
|
selectTile,
|
||||||
|
sendGameAction,
|
||||||
backHall,
|
backHall,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ export type RoomStatus = 'waiting' | 'playing' | 'finished'
|
|||||||
export interface RoomPlayerState {
|
export interface RoomPlayerState {
|
||||||
index: number
|
index: number
|
||||||
playerId: string
|
playerId: string
|
||||||
|
displayName?: string
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
handCount?: number
|
||||||
|
melds?: string[]
|
||||||
|
outTiles?: string[]
|
||||||
|
hasHu?: boolean
|
||||||
|
missingSuit?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuleState {
|
export interface RuleState {
|
||||||
@@ -57,6 +63,7 @@ export interface RoomState {
|
|||||||
game: GameState | null
|
game: GameState | null
|
||||||
players: RoomPlayerState[]
|
players: RoomPlayerState[]
|
||||||
currentTurnIndex: number | null
|
currentTurnIndex: number | null
|
||||||
|
myHand: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function createInitialRoomState(): RoomState {
|
function createInitialRoomState(): RoomState {
|
||||||
@@ -73,6 +80,7 @@ function createInitialRoomState(): RoomState {
|
|||||||
game: null,
|
game: null,
|
||||||
players: [],
|
players: [],
|
||||||
currentTurnIndex: null,
|
currentTurnIndex: null,
|
||||||
|
myHand: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +100,7 @@ export function resetActiveRoomState(seed?: Partial<RoomState>): void {
|
|||||||
...activeRoomState.value,
|
...activeRoomState.value,
|
||||||
...seed,
|
...seed,
|
||||||
players: seed.players ?? [],
|
players: seed.players ?? [],
|
||||||
|
myHand: seed.myHand ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +112,17 @@ export function mergeActiveRoomState(next: RoomState): void {
|
|||||||
activeRoomState.value = {
|
activeRoomState.value = {
|
||||||
...activeRoomState.value,
|
...activeRoomState.value,
|
||||||
...next,
|
...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,
|
game: next.game ?? activeRoomState.value.game,
|
||||||
players: next.players.length > 0 ? next.players : activeRoomState.value.players,
|
players: next.players.length > 0 ? next.players : activeRoomState.value.players,
|
||||||
currentTurnIndex:
|
currentTurnIndex:
|
||||||
next.currentTurnIndex !== null ? next.currentTurnIndex : activeRoomState.value.currentTurnIndex,
|
next.currentTurnIndex !== null ? next.currentTurnIndex : activeRoomState.value.currentTurnIndex,
|
||||||
|
myHand: next.myHand.length > 0 ? next.myHand : activeRoomState.value.myHand,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +138,7 @@ export function hydrateActiveRoomFromSelection(input: {
|
|||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
players?: RoomPlayerState[]
|
players?: RoomPlayerState[]
|
||||||
currentTurnIndex?: number | null
|
currentTurnIndex?: number | null
|
||||||
|
myHand?: string[]
|
||||||
}): void {
|
}): void {
|
||||||
resetActiveRoomState({
|
resetActiveRoomState({
|
||||||
id: input.roomId,
|
id: input.roomId,
|
||||||
@@ -135,5 +152,6 @@ export function hydrateActiveRoomFromSelection(input: {
|
|||||||
updatedAt: input.updatedAt ?? '',
|
updatedAt: input.updatedAt ?? '',
|
||||||
players: input.players ?? [],
|
players: input.players ?? [],
|
||||||
currentTurnIndex: input.currentTurnIndex ?? null,
|
currentTurnIndex: input.currentTurnIndex ?? null,
|
||||||
|
myHand: input.myHand ?? [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ const {
|
|||||||
leaveRoomPending,
|
leaveRoomPending,
|
||||||
canStartGame,
|
canStartGame,
|
||||||
seatViews,
|
seatViews,
|
||||||
|
selectedTile,
|
||||||
|
actionButtons,
|
||||||
connectWs,
|
connectWs,
|
||||||
sendStartGame,
|
sendStartGame,
|
||||||
|
selectTile,
|
||||||
|
sendGameAction,
|
||||||
backHall,
|
backHall,
|
||||||
} = useChengduGameRoom(route, router)
|
} = useChengduGameRoom(route, router)
|
||||||
|
|
||||||
@@ -129,7 +133,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
const score = scoreMap[playerId]
|
const score = scoreMap[playerId]
|
||||||
result[seat.key] = {
|
result[seat.key] = {
|
||||||
avatar: seat.isSelf ? '我' : String(index + 1),
|
avatar: seat.isSelf ? '我' : String(index + 1),
|
||||||
name: seat.isSelf ? '你自己' : playerId,
|
name: seat.isSelf ? '你自己' : seat.player.displayName || `玩家${seat.player.index + 1}`,
|
||||||
money: typeof score === 'number' ? String(score) : '--',
|
money: typeof score === 'number' ? String(score) : '--',
|
||||||
dealer: seat.player.index === dealerIndex,
|
dealer: seat.player.index === dealerIndex,
|
||||||
isTurn: seat.isTurn,
|
isTurn: seat.isTurn,
|
||||||
@@ -170,6 +174,23 @@ const centerTimer = computed(() => {
|
|||||||
: '等待中'
|
: '等待中'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pendingClaimText = computed(() => {
|
||||||
|
const claim = roomState.value.game?.state?.pendingClaim
|
||||||
|
if (!claim) {
|
||||||
|
return '无'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(claim)
|
||||||
|
} catch {
|
||||||
|
return '存在响应窗口'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedTileText = computed(() => {
|
||||||
|
return selectedTile.value ?? '未选择'
|
||||||
|
})
|
||||||
|
|
||||||
function missingSuitLabel(value: string | null | undefined): string {
|
function missingSuitLabel(value: string | null | undefined): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '未定'
|
return '未定'
|
||||||
@@ -270,6 +291,51 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="table-panel game-table-panel action-panel">
|
||||||
|
<div class="action-grid">
|
||||||
|
<div class="action-card">
|
||||||
|
<h3>我的手牌</h3>
|
||||||
|
<p class="action-hint">当前仅渲染 `my_hand` 事件下发的真实手牌。</p>
|
||||||
|
<div class="hand-wall" v-if="roomState.myHand.length > 0">
|
||||||
|
<button
|
||||||
|
v-for="(tile, index) in roomState.myHand"
|
||||||
|
:key="`${tile}-${index}`"
|
||||||
|
class="tile-chip"
|
||||||
|
type="button"
|
||||||
|
:class="{ selected: selectedTile === tile }"
|
||||||
|
@click="selectTile(tile)"
|
||||||
|
>
|
||||||
|
{{ tile }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="action-empty">尚未收到 `my_hand`。</p>
|
||||||
|
<p class="action-meta">已选牌:{{ selectedTileText }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-card">
|
||||||
|
<h3>对局动作</h3>
|
||||||
|
<p class="action-hint">已接入 `draw / discard / peng / gang / hu / pass` WS 发包。</p>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button
|
||||||
|
v-for="action in actionButtons"
|
||||||
|
:key="action.type"
|
||||||
|
class="primary-btn action-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="action.disabled"
|
||||||
|
@click="sendGameAction(action.type)"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="action-meta">当前响应窗口:{{ pendingClaimText }}</p>
|
||||||
|
<p class="action-meta">
|
||||||
|
最近弃牌:{{ roomState.game?.state?.lastDiscardTile || '无' }}
|
||||||
|
/ {{ roomState.game?.state?.lastDiscardBy || '无' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="table-shell">
|
<section class="table-shell">
|
||||||
<img class="table-desk" :src="deskImage" alt="" />
|
<img class="table-desk" :src="deskImage" alt="" />
|
||||||
<div class="table-felt">
|
<div class="table-felt">
|
||||||
@@ -345,3 +411,69 @@ onBeforeUnmount(() => {
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.action-panel {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
background: rgba(10, 27, 22, 0.72);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-hint,
|
||||||
|
.action-meta,
|
||||||
|
.action-empty {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hand-wall,
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-chip {
|
||||||
|
min-width: 56px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
background: rgba(247, 239, 220, 0.9);
|
||||||
|
color: #1c1b18;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-chip.selected {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
border-color: #f4c76a;
|
||||||
|
box-shadow: 0 10px 24px rgba(244, 199, 106, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
min-width: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.action-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user