first commit

This commit is contained in:
2026-02-18 14:30:42 +08:00
commit f79920ad6a
212 changed files with 3850 additions and 0 deletions

View File

@@ -0,0 +1,579 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { readStoredAuth } from '../utils/auth-storage'
const router = useRouter()
const route = useRoute()
const auth = ref(readStoredAuth())
const ws = ref<WebSocket | null>(null)
const wsStatus = ref<'disconnected' | 'connecting' | 'connected'>('disconnected')
const wsError = ref('')
const wsMessages = ref<string[]>([])
const startGamePending = ref(false)
const lastStartRequestId = ref('')
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? 'ws://127.0.0.1:8080/ws'
const DEFAULT_MAX_PLAYERS = 4
type SeatKey = 'top' | 'right' | 'bottom' | 'left'
interface RoomPlayerState {
index: number
playerId: string
ready: boolean
}
interface RoomState {
id: string
name: string
ownerId: string
maxPlayers: number
status: string
players: RoomPlayerState[]
currentTurnIndex: number | null
}
interface ActionEventLike {
type?: unknown
status?: unknown
requestId?: unknown
roomId?: unknown
room_id?: unknown
payload?: unknown
data?: unknown
}
const roomId = computed(() => {
return typeof route.params.roomId === 'string' ? route.params.roomId : ''
})
const roomName = computed(() => {
return typeof route.query.roomName === 'string' ? route.query.roomName : ''
})
const currentUserId = computed(() => {
const candidate = auth.value?.user?.id
if (typeof candidate === 'string') {
return candidate
}
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
return String(candidate)
}
return ''
})
const loggedInUserName = computed(() => {
if (!auth.value?.user) {
return ''
}
return auth.value.user.nickname ?? auth.value.user.username ?? ''
})
const roomState = ref<RoomState>({
id: roomId.value,
name: roomName.value,
ownerId: '',
maxPlayers: DEFAULT_MAX_PLAYERS,
status: 'waiting',
players: [],
currentTurnIndex: null,
})
const isRoomFull = computed(() => {
return (
roomState.value.maxPlayers > 0 &&
roomState.value.players.length === roomState.value.maxPlayers
)
})
const canStartGame = computed(() => {
return (
Boolean(roomState.value.id) &&
roomState.value.status === 'waiting' &&
isRoomFull.value &&
Boolean(currentUserId.value) &&
roomState.value.ownerId === currentUserId.value
)
})
const seatViews = computed(() => {
const seats: Record<SeatKey, RoomPlayerState | null> = {
top: null,
right: null,
bottom: null,
left: null,
}
const players = [...roomState.value.players].sort((a, b) => a.index - b.index)
const me = players.find((player) => player.playerId === currentUserId.value) ?? null
const anchorIndex = me?.index ?? players[0]?.index ?? 0
const clockwiseSeatByDelta: SeatKey[] = ['bottom', 'left', 'top', 'right']
for (const player of players) {
const normalizedDelta = ((player.index - anchorIndex) % 4 + 4) % 4
const seat = clockwiseSeatByDelta[normalizedDelta] ?? 'top'
seats[seat] = player
}
const turnSeat =
roomState.value.currentTurnIndex === null
? null
: clockwiseSeatByDelta[
((roomState.value.currentTurnIndex - anchorIndex) % 4 + 4) % 4
] ?? null
const order: SeatKey[] = ['top', 'right', 'bottom', 'left']
return order.map((seat) => {
const player = seats[seat]
const isSelf = Boolean(player) && player?.playerId === currentUserId.value
return {
key: seat,
player,
isSelf,
isTurn: turnSeat === seat,
label: player ? (isSelf ? '你' : player.playerId) : '空位',
subLabel: player ? `座位 ${player.index}` : '',
}
})
})
const roomStatusText = computed(() => {
if (roomState.value.status === 'playing') {
return '对局中'
}
if (roomState.value.status === 'finished') {
return '已结束'
}
return '等待中'
})
function backHall(): void {
sendLeaveRoom()
void router.push('/hall')
}
function pushWsMessage(text: string): void {
const line = `[${new Date().toLocaleTimeString()}] ${text}`
wsMessages.value.unshift(line)
if (wsMessages.value.length > 80) {
wsMessages.value.length = 80
}
}
function logWsSend(message: unknown): void {
console.log('[WS][client] 发送:', message)
}
function logWsReceive(kind: string, payload?: unknown): void {
const now = new Date().toLocaleTimeString()
if (payload === undefined) {
console.log(`[WS][${now}] 收到${kind}`)
return
}
console.log(`[WS][${now}] 收到${kind}:`, payload)
}
function disconnectWs(): void {
if (ws.value) {
ws.value.close()
ws.value = null
}
wsStatus.value = 'disconnected'
}
function toRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
function toStringOrEmpty(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return ''
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null {
const player = toRecord(input)
if (!player) {
return null
}
const playerId = toStringOrEmpty(player.playerId ?? player.player_id ?? player.user_id ?? player.id)
if (!playerId) {
return null
}
const seatIndex = toFiniteNumber(player.index ?? player.seat ?? player.position ?? player.player_index)
return {
index: seatIndex ?? fallbackIndex,
playerId,
ready: Boolean(player.ready),
}
}
function extractCurrentTurnIndex(value: Record<string, unknown>): number | null {
const keys = [
value.currentTurnIndex,
value.current_turn_index,
value.currentPlayerIndex,
value.current_player_index,
value.turnIndex,
value.turn_index,
value.activePlayerIndex,
value.active_player_index,
]
for (const key of keys) {
const parsed = toFiniteNumber(key)
if (parsed !== null) {
return parsed
}
}
return null
}
function normalizeRoom(input: unknown): RoomState | null {
const room = toRecord(input)
if (!room) {
return null
}
const id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
if (!id) {
return null
}
const maxPlayers =
toFiniteNumber(room.maxPlayers ?? room.max_players) ?? roomState.value.maxPlayers ?? DEFAULT_MAX_PLAYERS
const playersRaw =
(Array.isArray(room.players) ? room.players : null) ??
(Array.isArray(room.playerList) ? room.playerList : null) ??
(Array.isArray(room.player_list) ? room.player_list : null) ??
[]
const players = playersRaw
.map((item, index) => normalizePlayer(item, index))
.filter((item): item is RoomPlayerState => Boolean(item))
.sort((a, b) => a.index - b.index)
return {
id,
name: toStringOrEmpty(room.name) || roomState.value.name,
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id),
maxPlayers,
status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting',
players,
currentTurnIndex: extractCurrentTurnIndex(room),
}
}
function mergeRoomState(next: RoomState): void {
if (roomId.value && next.id !== roomId.value) {
return
}
roomState.value = {
...roomState.value,
...next,
players: next.players.length > 0 ? next.players : roomState.value.players,
currentTurnIndex:
next.currentTurnIndex !== null ? next.currentTurnIndex : roomState.value.currentTurnIndex,
}
}
function consumeGameEvent(raw: string): void {
let parsed: unknown = null
try {
parsed = JSON.parse(raw)
} catch {
return
}
const event = toRecord(parsed) as ActionEventLike | null
if (!event) {
return
}
const candidates: unknown[] = [event, event.payload, event.data]
const payload = toRecord(event.payload)
if (payload) {
candidates.push(payload.room, payload.state, payload.roomState)
}
for (const candidate of candidates) {
const normalized = normalizeRoom(candidate)
if (normalized) {
mergeRoomState(normalized)
break
}
}
if (
event.status === 'error' &&
typeof event.requestId === 'string' &&
event.requestId === lastStartRequestId.value
) {
startGamePending.value = false
}
}
function createRequestId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function sendStartGame(): void {
if (!ws.value || ws.value.readyState !== WebSocket.OPEN || !canStartGame.value || startGamePending.value) {
return
}
const sender = currentUserId.value
if (!sender) {
return
}
const requestId = createRequestId('start-game')
lastStartRequestId.value = requestId
startGamePending.value = true
const message = {
type: 'start_game',
sender,
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] 请求开始游戏 requestId=${requestId}`)
}
function sendLeaveRoom(): void {
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
return
}
const sender = currentUserId.value
const targetRoomId = roomState.value.id || roomId.value
if (!sender || !targetRoomId) {
return
}
const requestId = createRequestId('leave-room')
const message = {
type: 'leave_room',
sender,
target: 'room',
roomId: targetRoomId,
seq: Date.now(),
requestId,
trace_id: createRequestId('trace'),
payload: {},
}
logWsSend(message)
ws.value.send(JSON.stringify(message))
pushWsMessage(`[client] 请求退出房间 requestId=${requestId}`)
}
function connectWs(): void {
wsError.value = ''
const token = auth.value?.token
if (!token) {
wsError.value = '缺少 token无法建立 WebSocket 连接'
return
}
disconnectWs()
wsStatus.value = 'connecting'
const url = `${WS_BASE_URL}?token=${encodeURIComponent(token)}`
const socket = new WebSocket(url)
ws.value = socket
socket.onopen = () => {
wsStatus.value = 'connected'
pushWsMessage('WebSocket 已连接')
}
socket.onmessage = (event) => {
if (typeof event.data === 'string') {
logWsReceive('文本消息', event.data)
try {
const parsed = JSON.parse(event.data)
logWsReceive('JSON消息', parsed)
pushWsMessage(`[server] ${JSON.stringify(parsed, null, 2)}`)
} catch {
pushWsMessage(`[server] ${event.data}`)
}
consumeGameEvent(event.data)
return
}
logWsReceive('二进制消息')
pushWsMessage('[binary] 收到二进制消息')
}
socket.onerror = () => {
wsError.value = 'WebSocket 连接异常'
}
socket.onclose = () => {
wsStatus.value = 'disconnected'
startGamePending.value = false
pushWsMessage('WebSocket 已断开')
}
}
watch(
roomId,
(nextRoomId) => {
roomState.value = {
id: nextRoomId,
name: roomName.value,
ownerId: '',
maxPlayers: DEFAULT_MAX_PLAYERS,
status: 'waiting',
players: [],
currentTurnIndex: null,
}
startGamePending.value = false
lastStartRequestId.value = ''
},
{ immediate: true },
)
watch(roomName, (next) => {
roomState.value = {
...roomState.value,
name: next || roomState.value.name,
}
})
watch(
[canStartGame, wsStatus],
([canStart, status]) => {
if (!canStart || status !== 'connected') {
return
}
sendStartGame()
},
{ immediate: true },
)
watch(
() => roomState.value.status,
(status) => {
if (status === 'playing' || status === 'finished') {
startGamePending.value = false
}
},
)
onMounted(() => {
connectWs()
})
onBeforeUnmount(() => {
disconnectWs()
})
</script>
<template>
<section class="hall-page game-page">
<header class="hall-header game-header">
<div>
<h1>成都麻将对局</h1>
<p v-if="loggedInUserName" class="sub-title">玩家{{ loggedInUserName }}</p>
</div>
<div class="header-actions">
<button class="ghost-btn" type="button" @click="backHall">返回大厅</button>
</div>
</header>
<section class="table-panel game-table-panel">
<div class="room-brief">
<span class="room-brief-title">当前房间</span>
<span class="room-brief-item">
<em>房间名</em>
<strong>{{ roomState.name || roomName || '未命名房间' }}</strong>
</span>
<span class="room-brief-item room-brief-id">
<em>room_id</em>
<strong>{{ roomId || '未选择房间' }}</strong>
</span>
<span class="room-brief-item">
<em>状态</em>
<strong>{{ roomStatusText }}</strong>
</span>
<span class="room-brief-item">
<em>人数</em>
<strong>{{ roomState.players.length }}/{{ roomState.maxPlayers }}</strong>
</span>
<button
class="ghost-btn ws-reconnect"
type="button"
:disabled="!canStartGame || startGamePending"
@click="sendStartGame"
>
{{ startGamePending ? '开局请求中...' : '开始游戏' }}
</button>
</div>
<div class="mahjong-table game-mahjong-table" :class="{ active: Boolean(roomId) }">
<div
v-for="seat in seatViews"
:key="seat.key"
class="seat"
:class="[
`seat-${seat.key}`,
{ occupied: Boolean(seat.player), 'seat-me': seat.isSelf, 'seat-turn': seat.isTurn },
]"
>
<strong>{{ seat.label }}</strong>
<small v-if="seat.subLabel">{{ seat.subLabel }}</small>
<span v-if="seat.isTurn" class="turn-indicator">出牌中</span>
</div>
<div class="table-center">
<p>Chengdu Mahjong</p>
<p>{{ roomState.id || roomId || 'Waiting...' }}</p>
</div>
</div>
<section class="ws-panel">
<div class="ws-panel-head">
<strong>实时消息</strong>
<div class="ws-actions">
<span class="ws-state" :class="`is-${wsStatus}`">{{ wsStatus }}</span>
<button class="ghost-btn ws-reconnect" type="button" @click="connectWs">重连</button>
</div>
</div>
<p v-if="wsError" class="message error">{{ wsError }}</p>
<div class="ws-log">
<p v-if="wsMessages.length === 0" class="ws-empty">等待服务器消息...</p>
<p v-for="(line, idx) in wsMessages" :key="idx" class="ws-line">{{ line }}</p>
</div>
</section>
</section>
</section>
</template>