Compare commits
31 Commits
6fde4bbc0d
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e495dc6070 | |||
| 3c876c4c3d | |||
| cfc65070ea | |||
| 0bf68d4e49 | |||
| e96c45739e | |||
| 39d85f2998 | |||
| e6cba75f9b | |||
| 6c3fca3530 | |||
| 9b3e3fdb90 | |||
| e435fa9d96 | |||
| 941d878931 | |||
| 100d950eb8 | |||
| 06b25bde62 | |||
| 2625baf266 | |||
| 43439cb09d | |||
| be9bd8c76d | |||
| 623ee94b04 | |||
| 7751d3b8e3 | |||
| 5c9c2a180d | |||
| 4f7a54cf08 | |||
| d60a505226 | |||
| d1220cc45d | |||
| 7289635340 | |||
| dc09c7e487 | |||
| fd8f6d47fa | |||
| b1e394d675 | |||
| 921f47d916 | |||
| 0fa3c4f1df | |||
| 603f910e8b | |||
| f3137493af | |||
| 0f1684b8d7 |
@@ -1,4 +1,4 @@
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
VITE_GAME_WS_URL=/ws
|
||||
VITE_API_PROXY_TARGET=http://192.168.1.5:19000
|
||||
VITE_WS_PROXY_TARGET=http://192.168.1.5:19000
|
||||
VITE_API_PROXY_TARGET=http://127.0.0.1:19000
|
||||
VITE_WS_PROXY_TARGET=http://127.0.0.1:19000
|
||||
|
||||
@@ -27,3 +27,6 @@ Preview the production build:
|
||||
```bash
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
测试账号:A,B,C,D
|
||||
测试密码:123456
|
||||
@@ -31,6 +31,7 @@ HTTP 接口:
|
||||
{
|
||||
"name": "房间名",
|
||||
"game_type": "chengdu",
|
||||
"total_rounds": 8,
|
||||
"max_players": 4
|
||||
}
|
||||
```
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test"
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:live": "PLAYWRIGHT_LIVE=1 playwright test tests/e2e/room-flow.live.spec.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
14
playwright.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
headless: true,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
})
|
||||
263
src/api/auth.ts
@@ -1,26 +1,26 @@
|
||||
export interface AuthUser {
|
||||
id?: string | number
|
||||
username?: string
|
||||
nickname?: string
|
||||
id?: string | number
|
||||
username?: string
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
export interface AuthSessionInput {
|
||||
token: string
|
||||
tokenType?: string
|
||||
refreshToken?: string
|
||||
token: string
|
||||
tokenType?: string
|
||||
refreshToken?: string
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
token: string
|
||||
tokenType?: string
|
||||
refreshToken?: string
|
||||
expiresIn?: number
|
||||
user?: AuthUser
|
||||
token: string
|
||||
tokenType?: string
|
||||
refreshToken?: string
|
||||
expiresIn?: number
|
||||
user?: AuthUser
|
||||
}
|
||||
|
||||
interface ApiErrorPayload {
|
||||
message?: string
|
||||
error?: string
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').trim().replace(/\/$/, '')
|
||||
@@ -30,165 +30,178 @@ const REFRESH_PATH = import.meta.env.VITE_REFRESH_PATH ?? '/api/v1/auth/refresh'
|
||||
const LOGIN_BEARER_TOKEN = (import.meta.env.VITE_LOGIN_BEARER_TOKEN ?? '').trim()
|
||||
|
||||
function buildUrl(path: string): string {
|
||||
if (/^https?:\/\//.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
if (!API_BASE_URL) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
if (API_BASE_URL.startsWith('/')) {
|
||||
const basePath = API_BASE_URL.startsWith('/') ? API_BASE_URL : `/${API_BASE_URL}`
|
||||
if (normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`)) {
|
||||
return normalizedPath
|
||||
if (/^https?:\/\//.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
return `${basePath}${normalizedPath}`
|
||||
}
|
||||
|
||||
// Avoid duplicated API prefix, e.g. base: /api/v1 + path: /api/v1/auth/login
|
||||
try {
|
||||
const baseUrl = new URL(API_BASE_URL)
|
||||
const basePath = baseUrl.pathname.replace(/\/$/, '')
|
||||
if (basePath && normalizedPath.startsWith(`${basePath}/`)) {
|
||||
return `${API_BASE_URL}${normalizedPath.slice(basePath.length)}`
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
if (!API_BASE_URL) {
|
||||
return normalizedPath
|
||||
}
|
||||
} catch {
|
||||
// API_BASE_URL may be a relative path; fallback to direct join.
|
||||
}
|
||||
|
||||
return `${API_BASE_URL}${normalizedPath}`
|
||||
if (API_BASE_URL.startsWith('/')) {
|
||||
const basePath = API_BASE_URL.startsWith('/') ? API_BASE_URL : `/${API_BASE_URL}`
|
||||
if (normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`)) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
return `${basePath}${normalizedPath}`
|
||||
}
|
||||
|
||||
// Avoid duplicated API prefix, e.g. base: /api/v1 + path: /api/v1/auth/login
|
||||
try {
|
||||
const baseUrl = new URL(API_BASE_URL)
|
||||
const basePath = baseUrl.pathname.replace(/\/$/, '')
|
||||
if (basePath && normalizedPath.startsWith(`${basePath}/`)) {
|
||||
return `${API_BASE_URL}${normalizedPath.slice(basePath.length)}`
|
||||
}
|
||||
} catch {
|
||||
// API_BASE_URL may be a relative path; fallback to direct join.
|
||||
}
|
||||
|
||||
return `${API_BASE_URL}${normalizedPath}`
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
extraHeaders?: Record<string, string>,
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
extraHeaders?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...extraHeaders,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...extraHeaders,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message ?? payload.error ?? '请求失败,请稍后再试')
|
||||
}
|
||||
const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message ?? payload.error ?? '请求失败,请稍后再试')
|
||||
}
|
||||
|
||||
return payload
|
||||
return payload
|
||||
}
|
||||
|
||||
function createAuthHeader(token: string, tokenType = 'Bearer'): string {
|
||||
const normalizedToken = token.trim()
|
||||
if (/^\S+\s+\S+/.test(normalizedToken)) {
|
||||
return normalizedToken
|
||||
}
|
||||
const normalizedToken = token.trim()
|
||||
if (/^\S+\s+\S+/.test(normalizedToken)) {
|
||||
return normalizedToken
|
||||
}
|
||||
|
||||
return `${tokenType || 'Bearer'} ${normalizedToken}`
|
||||
return `${tokenType || 'Bearer'} ${normalizedToken}`
|
||||
}
|
||||
|
||||
function extractToken(payload: Record<string, unknown>): string {
|
||||
const candidate =
|
||||
payload.token ??
|
||||
payload.accessToken ??
|
||||
payload.access_token ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.token ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.accessToken ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.access_token
|
||||
const candidate =
|
||||
payload.token ??
|
||||
payload.accessToken ??
|
||||
payload.access_token ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.token ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.accessToken ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.access_token
|
||||
|
||||
if (typeof candidate !== 'string' || candidate.length === 0) {
|
||||
throw new Error('登录成功,但后端未返回 token 字段')
|
||||
}
|
||||
if (typeof candidate !== 'string' || candidate.length === 0) {
|
||||
throw new Error('登录成功,但后端未返回 token 字段')
|
||||
}
|
||||
|
||||
return candidate
|
||||
return candidate
|
||||
}
|
||||
|
||||
function extractTokenType(payload: Record<string, unknown>): string | undefined {
|
||||
const candidate =
|
||||
payload.token_type ??
|
||||
payload.tokenType ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.token_type ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.tokenType
|
||||
const candidate =
|
||||
payload.token_type ??
|
||||
payload.tokenType ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.token_type ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.tokenType
|
||||
|
||||
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
|
||||
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
|
||||
}
|
||||
|
||||
function extractRefreshToken(payload: Record<string, unknown>): string | undefined {
|
||||
const candidate =
|
||||
payload.refresh_token ??
|
||||
payload.refreshToken ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.refresh_token ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.refreshToken
|
||||
const candidate =
|
||||
payload.refresh_token ??
|
||||
payload.refreshToken ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.refresh_token ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.refreshToken
|
||||
|
||||
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
|
||||
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
|
||||
}
|
||||
|
||||
function extractExpiresIn(payload: Record<string, unknown>): number | undefined {
|
||||
const candidate =
|
||||
payload.expires_in ??
|
||||
payload.expiresIn ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.expires_in ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.expiresIn
|
||||
const candidate =
|
||||
payload.expires_in ??
|
||||
payload.expiresIn ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.expires_in ??
|
||||
(payload.data as Record<string, unknown> | undefined)?.expiresIn
|
||||
|
||||
return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined
|
||||
return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined
|
||||
}
|
||||
|
||||
function extractUser(payload: Record<string, unknown>): AuthUser | undefined {
|
||||
const user = payload.user ?? (payload.data as Record<string, unknown> | undefined)?.user
|
||||
return typeof user === 'object' && user !== null ? (user as AuthUser) : undefined
|
||||
const user = payload.user ?? (payload.data as Record<string, unknown> | undefined)?.user
|
||||
return typeof user === 'object' && user !== null ? (user as AuthUser) : undefined
|
||||
}
|
||||
|
||||
function parseAuthResult(payload: Record<string, unknown>): AuthResult {
|
||||
return {
|
||||
token: extractToken(payload),
|
||||
tokenType: extractTokenType(payload),
|
||||
refreshToken: extractRefreshToken(payload),
|
||||
expiresIn: extractExpiresIn(payload),
|
||||
user: extractUser(payload),
|
||||
}
|
||||
return {
|
||||
token: extractToken(payload),
|
||||
tokenType: extractTokenType(payload),
|
||||
refreshToken: extractRefreshToken(payload),
|
||||
expiresIn: extractExpiresIn(payload),
|
||||
user: extractUser(payload),
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(input: {
|
||||
username: string
|
||||
phone: string
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
phone: string
|
||||
email: string
|
||||
password: string
|
||||
}): Promise<void> {
|
||||
await request<Record<string, unknown>>(buildUrl(REGISTER_PATH), input)
|
||||
await request<Record<string, unknown>>(buildUrl(REGISTER_PATH), input)
|
||||
}
|
||||
|
||||
export async function login(input: { loginId: string; password: string }): Promise<AuthResult> {
|
||||
const payload = await request<Record<string, unknown>>(
|
||||
buildUrl(LOGIN_PATH),
|
||||
{
|
||||
login_id: input.loginId,
|
||||
password: input.password,
|
||||
},
|
||||
LOGIN_BEARER_TOKEN ? { Authorization: `Bearer ${LOGIN_BEARER_TOKEN}` } : undefined,
|
||||
)
|
||||
return parseAuthResult(payload)
|
||||
const payload = await request<Record<string, unknown>>(
|
||||
buildUrl(LOGIN_PATH),
|
||||
{
|
||||
login_id: input.loginId,
|
||||
password: input.password,
|
||||
},
|
||||
LOGIN_BEARER_TOKEN ? {Authorization: `Bearer ${LOGIN_BEARER_TOKEN}`} : undefined,
|
||||
)
|
||||
return parseAuthResult(payload)
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(input: AuthSessionInput): Promise<AuthResult> {
|
||||
if (!input.refreshToken) {
|
||||
throw new Error('缺少 refresh_token,无法刷新登录状态')
|
||||
}
|
||||
if (!input.refreshToken) {
|
||||
throw new Error('缺少 refresh_token,无法刷新登录状态')
|
||||
}
|
||||
|
||||
const payload = await request<Record<string, unknown>>(
|
||||
buildUrl(REFRESH_PATH),
|
||||
{
|
||||
refreshToken: input.refreshToken,
|
||||
},
|
||||
{
|
||||
Authorization: createAuthHeader(input.token, input.tokenType),
|
||||
},
|
||||
)
|
||||
const refreshBody = {
|
||||
refreshToken: input.refreshToken
|
||||
}
|
||||
|
||||
return parseAuthResult(payload)
|
||||
// 兼容不同后端实现:
|
||||
// 1) 有的要求 Authorization + refresh token
|
||||
// 2) 有的只接受 refresh token,不接受 Authorization
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = await request<Record<string, unknown>>(
|
||||
buildUrl(REFRESH_PATH),
|
||||
refreshBody,
|
||||
{
|
||||
Authorization: createAuthHeader(input.token, input.tokenType),
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
payload = await request<Record<string, unknown>>(
|
||||
buildUrl(REFRESH_PATH),
|
||||
refreshBody,
|
||||
)
|
||||
}
|
||||
|
||||
return parseAuthResult(payload)
|
||||
}
|
||||
|
||||
@@ -1,78 +1,79 @@
|
||||
import { authedRequest, type AuthSession } from './authed-request'
|
||||
import {authedRequest, type AuthSession} from './authed-request'
|
||||
|
||||
export interface RoomItem {
|
||||
room_id: string
|
||||
name: string
|
||||
game_type: string
|
||||
owner_id: string
|
||||
max_players: number
|
||||
player_count: number
|
||||
players?: Array<{
|
||||
index: number
|
||||
player_id: string
|
||||
player_name?: string
|
||||
PlayerName?: string
|
||||
ready: boolean
|
||||
}>
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
export interface Room {
|
||||
room_id: string
|
||||
name: string
|
||||
game_type: string
|
||||
owner_id: string
|
||||
max_players: number
|
||||
player_count: number
|
||||
players?: Array<{
|
||||
index: number
|
||||
player_id: string
|
||||
player_name?: string
|
||||
PlayerName?: string
|
||||
ready: boolean
|
||||
}>
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface RoomListResult {
|
||||
items: RoomItem[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
items: Room[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const ROOM_CREATE_PATH =
|
||||
import.meta.env.VITE_ROOM_CREATE_PATH ?? '/api/v1/game/mahjong/room/create'
|
||||
import.meta.env.VITE_ROOM_CREATE_PATH ?? '/api/v1/game/mahjong/room/create'
|
||||
const ROOM_LIST_PATH = import.meta.env.VITE_ROOM_LIST_PATH ?? '/api/v1/game/mahjong/room/list'
|
||||
const ROOM_JOIN_PATH = import.meta.env.VITE_ROOM_JOIN_PATH ?? '/api/v1/game/mahjong/room/join'
|
||||
|
||||
export async function createRoom(
|
||||
auth: AuthSession,
|
||||
input: { name: string; gameType: string; maxPlayers: number },
|
||||
onAuthUpdated?: (next: AuthSession) => void,
|
||||
): Promise<RoomItem> {
|
||||
return authedRequest<RoomItem>({
|
||||
method: 'POST',
|
||||
path: ROOM_CREATE_PATH,
|
||||
auth,
|
||||
onAuthUpdated,
|
||||
body: {
|
||||
name: input.name,
|
||||
game_type: input.gameType,
|
||||
max_players: input.maxPlayers,
|
||||
},
|
||||
})
|
||||
auth: AuthSession,
|
||||
input: { name: string; gameType: string; totalRounds: number; maxPlayers: number },
|
||||
onAuthUpdated?: (next: AuthSession) => void,
|
||||
): Promise<Room> {
|
||||
return authedRequest<Room>({
|
||||
method: 'POST',
|
||||
path: ROOM_CREATE_PATH,
|
||||
auth,
|
||||
onAuthUpdated,
|
||||
body: {
|
||||
name: input.name,
|
||||
game_type: input.gameType,
|
||||
total_rounds: input.totalRounds,
|
||||
max_players: input.maxPlayers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function listRooms(
|
||||
auth: AuthSession,
|
||||
onAuthUpdated?: (next: AuthSession) => void,
|
||||
auth: AuthSession,
|
||||
onAuthUpdated?: (next: AuthSession) => void,
|
||||
): Promise<RoomListResult> {
|
||||
return authedRequest<RoomListResult>({
|
||||
method: 'GET',
|
||||
path: ROOM_LIST_PATH,
|
||||
auth,
|
||||
onAuthUpdated,
|
||||
})
|
||||
return authedRequest<RoomListResult>({
|
||||
method: 'GET',
|
||||
path: ROOM_LIST_PATH,
|
||||
auth,
|
||||
onAuthUpdated,
|
||||
})
|
||||
}
|
||||
|
||||
export async function joinRoom(
|
||||
auth: AuthSession,
|
||||
input: { roomId: string },
|
||||
onAuthUpdated?: (next: AuthSession) => void,
|
||||
): Promise<RoomItem> {
|
||||
return authedRequest<RoomItem>({
|
||||
method: 'POST',
|
||||
path: ROOM_JOIN_PATH,
|
||||
auth,
|
||||
onAuthUpdated,
|
||||
body: {
|
||||
room_id: input.roomId,
|
||||
},
|
||||
})
|
||||
auth: AuthSession,
|
||||
input: { roomId: string },
|
||||
onAuthUpdated?: (next: AuthSession) => void,
|
||||
): Promise<Room> {
|
||||
return authedRequest<Room>({
|
||||
method: 'POST',
|
||||
path: ROOM_JOIN_PATH,
|
||||
auth,
|
||||
onAuthUpdated,
|
||||
body: {
|
||||
room_id: input.roomId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
BIN
src/assets/images/desk/desk_01_1920_945.png
Normal file
|
After Width: | Height: | Size: 1006 KiB |
BIN
src/assets/images/direction/bei.png
Executable file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src/assets/images/direction/dong.png
Executable file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
src/assets/images/direction/nan.png
Executable file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src/assets/images/direction/xi.png
Executable file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
src/assets/images/icons/cancel.png
Executable file
|
After Width: | Height: | Size: 11 KiB |
BIN
src/assets/images/icons/read.png
Executable file
|
After Width: | Height: | Size: 11 KiB |
1
src/assets/images/icons/square.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="1774505292809" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15817" width="256" height="256" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M1024 127.937531v767.625183c0 70.665495-57.272035 127.937531-127.937531 127.93753h-767.625183c-70.665495 0-127.937531-57.272035-127.93753-127.93753v-767.625183c0-70.665495 57.272035-127.937531 127.93753-127.937531h767.625183c70.665495 0 127.937531 57.272035 127.937531 127.937531z" p-id="15818" fill="#ffffff"></path></svg>
|
||||
|
After Width: | Height: | Size: 657 B |
1
src/assets/images/icons/triangle.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="1774491457300" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6759" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M535.466667 812.8l450.133333-563.2c14.933333-19.2 2.133333-49.066667-23.466667-49.066667H61.866667c-25.6 0-38.4 29.866667-23.466667 49.066667l450.133333 563.2c12.8 14.933333 34.133333 14.933333 46.933334 0z" fill="#ffffff" p-id="6760"></path></svg>
|
||||
|
After Width: | Height: | Size: 581 B |
151
src/assets/styles/windowSquare.css
Normal file
@@ -0,0 +1,151 @@
|
||||
.wind-square {
|
||||
position: relative;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 18px rgba(0, 0, 0, 0.28),
|
||||
inset 0 0 0 1px rgba(255, 240, 196, 0.2);
|
||||
}
|
||||
|
||||
.square-base {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: sepia(1) hue-rotate(92deg) saturate(3.3) brightness(0.22);
|
||||
}
|
||||
|
||||
.wind-square::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 28% 22%, rgba(255, 238, 191, 0.08), transparent 42%),
|
||||
linear-gradient(145deg, rgba(5, 33, 24, 0.34), rgba(0, 0, 0, 0.16));
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ===== 四个三角形区域 ===== */
|
||||
.quadrant {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* 上三角 */
|
||||
.quadrant-top {
|
||||
clip-path: polygon(50% 50%, 0 0, 100% 0);
|
||||
background: radial-gradient(circle at 50% 38%, rgba(255, 225, 180, 0.30), transparent 68%),
|
||||
linear-gradient(to bottom, rgba(180, 95, 55, 0.28), rgba(80, 35, 20, 0.12));
|
||||
}
|
||||
|
||||
/* 右三角 */
|
||||
.quadrant-right {
|
||||
clip-path: polygon(50% 50%, 100% 0, 100% 100%);
|
||||
background: radial-gradient(circle at 62% 50%, rgba(255, 225, 180, 0.30), transparent 68%),
|
||||
linear-gradient(to left, rgba(180, 95, 55, 0.28), rgba(80, 35, 20, 0.12));
|
||||
}
|
||||
|
||||
/* 下三角 */
|
||||
.quadrant-bottom {
|
||||
clip-path: polygon(50% 50%, 0 100%, 100% 100%);
|
||||
background: radial-gradient(circle at 50% 62%, rgba(255, 225, 180, 0.30), transparent 68%),
|
||||
linear-gradient(to top, rgba(180, 95, 55, 0.28), rgba(80, 35, 20, 0.12));
|
||||
}
|
||||
|
||||
/* 左三角 */
|
||||
.quadrant-left {
|
||||
clip-path: polygon(50% 50%, 0 0, 0 100%);
|
||||
background: radial-gradient(circle at 38% 50%, rgba(255, 225, 180, 0.30), transparent 68%),
|
||||
linear-gradient(to right, rgba(180, 95, 55, 0.28), rgba(80, 35, 20, 0.12));
|
||||
}
|
||||
|
||||
/* 激活时闪烁 */
|
||||
.quadrant.active {
|
||||
opacity: 1;
|
||||
animation: quadrant-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes quadrant-pulse {
|
||||
0% {
|
||||
opacity: 0.22;
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.72;
|
||||
filter: brightness(1.18);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.22;
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.diagonal {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 160%;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(80, 35, 20, 0.6) 25%,
|
||||
rgba(160, 85, 50, 0.9) 50%,
|
||||
rgba(80, 35, 20, 0.6) 75%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
transform-origin: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.diagonal-a {
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.diagonal-b {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
.wind-slot {
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.wind-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
filter: brightness(0) invert(1) drop-shadow(0 0 2px rgba(255, 220, 180, 0.8)) drop-shadow(0 0 4px rgba(120, 60, 30, 0.6));
|
||||
}
|
||||
|
||||
.wind-top {
|
||||
top: 5px;
|
||||
left: 34px;
|
||||
}
|
||||
|
||||
.wind-right {
|
||||
top: 34px;
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.wind-bottom {
|
||||
bottom: 5px;
|
||||
left: 34px;
|
||||
}
|
||||
|
||||
.wind-left {
|
||||
top: 34px;
|
||||
left: 5px;
|
||||
}
|
||||
123
src/components/chengdu/ChengduBottomActions.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import type { ClaimOptionState } from '../../types/state'
|
||||
import type { Tile } from '../../types/tile'
|
||||
|
||||
defineProps<{
|
||||
showDingQueChooser: boolean
|
||||
showReadyToggle: boolean
|
||||
showStartGameButton: boolean
|
||||
selectedDiscardTile: Tile | null
|
||||
dingQuePending: boolean
|
||||
canConfirmDiscard: boolean
|
||||
discardPending: boolean
|
||||
confirmDiscardLabel: string
|
||||
readyTogglePending: boolean
|
||||
myReadyState: boolean
|
||||
canDrawTile: boolean
|
||||
canStartGame: boolean
|
||||
isRoomOwner: boolean
|
||||
canSelfGang: boolean
|
||||
canSelfHu: boolean
|
||||
showClaimActions: boolean
|
||||
turnActionPending: boolean
|
||||
visibleClaimOptions: ClaimOptionState[]
|
||||
claimActionPending: boolean
|
||||
showWaitingOwnerTip: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
chooseDingQue: [suit: Tile['suit']]
|
||||
confirmDiscard: []
|
||||
toggleReadyState: []
|
||||
drawTile: []
|
||||
startGame: []
|
||||
submitSelfGang: []
|
||||
submitSelfHu: []
|
||||
submitClaim: [action: ClaimOptionState]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showWaitingOwnerTip" class="waiting-owner-tip">
|
||||
<span>等待房主开始游戏</span>
|
||||
</div>
|
||||
|
||||
<div class="bottom-control-panel">
|
||||
<div v-if="showDingQueChooser || showReadyToggle || showStartGameButton || selectedDiscardTile" class="bottom-action-bar">
|
||||
<div v-if="showDingQueChooser" class="ding-que-bar">
|
||||
<button class="ding-que-button" data-testid="ding-que-w" type="button" :disabled="dingQuePending" @click="emit('chooseDingQue', 'W')">万</button>
|
||||
<button class="ding-que-button" data-testid="ding-que-t" type="button" :disabled="dingQuePending" @click="emit('chooseDingQue', 'T')">筒</button>
|
||||
<button class="ding-que-button" data-testid="ding-que-b" type="button" :disabled="dingQuePending" @click="emit('chooseDingQue', 'B')">条</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="selectedDiscardTile"
|
||||
class="ready-toggle ready-toggle-inline discard-confirm-button"
|
||||
data-testid="confirm-discard"
|
||||
type="button"
|
||||
:disabled="!canConfirmDiscard || discardPending"
|
||||
@click="emit('confirmDiscard')"
|
||||
>
|
||||
<span class="ready-toggle-label">{{ confirmDiscardLabel }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showReadyToggle"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
data-testid="ready-toggle"
|
||||
type="button"
|
||||
:disabled="readyTogglePending"
|
||||
@click="emit('toggleReadyState')"
|
||||
>
|
||||
<span class="ready-toggle-label">{{ myReadyState ? '取 消' : '准 备' }}</span>
|
||||
</button>
|
||||
|
||||
<button v-if="canDrawTile" class="ready-toggle ready-toggle-inline" data-testid="draw-tile" type="button" @click="emit('drawTile')">
|
||||
<span class="ready-toggle-label">摸牌</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showStartGameButton && isRoomOwner"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
data-testid="start-game"
|
||||
type="button"
|
||||
:disabled="!canStartGame"
|
||||
@click="emit('startGame')"
|
||||
>
|
||||
<span class="ready-toggle-label">开始游戏</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="canSelfGang" class="hand-action-bar">
|
||||
<button class="hand-action-tile" data-testid="hand-gang" type="button" :disabled="turnActionPending" @click="emit('submitSelfGang')">
|
||||
杠
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="canSelfHu || showClaimActions" class="claim-action-bar" data-testid="claim-action-bar">
|
||||
<button
|
||||
v-if="canSelfHu"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
data-testid="claim-self-hu"
|
||||
type="button"
|
||||
:disabled="turnActionPending"
|
||||
@click="emit('submitSelfHu')"
|
||||
>
|
||||
<span class="ready-toggle-label">胡</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="option in visibleClaimOptions"
|
||||
:key="option"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
:data-testid="`claim-${option}`"
|
||||
type="button"
|
||||
:disabled="claimActionPending"
|
||||
@click="emit('submitClaim', option)"
|
||||
>
|
||||
<span class="ready-toggle-label">
|
||||
{{ option === 'peng' ? '碰' : option === 'gang' ? '杠' : option === 'hu' ? '胡' : '过' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
55
src/components/chengdu/ChengduDeskZones.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { SeatKey } from '../../game/seat'
|
||||
import type { DeskSeatState } from '../../views/chengdu/types'
|
||||
|
||||
defineProps<{
|
||||
deskSeats: Record<SeatKey, DeskSeatState>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="deskSeats.top.tiles.length > 0 || deskSeats.top.hasHu" class="desk-zone desk-zone-top">
|
||||
<img
|
||||
v-for="tile in deskSeats.top.tiles"
|
||||
:key="tile.key"
|
||||
class="desk-tile"
|
||||
:class="{ 'is-group-start': tile.isGroupStart, 'is-covered': tile.imageType === 'covered' }"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
<span v-if="deskSeats.top.hasHu" class="desk-hu-flag">胡</span>
|
||||
</div>
|
||||
<div v-if="deskSeats.right.tiles.length > 0 || deskSeats.right.hasHu" class="desk-zone desk-zone-right">
|
||||
<img
|
||||
v-for="tile in deskSeats.right.tiles"
|
||||
:key="tile.key"
|
||||
class="desk-tile"
|
||||
:class="{ 'is-group-start': tile.isGroupStart, 'is-covered': tile.imageType === 'covered' }"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
<span v-if="deskSeats.right.hasHu" class="desk-hu-flag">胡</span>
|
||||
</div>
|
||||
<div v-if="deskSeats.bottom.tiles.length > 0 || deskSeats.bottom.hasHu" class="desk-zone desk-zone-bottom">
|
||||
<img
|
||||
v-for="tile in deskSeats.bottom.tiles"
|
||||
:key="tile.key"
|
||||
class="desk-tile"
|
||||
:class="{ 'is-group-start': tile.isGroupStart, 'is-covered': tile.imageType === 'covered' }"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
<span v-if="deskSeats.bottom.hasHu" class="desk-hu-flag">胡</span>
|
||||
</div>
|
||||
<div v-if="deskSeats.left.tiles.length > 0 || deskSeats.left.hasHu" class="desk-zone desk-zone-left">
|
||||
<img
|
||||
v-for="tile in deskSeats.left.tiles"
|
||||
:key="tile.key"
|
||||
class="desk-tile"
|
||||
:class="{ 'is-group-start': tile.isGroupStart, 'is-covered': tile.imageType === 'covered' }"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
<span v-if="deskSeats.left.hasHu" class="desk-hu-flag">胡</span>
|
||||
</div>
|
||||
</template>
|
||||
120
src/components/chengdu/ChengduSettlementOverlay.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
isLastRound: boolean
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
settlementPlayers: Array<{
|
||||
playerId: string
|
||||
displayName: string
|
||||
score: number
|
||||
isWinner: boolean
|
||||
seatIndex: number
|
||||
isReady: boolean
|
||||
}>
|
||||
loggedInUserId: string
|
||||
isRoomOwner: boolean
|
||||
selfIsReady: boolean
|
||||
readyTogglePending: boolean
|
||||
startNextRoundPending: boolean
|
||||
leaveRoomPending: boolean
|
||||
settlementCountdown: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
ready: []
|
||||
startNextRound: []
|
||||
exit: []
|
||||
backHall: []
|
||||
}>()
|
||||
|
||||
const allPlayersReady = computed(() =>
|
||||
props.settlementPlayers.length > 0 && props.settlementPlayers.every((p) => p.isReady),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="show" class="settlement-overlay">
|
||||
<div class="settlement-panel">
|
||||
<h2 class="settlement-title">
|
||||
{{ isLastRound ? '最终结算' : `第 ${currentRound} 局结算` }}
|
||||
</h2>
|
||||
<p v-if="totalRounds > 0" class="settlement-round-info">{{ currentRound }} / {{ totalRounds }} 局</p>
|
||||
<div class="settlement-list">
|
||||
<div
|
||||
v-for="(item, index) in settlementPlayers"
|
||||
:key="item.playerId"
|
||||
class="settlement-row"
|
||||
:class="{ 'is-winner': item.isWinner, 'is-self': item.playerId === loggedInUserId }"
|
||||
>
|
||||
<span class="settlement-rank">{{ index + 1 }}</span>
|
||||
<span class="settlement-name">
|
||||
{{ item.displayName }}
|
||||
<span v-if="item.isWinner" class="settlement-winner-badge">胡</span>
|
||||
</span>
|
||||
<span class="settlement-score" :class="{ 'is-positive': item.score > 0, 'is-negative': item.score < 0 }">
|
||||
{{ item.score > 0 ? '+' : '' }}{{ item.score }}
|
||||
</span>
|
||||
<span class="settlement-ready-badge" :class="{ 'is-ready': item.isReady }">
|
||||
{{ item.isReady ? '已准备' : '等待...' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settlement-actions">
|
||||
<!-- 非末局:准备按钮 + 房主开始游戏按钮 -->
|
||||
<template v-if="!isLastRound">
|
||||
<button
|
||||
class="ready-toggle ready-toggle-inline settlement-btn"
|
||||
:class="{ 'is-ready': selfIsReady }"
|
||||
type="button"
|
||||
:disabled="selfIsReady || readyTogglePending"
|
||||
@click="emit('ready')"
|
||||
>
|
||||
<span class="ready-toggle-label">
|
||||
{{
|
||||
readyTogglePending
|
||||
? '请求中...'
|
||||
: selfIsReady
|
||||
? '已准备'
|
||||
: '准备'
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isRoomOwner"
|
||||
class="ready-toggle ready-toggle-inline settlement-btn"
|
||||
type="button"
|
||||
:disabled="!allPlayersReady || startNextRoundPending"
|
||||
@click="emit('startNextRound')"
|
||||
>
|
||||
<span class="ready-toggle-label">
|
||||
{{
|
||||
startNextRoundPending
|
||||
? '开始中...'
|
||||
: allPlayersReady
|
||||
? '开始游戏'
|
||||
: `开始游戏 (${settlementPlayers.filter((p) => p.isReady).length}/${settlementPlayers.length})`
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
<p v-else-if="allPlayersReady" class="settlement-waiting-owner">等待房主开始...</p>
|
||||
</template>
|
||||
<!-- 末局:返回大厅 -->
|
||||
<button v-else class="ready-toggle ready-toggle-inline settlement-btn" type="button" @click="emit('backHall')">
|
||||
<span class="ready-toggle-label">返回大厅</span>
|
||||
</button>
|
||||
<!-- 退出按钮(始终显示) -->
|
||||
<button
|
||||
class="ready-toggle ready-toggle-inline settlement-btn settlement-btn-exit"
|
||||
type="button"
|
||||
:disabled="leaveRoomPending"
|
||||
@click="emit('exit')"
|
||||
>
|
||||
<span class="ready-toggle-label">{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
112
src/components/chengdu/ChengduTableHeader.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { ActionCountdownView } from '../../views/chengdu/types'
|
||||
|
||||
defineProps<{
|
||||
leaveRoomPending: boolean
|
||||
menuOpen: boolean
|
||||
menuTriggerActive: boolean
|
||||
isTrustMode: boolean
|
||||
wallCount: number
|
||||
networkLabel: string
|
||||
wsStatus: string
|
||||
formattedClock: string
|
||||
roomName: string
|
||||
currentPhaseText: string
|
||||
playerCount: number
|
||||
maxPlayers: number
|
||||
roundText: string
|
||||
roomStatusText: string
|
||||
wsError: string
|
||||
actionCountdown: ActionCountdownView | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleMenu: []
|
||||
toggleTrustMode: []
|
||||
leaveRoom: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="top-left-tools">
|
||||
<div class="menu-trigger-wrap">
|
||||
<button
|
||||
class="metal-circle menu-trigger"
|
||||
:class="{ 'is-feedback': menuTriggerActive }"
|
||||
type="button"
|
||||
:disabled="leaveRoomPending"
|
||||
@click.stop="emit('toggleMenu')"
|
||||
>
|
||||
<span class="menu-trigger-icon">☰</span>
|
||||
</button>
|
||||
<transition name="menu-pop">
|
||||
<div v-if="menuOpen" class="menu-popover" @click.stop>
|
||||
<div class="menu-list">
|
||||
<button class="menu-item menu-item-delay-1" type="button" @click="emit('toggleTrustMode')">
|
||||
<slot name="robot-icon" />
|
||||
<span>{{ isTrustMode ? '取消托管' : '托管' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="menu-item menu-item-danger menu-item-delay-2"
|
||||
type="button"
|
||||
:disabled="leaveRoomPending"
|
||||
@click="emit('leaveRoom')"
|
||||
>
|
||||
<slot name="exit-icon" />
|
||||
<span>{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<div class="left-counter">
|
||||
<span class="counter-light"></span>
|
||||
<strong>{{ wallCount }}</strong>
|
||||
</div>
|
||||
<span v-if="isTrustMode" class="trust-chip">托管中</span>
|
||||
</div>
|
||||
|
||||
<div class="top-right-clock">
|
||||
<div class="signal-chip">
|
||||
<span class="wifi-dot" :class="`is-${wsStatus}`"></span>
|
||||
<strong>{{ networkLabel }}</strong>
|
||||
</div>
|
||||
<span>{{ formattedClock }}</span>
|
||||
</div>
|
||||
|
||||
<div class="room-status-panel">
|
||||
<div class="room-status-grid">
|
||||
<div class="room-status-item">
|
||||
<span>房间</span>
|
||||
<strong>{{ roomName || '未命名' }}</strong>
|
||||
</div>
|
||||
<div class="room-status-item">
|
||||
<span>阶段</span>
|
||||
<strong>{{ currentPhaseText }}</strong>
|
||||
</div>
|
||||
<div class="room-status-item">
|
||||
<span>人数</span>
|
||||
<strong>{{ playerCount }}/{{ maxPlayers }}</strong>
|
||||
</div>
|
||||
<div v-if="roundText" class="room-status-item">
|
||||
<span>局数</span>
|
||||
<strong>{{ roundText }}</strong>
|
||||
</div>
|
||||
<div class="room-status-item">
|
||||
<span>状态</span>
|
||||
<strong>{{ roomStatusText }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="wsError" class="room-status-error">{{ wsError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="actionCountdown" class="action-countdown" :class="{ 'is-self': actionCountdown.isSelf }">
|
||||
<div class="action-countdown-head">
|
||||
<span>{{ actionCountdown.playerLabel }}操作倒计时</span>
|
||||
<strong>{{ actionCountdown.remaining }}s</strong>
|
||||
</div>
|
||||
<div class="action-countdown-track">
|
||||
<span class="action-countdown-fill" :style="{ width: `${actionCountdown.progress}%` }"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
90
src/components/chengdu/ChengduWallSeats.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import type { SeatKey } from '../../game/seat'
|
||||
import type { Tile } from '../../types/tile'
|
||||
import type { WallSeatState } from '../../views/chengdu/types'
|
||||
|
||||
defineProps<{
|
||||
wallSeats: Record<SeatKey, WallSeatState>
|
||||
selectedDiscardTileId: number | null
|
||||
discardBlockedReason: string
|
||||
discardTileBlockedReason: (tile: Tile) => string
|
||||
formatTile: (tile: Tile) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectDiscardTile: [tile: Tile]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="wallSeats.top.tiles.length > 0" class="wall wall-top wall-live">
|
||||
<img
|
||||
v-for="(tile, index) in wallSeats.top.tiles"
|
||||
:key="tile.key"
|
||||
class="wall-live-tile"
|
||||
:class="{
|
||||
'is-group-start': index > 0 && tile.suit && wallSeats.top.tiles[index - 1]?.suit !== tile.suit,
|
||||
'is-exposed': tile.imageType !== 'hand',
|
||||
}"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="wallSeats.right.tiles.length > 0" class="wall wall-right wall-live">
|
||||
<img
|
||||
v-for="(tile, index) in wallSeats.right.tiles"
|
||||
:key="tile.key"
|
||||
class="wall-live-tile"
|
||||
:class="{
|
||||
'is-group-start': index > 0 && tile.suit && wallSeats.right.tiles[index - 1]?.suit !== tile.suit,
|
||||
'is-exposed': tile.imageType !== 'hand',
|
||||
}"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="wallSeats.bottom.tiles.length > 0" class="wall wall-bottom wall-live">
|
||||
<template v-for="(tile, index) in wallSeats.bottom.tiles" :key="tile.key">
|
||||
<button
|
||||
v-if="tile.tile && tile.imageType === 'hand'"
|
||||
class="wall-live-tile-button"
|
||||
:class="{
|
||||
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
||||
'is-lack-tagged': tile.showLackTag,
|
||||
'is-selected': selectedDiscardTileId === tile.tile.id,
|
||||
}"
|
||||
:data-testid="`hand-tile-${tile.tile.id}`"
|
||||
type="button"
|
||||
:disabled="Boolean(discardBlockedReason)"
|
||||
:title="discardTileBlockedReason(tile.tile) || formatTile(tile.tile)"
|
||||
@click="emit('selectDiscardTile', tile.tile)"
|
||||
>
|
||||
<span v-if="tile.showLackTag" class="wall-live-tile-lack-tag">缺</span>
|
||||
<img class="wall-live-tile" :src="tile.src" :alt="tile.alt" />
|
||||
</button>
|
||||
<img
|
||||
v-else
|
||||
class="wall-live-tile"
|
||||
:class="{
|
||||
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
||||
'is-exposed': tile.imageType !== 'hand',
|
||||
}"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="wallSeats.left.tiles.length > 0" class="wall wall-left wall-live">
|
||||
<img
|
||||
v-for="(tile, index) in wallSeats.left.tiles"
|
||||
:key="tile.key"
|
||||
class="wall-live-tile"
|
||||
:class="{
|
||||
'is-group-start': index > 0 && tile.suit && wallSeats.left.tiles[index - 1]?.suit !== tile.suit,
|
||||
'is-exposed': tile.imageType !== 'hand',
|
||||
}"
|
||||
:src="tile.src"
|
||||
:alt="tile.alt"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import wanIcon from '../../assets/images/flowerClolor/wan.png'
|
||||
import tongIcon from '../../assets/images/flowerClolor/tong.png'
|
||||
import tiaoIcon from '../../assets/images/flowerClolor/tiao.png'
|
||||
import defaultAvatarIcon from '../../assets/images/icons/avatar.svg'
|
||||
import { getLackSuitImage } from '../../config/flowerColorMap'
|
||||
import type { Suit } from '../../types/tile'
|
||||
import type { SeatPlayerCardModel } from './seat-player-card'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -11,17 +10,26 @@ const props = defineProps<{
|
||||
player: SeatPlayerCardModel
|
||||
}>()
|
||||
|
||||
function normalizeMissingSuit(value: string): Suit | null {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
const missingSuitMap: Record<string, Suit> = {
|
||||
万: 'W',
|
||||
筒: 'T',
|
||||
条: 'B',
|
||||
w: 'W',
|
||||
t: 'T',
|
||||
b: 'B',
|
||||
wan: 'W',
|
||||
tong: 'T',
|
||||
tiao: 'B',
|
||||
}
|
||||
|
||||
return missingSuitMap[normalized] ?? null
|
||||
}
|
||||
|
||||
const missingSuitIcon = computed(() => {
|
||||
if (props.player.missingSuitLabel === '万') {
|
||||
return wanIcon
|
||||
}
|
||||
if (props.player.missingSuitLabel === '筒') {
|
||||
return tongIcon
|
||||
}
|
||||
if (props.player.missingSuitLabel === '条') {
|
||||
return tiaoIcon
|
||||
}
|
||||
return ''
|
||||
const suit = normalizeMissingSuit(props.player.missingSuitLabel)
|
||||
return suit ? getLackSuitImage(suit) : ''
|
||||
})
|
||||
|
||||
const resolvedAvatarUrl = computed(() => {
|
||||
@@ -43,6 +51,8 @@ const resolvedAvatarUrl = computed(() => {
|
||||
|
||||
<div class="player-meta">
|
||||
<p>{{ player.name }}</p>
|
||||
<small v-if="player.isTrustee" class="trustee-chip">托管中</small>
|
||||
<small v-if="player.isReady" class="ready-chip">已准备</small>
|
||||
</div>
|
||||
|
||||
<div class="missing-mark">
|
||||
|
||||
42
src/components/game/WindSquare.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import squareIcon from '../../assets/images/icons/square.svg'
|
||||
import '@src/assets/styles/windowSquare.css'
|
||||
|
||||
defineProps<{
|
||||
seatWinds: {
|
||||
top: string
|
||||
right: string
|
||||
bottom: string
|
||||
left: string
|
||||
}
|
||||
activePosition?: 'top' | 'right' | 'bottom' | 'left' | ''
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wind-square">
|
||||
<img class="square-base" :src="squareIcon" alt="" />
|
||||
|
||||
<!-- 四个三角形高亮区域 -->
|
||||
<div class="quadrant quadrant-top" :class="{ active: activePosition === 'top' }"></div>
|
||||
<div class="quadrant quadrant-right" :class="{ active: activePosition === 'right' }"></div>
|
||||
<div class="quadrant quadrant-bottom" :class="{ active: activePosition === 'bottom' }"></div>
|
||||
<div class="quadrant quadrant-left" :class="{ active: activePosition === 'left' }"></div>
|
||||
|
||||
<div class="diagonal diagonal-a"></div>
|
||||
<div class="diagonal diagonal-b"></div>
|
||||
|
||||
<span class="wind-slot wind-top">
|
||||
<img class="wind-icon" :src="seatWinds.top" alt="上方位风" />
|
||||
</span>
|
||||
<span class="wind-slot wind-right">
|
||||
<img class="wind-icon" :src="seatWinds.right" alt="右方位风" />
|
||||
</span>
|
||||
<span class="wind-slot wind-bottom">
|
||||
<img class="wind-icon" :src="seatWinds.bottom" alt="下方位风" />
|
||||
</span>
|
||||
<span class="wind-slot wind-left">
|
||||
<img class="wind-icon" :src="seatWinds.left" alt="左方位风" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,5 +4,7 @@ export interface SeatPlayerCardModel {
|
||||
name: string // 显示名称
|
||||
dealer: boolean // 是否庄家
|
||||
isTurn: boolean // 是否当前轮到该玩家
|
||||
isReady: boolean // 是否已准备
|
||||
isTrustee: boolean // 是否托管
|
||||
missingSuitLabel: string // 定缺花色(万/筒/条)
|
||||
}
|
||||
|
||||
299
src/config/bottomTileMap.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
// src/config/bottomTileMap.ts
|
||||
|
||||
export type Suit = 'W' | 'T' | 'B' | 'F' | 'D'
|
||||
|
||||
export interface Tile {
|
||||
id: number
|
||||
suit: Suit
|
||||
value: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片用途:
|
||||
* - hand: 手牌
|
||||
* - exposed: 碰/杠/胡等明牌
|
||||
* - covered: 盖住的牌
|
||||
*/
|
||||
export type TileImageType = 'hand' | 'exposed' | 'covered'
|
||||
|
||||
export type TilePosition = 'bottom'
|
||||
|
||||
/**
|
||||
* 手牌图索引:
|
||||
* p4b1_x => 万
|
||||
* p4b2_x => 筒
|
||||
* p4b3_x => 条
|
||||
* p4b4_x => 东南西北中发白
|
||||
*/
|
||||
const HAND_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
|
||||
W: 1,
|
||||
T: 2,
|
||||
B: 3,
|
||||
F: 4,
|
||||
D: 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* 明牌图索引:
|
||||
* p4s1_x => 万
|
||||
* p4s2_x => 筒
|
||||
* p4s3_x => 条
|
||||
* p4s4_x => 东南西北中发白
|
||||
*/
|
||||
const EXPOSED_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
|
||||
W: 1,
|
||||
T: 2,
|
||||
B: 3,
|
||||
F: 4,
|
||||
D: 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* 字牌 value 映射:
|
||||
* 风牌 F:
|
||||
* 1=东 2=南 3=西 4=北
|
||||
*
|
||||
* 箭牌 D:
|
||||
* 1=中 2=发 3=白
|
||||
*
|
||||
* 在图片资源中:
|
||||
* 1=东 2=南 3=西 4=北 5=中 6=发 7=白
|
||||
*/
|
||||
function getHonorImageValue(suit: Suit, value: number): number {
|
||||
if (suit === 'F') {
|
||||
return value
|
||||
}
|
||||
if (suit === 'D') {
|
||||
return value + 4
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建手牌图片 key
|
||||
* 例如:
|
||||
* /src/assets/images/tiles/bottom/p4b1_1.png
|
||||
* /src/assets/images/tiles/bottom/p4b4_5.png
|
||||
*/
|
||||
function buildHandTileImageKey(
|
||||
suit: Suit,
|
||||
value: number,
|
||||
position: TilePosition = 'bottom',
|
||||
): string {
|
||||
const suitIndex = HAND_SUIT_INDEX_MAP[suit]
|
||||
const imageValue = suit === 'F' || suit === 'D'
|
||||
? getHonorImageValue(suit, value)
|
||||
: value
|
||||
|
||||
return `/src/assets/images/tiles/${position}/p4b${suitIndex}_${imageValue}.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建明牌图片 key(碰/杠/胡漏出的牌)
|
||||
* 例如:
|
||||
* /src/assets/images/tiles/bottom/p4s1_1.png
|
||||
* /src/assets/images/tiles/bottom/p4s4_5.png
|
||||
*/
|
||||
function buildExposedTileImageKey(
|
||||
suit: Suit,
|
||||
value: number,
|
||||
position: TilePosition = 'bottom',
|
||||
): string {
|
||||
const suitIndex = EXPOSED_SUIT_INDEX_MAP[suit]
|
||||
const imageValue = suit === 'F' || suit === 'D'
|
||||
? getHonorImageValue(suit, value)
|
||||
: value
|
||||
|
||||
return `/src/assets/images/tiles/${position}/p4s${suitIndex}_${imageValue}.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建盖牌图片 key
|
||||
*/
|
||||
function buildCoveredTileImageKey(position: TilePosition = 'bottom'): string {
|
||||
return `/src/assets/images/tiles/${position}/tdbgs_4.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Vite 收集所有麻将牌资源
|
||||
*/
|
||||
const tileImageModules = import.meta.glob(
|
||||
'/src/assets/images/tiles/bottom/*.png',
|
||||
{
|
||||
eager: true,
|
||||
import: 'default',
|
||||
},
|
||||
) as Record<string, string>
|
||||
|
||||
/**
|
||||
* 判断是否为合法花色
|
||||
*/
|
||||
export function isValidSuit(suit: string): suit is Suit {
|
||||
return suit === 'W' || suit === 'T' || suit === 'B' || suit === 'F' || suit === 'D'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为合法点数
|
||||
* W/T/B => 1~9
|
||||
* F => 1~4
|
||||
* D => 1~3
|
||||
*/
|
||||
export function isValidTileValueBySuit(suit: Suit, value: number): boolean {
|
||||
if (!Number.isInteger(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (suit) {
|
||||
case 'W':
|
||||
case 'T':
|
||||
case 'B':
|
||||
return value >= 1 && value <= 9
|
||||
case 'F':
|
||||
return value >= 1 && value <= 4
|
||||
case 'D':
|
||||
return value >= 1 && value <= 3
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为合法牌
|
||||
*/
|
||||
export function isValidTile(tile: { suit: string; value: number }): tile is Pick<Tile, 'suit' | 'value'> {
|
||||
if (!isValidSuit(tile.suit)) {
|
||||
return false
|
||||
}
|
||||
return isValidTileValueBySuit(tile.suit, tile.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取手牌图片
|
||||
*/
|
||||
export function getHandTileImage(
|
||||
tile: Pick<Tile, 'suit' | 'value'>,
|
||||
position: TilePosition = 'bottom',
|
||||
): string {
|
||||
if (!isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const key = buildHandTileImageKey(tile.suit, tile.value, position)
|
||||
return tileImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取碰/杠/胡漏出的明牌图片
|
||||
*/
|
||||
export function getExposedTileImage(
|
||||
tile: Pick<Tile, 'suit' | 'value'>,
|
||||
position: TilePosition = 'bottom',
|
||||
): string {
|
||||
if (!isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const key = buildExposedTileImageKey(tile.suit, tile.value, position)
|
||||
return tileImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取盖住的牌图片
|
||||
*/
|
||||
export function getCoveredTileImage(position: TilePosition = 'bottom'): string {
|
||||
const key = buildCoveredTileImageKey(position)
|
||||
return tileImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一获取牌图片
|
||||
*/
|
||||
export function getTileImage(
|
||||
tile: Pick<Tile, 'suit' | 'value'>,
|
||||
imageType: TileImageType = 'hand',
|
||||
position: TilePosition = 'bottom',
|
||||
): string {
|
||||
if (imageType === 'covered') {
|
||||
return getCoveredTileImage(position)
|
||||
}
|
||||
|
||||
if (!isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (imageType === 'exposed') {
|
||||
return getExposedTileImage(tile, position)
|
||||
}
|
||||
|
||||
return getHandTileImage(tile, position)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有基础牌(不含重复)
|
||||
* 包含:
|
||||
* - 万 1~9
|
||||
* - 筒 1~9
|
||||
* - 条 1~9
|
||||
* - 东南西北
|
||||
* - 中发白
|
||||
*/
|
||||
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
|
||||
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
|
||||
|
||||
// 万筒条
|
||||
const numberSuits: Array<'W' | 'T' | 'B'> = ['W', 'T', 'B']
|
||||
for (const suit of numberSuits) {
|
||||
for (let value = 1; value <= 9; value++) {
|
||||
result.push({ suit, value })
|
||||
}
|
||||
}
|
||||
|
||||
// 东南西北
|
||||
for (let value = 1; value <= 4; value++) {
|
||||
result.push({ suit: 'F', value })
|
||||
}
|
||||
|
||||
// 中发白
|
||||
for (let value = 1; value <= 3; value++) {
|
||||
result.push({ suit: 'D', value })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取牌的中文名称
|
||||
*/
|
||||
export function getTileLabel(tile: Pick<Tile, 'suit' | 'value'>): string {
|
||||
if (!isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (tile.suit) {
|
||||
case 'W':
|
||||
return `${tile.value}万`
|
||||
case 'T':
|
||||
return `${tile.value}筒`
|
||||
case 'B':
|
||||
return `${tile.value}条`
|
||||
case 'F': {
|
||||
const map: Record<number, string> = {
|
||||
1: '东',
|
||||
2: '南',
|
||||
3: '西',
|
||||
4: '北',
|
||||
}
|
||||
return map[tile.value] || ''
|
||||
}
|
||||
case 'D': {
|
||||
const map: Record<number, string> = {
|
||||
1: '中',
|
||||
2: '发',
|
||||
3: '白',
|
||||
}
|
||||
return map[tile.value] || ''
|
||||
}
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
24
src/config/deskImageMap.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/config/deskImageMap.ts
|
||||
|
||||
export interface DeskAsset {
|
||||
width: number
|
||||
height: number
|
||||
ratio: number
|
||||
src: string
|
||||
}
|
||||
|
||||
// 所有桌面资源
|
||||
export const DESK_ASSETS: DeskAsset[] = [
|
||||
{
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
ratio: 1920 / 1080,
|
||||
src: new URL('@/assets/images/desk/desk_01_1920_1080.png', import.meta.url).href,
|
||||
},
|
||||
{
|
||||
width: 1920,
|
||||
height: 945,
|
||||
ratio: 1920 / 945,
|
||||
src: new URL('@/assets/images/desk/desk_01_1920_945.png', import.meta.url).href,
|
||||
},
|
||||
]
|
||||
41
src/config/flowerColorMap.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// src/config/lackSuitMap.ts
|
||||
|
||||
|
||||
import type {Suit} from "../types/tile.ts";
|
||||
|
||||
const lackSuitImageModules = import.meta.glob(
|
||||
'/src/assets/images/flowerClolor/*.png',
|
||||
{
|
||||
eager: true,
|
||||
import: 'default',
|
||||
},
|
||||
) as Record<string, string>
|
||||
|
||||
const SUIT_FILE_MAP: Record<Suit, 'wan' | 'tong' | 'tiao'> = {
|
||||
W: 'wan',
|
||||
T: 'tong',
|
||||
B: 'tiao',
|
||||
}
|
||||
|
||||
function buildLackSuitImageKey(suit: Suit): string {
|
||||
const fileName = SUIT_FILE_MAP[suit]
|
||||
return `/src/assets/images/flowerClolor/${fileName}.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据花色获取缺门图标
|
||||
* W -> wan.png
|
||||
* T -> tong.png
|
||||
* B -> tiao.png
|
||||
*/
|
||||
export function getLackSuitImage(suit: Suit): string {
|
||||
const key = buildLackSuitImageKey(suit)
|
||||
return lackSuitImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为合法缺门花色
|
||||
*/
|
||||
export function isValidLackSuit(suit: string): suit is Suit {
|
||||
return suit === 'W' || suit === 'T' || suit === 'B'
|
||||
}
|
||||
251
src/config/leftTileMap.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
// src/config/leftTileMap.ts
|
||||
|
||||
export type Suit = 'W' | 'T' | 'B' | 'F' | 'D'
|
||||
|
||||
export interface Tile {
|
||||
id: number
|
||||
suit: Suit
|
||||
value: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片用途:
|
||||
* - hand: 左侧手牌背面
|
||||
* - exposed: 左侧碰/杠/胡等明牌
|
||||
* - covered: 左侧盖住的牌
|
||||
*/
|
||||
export type TileImageType = 'hand' | 'exposed' | 'covered'
|
||||
|
||||
export type TilePosition = 'left'
|
||||
|
||||
/**
|
||||
* 明牌图索引:
|
||||
* p3s1_x => 万
|
||||
* p3s2_x => 筒
|
||||
* p3s3_x => 条
|
||||
* p3s4_x => 东南西北中发白
|
||||
*/
|
||||
const EXPOSED_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
|
||||
W: 1,
|
||||
T: 2,
|
||||
B: 3,
|
||||
F: 4,
|
||||
D: 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* 字牌 value 映射:
|
||||
* F:
|
||||
* 1=东 2=南 3=西 4=北
|
||||
*
|
||||
* D:
|
||||
* 1=中 2=发 3=白
|
||||
*
|
||||
* 图片资源中:
|
||||
* 1=东 2=南 3=西 4=北 5=中 6=发 7=白
|
||||
*/
|
||||
function getHonorImageValue(suit: Suit, value: number): number {
|
||||
if (suit === 'F') return value
|
||||
if (suit === 'D') return value + 4
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建左侧明牌图片 key
|
||||
* 例如:
|
||||
* /src/assets/images/tiles/left/p3s1_1.png
|
||||
* /src/assets/images/tiles/left/p3s4_5.png
|
||||
*/
|
||||
function buildExposedTileImageKey(
|
||||
suit: Suit,
|
||||
value: number,
|
||||
position: TilePosition = 'left',
|
||||
): string {
|
||||
const suitIndex = EXPOSED_SUIT_INDEX_MAP[suit]
|
||||
const imageValue =
|
||||
suit === 'F' || suit === 'D' ? getHonorImageValue(suit, value) : value
|
||||
|
||||
return `/src/assets/images/tiles/${position}/p3s${suitIndex}_${imageValue}.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建左侧手牌背面图片 key
|
||||
*/
|
||||
function buildHandTileImageKey(position: TilePosition = 'left'): string {
|
||||
return `/src/assets/images/tiles/${position}/tbgs_3.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建左侧盖牌图片 key
|
||||
*/
|
||||
function buildCoveredTileImageKey(position: TilePosition = 'left'): string {
|
||||
return `/src/assets/images/tiles/${position}/tdbgs_3.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Vite 收集左侧麻将牌资源
|
||||
*/
|
||||
const tileImageModules = import.meta.glob('/src/assets/images/tiles/left/*.png', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
}) as Record<string, string>
|
||||
|
||||
/**
|
||||
* 判断是否为合法花色
|
||||
*/
|
||||
export function isValidSuit(suit: string): suit is Suit {
|
||||
return suit === 'W' || suit === 'T' || suit === 'B' || suit === 'F' || suit === 'D'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为合法点数
|
||||
* W/T/B => 1~9
|
||||
* F => 1~4
|
||||
* D => 1~3
|
||||
*/
|
||||
export function isValidTileValueBySuit(suit: Suit, value: number): boolean {
|
||||
if (!Number.isInteger(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (suit) {
|
||||
case 'W':
|
||||
case 'T':
|
||||
case 'B':
|
||||
return value >= 1 && value <= 9
|
||||
case 'F':
|
||||
return value >= 1 && value <= 4
|
||||
case 'D':
|
||||
return value >= 1 && value <= 3
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为合法牌
|
||||
*/
|
||||
export function isValidTile(tile: {
|
||||
suit: string
|
||||
value: number
|
||||
}): tile is Pick<Tile, 'suit' | 'value'> {
|
||||
if (!isValidSuit(tile.suit)) {
|
||||
return false
|
||||
}
|
||||
return isValidTileValueBySuit(tile.suit, tile.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取左侧手牌背面图
|
||||
*/
|
||||
export function getHandTileImage(position: TilePosition = 'left'): string {
|
||||
const key = buildHandTileImageKey(position)
|
||||
return tileImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取左侧碰/杠/胡等明牌图片
|
||||
*/
|
||||
export function getExposedTileImage(
|
||||
tile: Pick<Tile, 'suit' | 'value'>,
|
||||
position: TilePosition = 'left',
|
||||
): string {
|
||||
if (!isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const key = buildExposedTileImageKey(tile.suit, tile.value, position)
|
||||
return tileImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取左侧盖牌图片
|
||||
*/
|
||||
export function getCoveredTileImage(position: TilePosition = 'left'): string {
|
||||
const key = buildCoveredTileImageKey(position)
|
||||
return tileImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一获取左侧牌图片
|
||||
*/
|
||||
export function getTileImage(
|
||||
tile?: Pick<Tile, 'suit' | 'value'>,
|
||||
imageType: TileImageType = 'hand',
|
||||
position: TilePosition = 'left',
|
||||
): string {
|
||||
if (imageType === 'hand') {
|
||||
return getHandTileImage(position)
|
||||
}
|
||||
|
||||
if (imageType === 'covered') {
|
||||
return getCoveredTileImage(position)
|
||||
}
|
||||
|
||||
if (!tile || !isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return getExposedTileImage(tile, position)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有基础牌(不含重复)
|
||||
*/
|
||||
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
|
||||
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
|
||||
|
||||
const numberSuits: Array<'W' | 'T' | 'B'> = ['W', 'T', 'B']
|
||||
for (const suit of numberSuits) {
|
||||
for (let value = 1; value <= 9; value++) {
|
||||
result.push({ suit, value })
|
||||
}
|
||||
}
|
||||
|
||||
for (let value = 1; value <= 4; value++) {
|
||||
result.push({ suit: 'F', value })
|
||||
}
|
||||
|
||||
for (let value = 1; value <= 3; value++) {
|
||||
result.push({ suit: 'D', value })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取牌的中文名称
|
||||
*/
|
||||
export function getTileLabel(tile: Pick<Tile, 'suit' | 'value'>): string {
|
||||
if (!isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (tile.suit) {
|
||||
case 'W':
|
||||
return `${tile.value}万`
|
||||
case 'T':
|
||||
return `${tile.value}筒`
|
||||
case 'B':
|
||||
return `${tile.value}条`
|
||||
case 'F': {
|
||||
const map: Record<number, string> = {
|
||||
1: '东',
|
||||
2: '南',
|
||||
3: '西',
|
||||
4: '北',
|
||||
}
|
||||
return map[tile.value] || ''
|
||||
}
|
||||
case 'D': {
|
||||
const map: Record<number, string> = {
|
||||
1: '中',
|
||||
2: '发',
|
||||
3: '白',
|
||||
}
|
||||
return map[tile.value] || ''
|
||||
}
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
251
src/config/rightTileMap.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
// src/config/rightTileMap.ts
|
||||
|
||||
export type Suit = 'W' | 'T' | 'B' | 'F' | 'D'
|
||||
|
||||
export interface Tile {
|
||||
id: number
|
||||
suit: Suit
|
||||
value: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片用途:
|
||||
* - hand: 右侧手牌背面
|
||||
* - exposed: 右侧碰/杠/胡等明牌
|
||||
* - covered: 右侧盖住的牌
|
||||
*/
|
||||
export type TileImageType = 'hand' | 'exposed' | 'covered'
|
||||
|
||||
export type TilePosition = 'right'
|
||||
|
||||
/**
|
||||
* 明牌图索引:
|
||||
* p1s1_x => 万
|
||||
* p1s2_x => 筒
|
||||
* p1s3_x => 条
|
||||
* p1s4_x => 东南西北中发白
|
||||
*/
|
||||
const EXPOSED_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
|
||||
W: 1,
|
||||
T: 2,
|
||||
B: 3,
|
||||
F: 4,
|
||||
D: 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* 字牌 value 映射:
|
||||
* F:
|
||||
* 1=东 2=南 3=西 4=北
|
||||
*
|
||||
* D:
|
||||
* 1=中 2=发 3=白
|
||||
*
|
||||
* 图片资源中:
|
||||
* 1=东 2=南 3=西 4=北 5=中 6=发 7=白
|
||||
*/
|
||||
function getHonorImageValue(suit: Suit, value: number): number {
|
||||
if (suit === 'F') return value
|
||||
if (suit === 'D') return value + 4
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建右侧明牌图片 key
|
||||
* 例如:
|
||||
* /src/assets/images/tiles/right/p1s1_1.png
|
||||
* /src/assets/images/tiles/right/p1s4_5.png
|
||||
*/
|
||||
function buildExposedTileImageKey(
|
||||
suit: Suit,
|
||||
value: number,
|
||||
position: TilePosition = 'right',
|
||||
): string {
|
||||
const suitIndex = EXPOSED_SUIT_INDEX_MAP[suit]
|
||||
const imageValue =
|
||||
suit === 'F' || suit === 'D' ? getHonorImageValue(suit, value) : value
|
||||
|
||||
return `/src/assets/images/tiles/${position}/p1s${suitIndex}_${imageValue}.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建右侧手牌背面图片 key
|
||||
*/
|
||||
function buildHandTileImageKey(position: TilePosition = 'right'): string {
|
||||
return `/src/assets/images/tiles/${position}/tbgs_1.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建右侧盖牌图片 key
|
||||
*/
|
||||
function buildCoveredTileImageKey(position: TilePosition = 'right'): string {
|
||||
return `/src/assets/images/tiles/${position}/tdbgs_1.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Vite 收集右侧麻将牌资源
|
||||
*/
|
||||
const tileImageModules = import.meta.glob('/src/assets/images/tiles/right/*.png', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
}) as Record<string, string>
|
||||
|
||||
/**
|
||||
* 判断是否为合法花色
|
||||
*/
|
||||
export function isValidSuit(suit: string): suit is Suit {
|
||||
return suit === 'W' || suit === 'T' || suit === 'B' || suit === 'F' || suit === 'D'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为合法点数
|
||||
* W/T/B => 1~9
|
||||
* F => 1~4
|
||||
* D => 1~3
|
||||
*/
|
||||
export function isValidTileValueBySuit(suit: Suit, value: number): boolean {
|
||||
if (!Number.isInteger(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (suit) {
|
||||
case 'W':
|
||||
case 'T':
|
||||
case 'B':
|
||||
return value >= 1 && value <= 9
|
||||
case 'F':
|
||||
return value >= 1 && value <= 4
|
||||
case 'D':
|
||||
return value >= 1 && value <= 3
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为合法牌
|
||||
*/
|
||||
export function isValidTile(tile: {
|
||||
suit: string
|
||||
value: number
|
||||
}): tile is Pick<Tile, 'suit' | 'value'> {
|
||||
if (!isValidSuit(tile.suit)) {
|
||||
return false
|
||||
}
|
||||
return isValidTileValueBySuit(tile.suit, tile.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取右侧手牌背面图
|
||||
*/
|
||||
export function getHandTileImage(position: TilePosition = 'right'): string {
|
||||
const key = buildHandTileImageKey(position)
|
||||
return tileImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取右侧碰/杠/胡等明牌图片
|
||||
*/
|
||||
export function getExposedTileImage(
|
||||
tile: Pick<Tile, 'suit' | 'value'>,
|
||||
position: TilePosition = 'right',
|
||||
): string {
|
||||
if (!isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const key = buildExposedTileImageKey(tile.suit, tile.value, position)
|
||||
return tileImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取右侧盖牌图片
|
||||
*/
|
||||
export function getCoveredTileImage(position: TilePosition = 'right'): string {
|
||||
const key = buildCoveredTileImageKey(position)
|
||||
return tileImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一获取右侧牌图片
|
||||
*/
|
||||
export function getTileImage(
|
||||
tile?: Pick<Tile, 'suit' | 'value'>,
|
||||
imageType: TileImageType = 'hand',
|
||||
position: TilePosition = 'right',
|
||||
): string {
|
||||
if (imageType === 'hand') {
|
||||
return getHandTileImage(position)
|
||||
}
|
||||
|
||||
if (imageType === 'covered') {
|
||||
return getCoveredTileImage(position)
|
||||
}
|
||||
|
||||
if (!tile || !isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return getExposedTileImage(tile, position)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有基础牌(不含重复)
|
||||
*/
|
||||
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
|
||||
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
|
||||
|
||||
const numberSuits: Array<'W' | 'T' | 'B'> = ['W', 'T', 'B']
|
||||
for (const suit of numberSuits) {
|
||||
for (let value = 1; value <= 9; value++) {
|
||||
result.push({ suit, value })
|
||||
}
|
||||
}
|
||||
|
||||
for (let value = 1; value <= 4; value++) {
|
||||
result.push({ suit: 'F', value })
|
||||
}
|
||||
|
||||
for (let value = 1; value <= 3; value++) {
|
||||
result.push({ suit: 'D', value })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取牌的中文名称
|
||||
*/
|
||||
export function getTileLabel(tile: Pick<Tile, 'suit' | 'value'>): string {
|
||||
if (!isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (tile.suit) {
|
||||
case 'W':
|
||||
return `${tile.value}万`
|
||||
case 'T':
|
||||
return `${tile.value}筒`
|
||||
case 'B':
|
||||
return `${tile.value}条`
|
||||
case 'F': {
|
||||
const map: Record<number, string> = {
|
||||
1: '东',
|
||||
2: '南',
|
||||
3: '西',
|
||||
4: '北',
|
||||
}
|
||||
return map[tile.value] || ''
|
||||
}
|
||||
case 'D': {
|
||||
const map: Record<number, string> = {
|
||||
1: '中',
|
||||
2: '发',
|
||||
3: '白',
|
||||
}
|
||||
return map[tile.value] || ''
|
||||
}
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
251
src/config/topTileMap.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
// src/config/topTileMap.ts
|
||||
|
||||
export type Suit = 'W' | 'T' | 'B' | 'F' | 'D'
|
||||
|
||||
export interface Tile {
|
||||
id: number
|
||||
suit: Suit
|
||||
value: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片用途:
|
||||
* - hand: 上方手牌背面
|
||||
* - exposed: 上方碰/杠/胡等明牌
|
||||
* - covered: 上方盖住的牌
|
||||
*/
|
||||
export type TileImageType = 'hand' | 'exposed' | 'covered'
|
||||
|
||||
export type TilePosition = 'top'
|
||||
|
||||
/**
|
||||
* 明牌图索引:
|
||||
* p2s1_x => 万
|
||||
* p2s2_x => 筒
|
||||
* p2s3_x => 条
|
||||
* p2s4_x => 东南西北中发白
|
||||
*/
|
||||
const EXPOSED_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
|
||||
W: 1,
|
||||
T: 2,
|
||||
B: 3,
|
||||
F: 4,
|
||||
D: 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* 字牌 value 映射:
|
||||
* F:
|
||||
* 1=东 2=南 3=西 4=北
|
||||
*
|
||||
* D:
|
||||
* 1=中 2=发 3=白
|
||||
*
|
||||
* 图片资源中:
|
||||
* 1=东 2=南 3=西 4=北 5=中 6=发 7=白
|
||||
*/
|
||||
function getHonorImageValue(suit: Suit, value: number): number {
|
||||
if (suit === 'F') return value
|
||||
if (suit === 'D') return value + 4
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建上方明牌图片 key
|
||||
* 例如:
|
||||
* /src/assets/images/tiles/top/p2s1_1.png
|
||||
* /src/assets/images/tiles/top/p2s4_5.png
|
||||
*/
|
||||
function buildExposedTileImageKey(
|
||||
suit: Suit,
|
||||
value: number,
|
||||
position: TilePosition = 'top',
|
||||
): string {
|
||||
const suitIndex = EXPOSED_SUIT_INDEX_MAP[suit]
|
||||
const imageValue =
|
||||
suit === 'F' || suit === 'D' ? getHonorImageValue(suit, value) : value
|
||||
|
||||
return `/src/assets/images/tiles/${position}/p2s${suitIndex}_${imageValue}.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建上方手牌背面图片 key
|
||||
*/
|
||||
function buildHandTileImageKey(position: TilePosition = 'top'): string {
|
||||
return `/src/assets/images/tiles/${position}/tbgs_2.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建上方盖牌图片 key
|
||||
*/
|
||||
function buildCoveredTileImageKey(position: TilePosition = 'top'): string {
|
||||
return `/src/assets/images/tiles/${position}/tdbgs_2.png`
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Vite 收集上方麻将牌资源
|
||||
*/
|
||||
const tileImageModules = import.meta.glob('/src/assets/images/tiles/top/*.png', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
}) as Record<string, string>
|
||||
|
||||
/**
|
||||
* 判断是否为合法花色
|
||||
*/
|
||||
export function isValidSuit(suit: string): suit is Suit {
|
||||
return suit === 'W' || suit === 'T' || suit === 'B' || suit === 'F' || suit === 'D'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为合法点数
|
||||
* W/T/B => 1~9
|
||||
* F => 1~4
|
||||
* D => 1~3
|
||||
*/
|
||||
export function isValidTileValueBySuit(suit: Suit, value: number): boolean {
|
||||
if (!Number.isInteger(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (suit) {
|
||||
case 'W':
|
||||
case 'T':
|
||||
case 'B':
|
||||
return value >= 1 && value <= 9
|
||||
case 'F':
|
||||
return value >= 1 && value <= 4
|
||||
case 'D':
|
||||
return value >= 1 && value <= 3
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为合法牌
|
||||
*/
|
||||
export function isValidTile(tile: {
|
||||
suit: string
|
||||
value: number
|
||||
}): tile is Pick<Tile, 'suit' | 'value'> {
|
||||
if (!isValidSuit(tile.suit)) {
|
||||
return false
|
||||
}
|
||||
return isValidTileValueBySuit(tile.suit, tile.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上方手牌背面图
|
||||
*/
|
||||
export function getHandTileImage(position: TilePosition = 'top'): string {
|
||||
const key = buildHandTileImageKey(position)
|
||||
return tileImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上方碰/杠/胡等明牌图片
|
||||
*/
|
||||
export function getExposedTileImage(
|
||||
tile: Pick<Tile, 'suit' | 'value'>,
|
||||
position: TilePosition = 'top',
|
||||
): string {
|
||||
if (!isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const key = buildExposedTileImageKey(tile.suit, tile.value, position)
|
||||
return tileImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上方盖牌图片
|
||||
*/
|
||||
export function getCoveredTileImage(position: TilePosition = 'top'): string {
|
||||
const key = buildCoveredTileImageKey(position)
|
||||
return tileImageModules[key] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一获取上方牌图片
|
||||
*/
|
||||
export function getTileImage(
|
||||
tile?: Pick<Tile, 'suit' | 'value'>,
|
||||
imageType: TileImageType = 'hand',
|
||||
position: TilePosition = 'top',
|
||||
): string {
|
||||
if (imageType === 'hand') {
|
||||
return getHandTileImage(position)
|
||||
}
|
||||
|
||||
if (imageType === 'covered') {
|
||||
return getCoveredTileImage(position)
|
||||
}
|
||||
|
||||
if (!tile || !isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return getExposedTileImage(tile, position)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有基础牌(不含重复)
|
||||
*/
|
||||
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
|
||||
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
|
||||
|
||||
const numberSuits: Array<'W' | 'T' | 'B'> = ['W', 'T', 'B']
|
||||
for (const suit of numberSuits) {
|
||||
for (let value = 1; value <= 9; value++) {
|
||||
result.push({ suit, value })
|
||||
}
|
||||
}
|
||||
|
||||
for (let value = 1; value <= 4; value++) {
|
||||
result.push({ suit: 'F', value })
|
||||
}
|
||||
|
||||
for (let value = 1; value <= 3; value++) {
|
||||
result.push({ suit: 'D', value })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取牌的中文名称
|
||||
*/
|
||||
export function getTileLabel(tile: Pick<Tile, 'suit' | 'value'>): string {
|
||||
if (!isValidTile(tile)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (tile.suit) {
|
||||
case 'W':
|
||||
return `${tile.value}万`
|
||||
case 'T':
|
||||
return `${tile.value}筒`
|
||||
case 'B':
|
||||
return `${tile.value}条`
|
||||
case 'F': {
|
||||
const map: Record<number, string> = {
|
||||
1: '东',
|
||||
2: '南',
|
||||
3: '西',
|
||||
4: '北',
|
||||
}
|
||||
return map[tile.value] || ''
|
||||
}
|
||||
case 'D': {
|
||||
const map: Record<number, string> = {
|
||||
1: '中',
|
||||
2: '发',
|
||||
3: '白',
|
||||
}
|
||||
return map[tile.value] || ''
|
||||
}
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,52 @@ export interface RoomPlayerUpdatePayload {
|
||||
avatar_url?: string
|
||||
Ready?: boolean
|
||||
ready?: boolean
|
||||
is_ready?: boolean
|
||||
MissingSuit?: string | null
|
||||
missing_suit?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export interface RoomTrusteePayload {
|
||||
player_id?: string
|
||||
playerId?: string
|
||||
trustee?: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface DiscardActionPayload {
|
||||
player_id?: string
|
||||
playerId?: string
|
||||
PlayerID?: string
|
||||
tile?: Tile
|
||||
next_seat?: number
|
||||
nextSeat?: number
|
||||
}
|
||||
|
||||
export interface DrawActionPayload {
|
||||
player_id?: string
|
||||
playerId?: string
|
||||
PlayerID?: string
|
||||
tile?: Tile
|
||||
}
|
||||
|
||||
export interface PlayerTurnPayload {
|
||||
player_id?: string
|
||||
playerId?: string
|
||||
PlayerID?: string
|
||||
timeout?: number
|
||||
Timeout?: number
|
||||
start_at?: number
|
||||
startAt?: number
|
||||
StartAt?: number
|
||||
allow_actions?: string[]
|
||||
allowActions?: string[]
|
||||
AllowActions?: string[]
|
||||
available_actions?: string[]
|
||||
availableActions?: string[]
|
||||
AvailableActions?: string[]
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 游戏动作定义(只描述“发生了什么”)
|
||||
@@ -44,20 +85,13 @@ export type GameAction =
|
||||
// 摸牌
|
||||
| {
|
||||
type: 'DRAW_TILE'
|
||||
payload: {
|
||||
playerId: string
|
||||
tile: Tile
|
||||
}
|
||||
payload: DrawActionPayload
|
||||
}
|
||||
|
||||
// 出牌
|
||||
| {
|
||||
type: 'PLAY_TILE'
|
||||
payload: {
|
||||
playerId: string
|
||||
tile: Tile
|
||||
nextSeat: number
|
||||
}
|
||||
payload: DiscardActionPayload
|
||||
}
|
||||
|
||||
// 进入操作窗口(碰/杠/胡)
|
||||
@@ -80,3 +114,13 @@ export type GameAction =
|
||||
type: 'ROOM_PLAYER_UPDATE'
|
||||
payload: RoomPlayerUpdatePayload
|
||||
}
|
||||
|
||||
| {
|
||||
type: 'ROOM_TRUSTEE'
|
||||
payload: RoomTrusteePayload
|
||||
}
|
||||
|
||||
| {
|
||||
type: 'PLAYER_TURN'
|
||||
payload: PlayerTurnPayload
|
||||
}
|
||||
|
||||
283
src/game/chengdu/messageNormalizers.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import type {
|
||||
ClaimOptionState,
|
||||
MeldState,
|
||||
PendingClaimState,
|
||||
PlayerState,
|
||||
Tile,
|
||||
} from '../../types/state'
|
||||
|
||||
export function normalizeWsType(type: string): string {
|
||||
return type.replace(/[-\s]/g, '_').toUpperCase()
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
export function readString(source: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function readNumber(source: Record<string, unknown>, ...keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeTimestampMs(value: number | null): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return null
|
||||
}
|
||||
return value >= 1_000_000_000_000 ? value : value * 1000
|
||||
}
|
||||
|
||||
export function readStringArray(source: Record<string, unknown>, ...keys: string[]): string[] {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === 'string')
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function readBoolean(source: Record<string, unknown>, ...keys: string[]): boolean | null {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (value === 1) {
|
||||
return true
|
||||
}
|
||||
if (value === 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') {
|
||||
return true
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function readMissingSuit(source: Record<string, unknown> | null | undefined): string | null {
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
return readString(source, 'missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit') || null
|
||||
}
|
||||
|
||||
export function readMissingSuitWithPresence(
|
||||
source: Record<string, unknown> | null | undefined,
|
||||
): { present: boolean; value: string | null } {
|
||||
if (!source) {
|
||||
return { present: false, value: null }
|
||||
}
|
||||
|
||||
const keys = ['missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit']
|
||||
const hasMissingSuitField = keys.some((key) => Object.prototype.hasOwnProperty.call(source, key))
|
||||
if (!hasMissingSuitField) {
|
||||
return { present: false, value: null }
|
||||
}
|
||||
|
||||
return { present: true, value: readMissingSuit(source) }
|
||||
}
|
||||
|
||||
export function tileToText(tile: Tile): string {
|
||||
return `${tile.suit}${tile.value}`
|
||||
}
|
||||
|
||||
export function readPlayerTurnPlayerId(payload: Record<string, unknown>): string {
|
||||
return (
|
||||
(typeof payload.player_id === 'string' && payload.player_id) ||
|
||||
(typeof payload.playerId === 'string' && payload.playerId) ||
|
||||
(typeof payload.PlayerID === 'string' && payload.PlayerID) ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
export function readPlayerTurnAllowActions(payload: Record<string, unknown>): string[] {
|
||||
const source =
|
||||
payload.allow_actions ??
|
||||
payload.allowActions ??
|
||||
payload.AllowActions ??
|
||||
payload.available_actions ??
|
||||
payload.availableActions ??
|
||||
payload.AvailableActions
|
||||
if (!Array.isArray(source)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const actions = source
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter((item) => item.length > 0)
|
||||
return Array.from(new Set(actions))
|
||||
}
|
||||
|
||||
export function normalizeTile(tile: unknown): Tile | null {
|
||||
const source = asRecord(tile)
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
|
||||
const id = readNumber(source, 'id')
|
||||
const suit = readString(source, 'suit') as Tile['suit'] | ''
|
||||
const value = readNumber(source, 'value')
|
||||
if (typeof id !== 'number' || !suit || typeof value !== 'number') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (suit !== 'W' && suit !== 'T' && suit !== 'B') {
|
||||
return null
|
||||
}
|
||||
|
||||
return { id, suit, value }
|
||||
}
|
||||
|
||||
export function normalizeTiles(value: unknown): Tile[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value.map((item) => normalizeTile(item)).filter((item): item is Tile => Boolean(item))
|
||||
}
|
||||
|
||||
export function normalizePendingClaim(
|
||||
gameState: Record<string, unknown> | null | undefined,
|
||||
loggedInUserId: string,
|
||||
): PendingClaimState | undefined {
|
||||
if (!gameState || !loggedInUserId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const pendingClaim = asRecord(gameState.pending_claim ?? gameState.pendingClaim)
|
||||
if (!pendingClaim) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const selfOptions = asRecord(pendingClaim[loggedInUserId])
|
||||
if (!selfOptions) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const options: ClaimOptionState[] = []
|
||||
if (readBoolean(selfOptions, 'hu')) {
|
||||
options.push('hu')
|
||||
}
|
||||
if (readBoolean(selfOptions, 'gang')) {
|
||||
options.push('gang')
|
||||
}
|
||||
if (readBoolean(selfOptions, 'peng')) {
|
||||
options.push('peng')
|
||||
}
|
||||
if (options.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
options.push('pass')
|
||||
|
||||
return {
|
||||
tile: normalizeTile(gameState.last_discard_tile ?? gameState.lastDiscardTile) ?? undefined,
|
||||
fromPlayerId: readString(gameState, 'last_discard_by', 'lastDiscardBy') || undefined,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMeldType(value: unknown, concealed = false): MeldState['type'] | null {
|
||||
if (typeof value !== 'string') {
|
||||
return concealed ? 'an_gang' : null
|
||||
}
|
||||
|
||||
const normalized = value.replace(/[-\s]/g, '_').toLowerCase()
|
||||
if (normalized === 'peng') {
|
||||
return 'peng'
|
||||
}
|
||||
if (normalized === 'ming_gang' || normalized === 'gang' || normalized === 'gang_open') {
|
||||
return concealed ? 'an_gang' : 'ming_gang'
|
||||
}
|
||||
if (normalized === 'an_gang' || normalized === 'angang' || normalized === 'concealed_gang') {
|
||||
return 'an_gang'
|
||||
}
|
||||
|
||||
return concealed ? 'an_gang' : null
|
||||
}
|
||||
|
||||
export function normalizeMelds(value: unknown): PlayerState['melds'] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => {
|
||||
if (Array.isArray(item)) {
|
||||
const tiles = normalizeTiles(item)
|
||||
if (tiles.length === 3) {
|
||||
return { type: 'peng', tiles, fromPlayerId: '' } satisfies MeldState
|
||||
}
|
||||
if (tiles.length === 4) {
|
||||
return { type: 'ming_gang', tiles, fromPlayerId: '' } satisfies MeldState
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const source = asRecord(item)
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tiles = normalizeTiles(
|
||||
source.tiles ??
|
||||
source.meld_tiles ??
|
||||
source.meldTiles ??
|
||||
source.cards ??
|
||||
source.card_list,
|
||||
)
|
||||
if (tiles.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const concealed =
|
||||
readBoolean(source, 'concealed', 'is_concealed', 'isConcealed', 'hidden', 'is_hidden') ?? false
|
||||
const explicitType = normalizeMeldType(
|
||||
source.type ?? source.meld_type ?? source.meldType ?? source.kind,
|
||||
concealed,
|
||||
)
|
||||
const type =
|
||||
explicitType ??
|
||||
(tiles.length === 4 ? (concealed ? 'an_gang' : 'ming_gang') : tiles.length === 3 ? 'peng' : null)
|
||||
|
||||
if (type === 'peng' || type === 'ming_gang') {
|
||||
return {
|
||||
type,
|
||||
tiles,
|
||||
fromPlayerId: readString(source, 'from_player_id', 'fromPlayerId'),
|
||||
} satisfies MeldState
|
||||
}
|
||||
|
||||
if (type === 'an_gang') {
|
||||
return { type, tiles } satisfies MeldState
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
.filter((item): item is MeldState => Boolean(item))
|
||||
}
|
||||
@@ -34,6 +34,14 @@ export function dispatchGameAction(action: GameAction) {
|
||||
store.onRoomPlayerUpdate(action.payload)
|
||||
break
|
||||
|
||||
case 'ROOM_TRUSTEE':
|
||||
store.onRoomTrustee(action.payload)
|
||||
break
|
||||
|
||||
case 'PLAYER_TURN':
|
||||
store.onPlayerTurn(action.payload)
|
||||
break
|
||||
|
||||
|
||||
default:
|
||||
throw new Error('Invalid game action')
|
||||
|
||||
0
src/game/events.ts
Normal file
@@ -4,10 +4,35 @@ import {
|
||||
type GameState,
|
||||
type PendingClaimState,
|
||||
} from '../types/state'
|
||||
import type { RoomPlayerUpdatePayload } from '../game/actions'
|
||||
import type { PlayerTurnPayload, RoomPlayerUpdatePayload, RoomTrusteePayload } from '../game/actions'
|
||||
import { readStoredAuth } from '../utils/auth-storage'
|
||||
|
||||
import type { Tile } from '../types/tile'
|
||||
|
||||
function parseBooleanish(value: unknown): boolean | null {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (value === 1) {
|
||||
return true
|
||||
}
|
||||
if (value === 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true' || normalized === '1') {
|
||||
return true
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const useGameStore = defineStore('game', {
|
||||
state: (): GameState => ({
|
||||
roomId: '',
|
||||
@@ -16,6 +41,8 @@ export const useGameStore = defineStore('game', {
|
||||
|
||||
dealerIndex: 0,
|
||||
currentTurn: 0,
|
||||
currentPlayerId: '',
|
||||
needDraw: false,
|
||||
|
||||
players: {},
|
||||
|
||||
@@ -26,32 +53,51 @@ export const useGameStore = defineStore('game', {
|
||||
winners: [],
|
||||
|
||||
scores: {},
|
||||
|
||||
currentRound: 0,
|
||||
|
||||
totalRounds: 0,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
// 初始化
|
||||
resetGame() {
|
||||
this.$reset()
|
||||
},
|
||||
|
||||
// 初始<E5889D>?
|
||||
initGame(data: GameState) {
|
||||
Object.assign(this, data)
|
||||
},
|
||||
|
||||
// 摸牌
|
||||
onDrawTile(data: { playerId: string; tile: Tile }) {
|
||||
const player = this.players[data.playerId]
|
||||
onDrawTile(data: { playerId?: string; player_id?: string; PlayerID?: string; tile?: Tile }) {
|
||||
const playerId =
|
||||
(typeof data.playerId === 'string' && data.playerId) ||
|
||||
(typeof data.player_id === 'string' && data.player_id) ||
|
||||
(typeof data.PlayerID === 'string' && data.PlayerID) ||
|
||||
''
|
||||
const tile = data.tile
|
||||
if (!playerId || !tile) return
|
||||
|
||||
const player = this.players[playerId]
|
||||
if (!player) return
|
||||
|
||||
// 只更新自己的手牌
|
||||
if (player.playerId === this.getMyPlayerId()) {
|
||||
player.handTiles.push(data.tile)
|
||||
player.handTiles.push(tile)
|
||||
}
|
||||
player.handCount += 1
|
||||
|
||||
// 剩余牌数减少
|
||||
this.remainingTiles = Math.max(0, this.remainingTiles - 1)
|
||||
|
||||
// 更新回合(seatIndex)
|
||||
// 更新回合(seatIndex<EFBFBD>?
|
||||
this.currentTurn = player.seatIndex
|
||||
this.currentPlayerId = player.playerId
|
||||
|
||||
// 清除操作窗口
|
||||
this.pendingClaim = undefined
|
||||
this.needDraw = false
|
||||
|
||||
// 进入出牌阶段
|
||||
this.phase = GAME_PHASE.PLAYING
|
||||
@@ -59,36 +105,56 @@ export const useGameStore = defineStore('game', {
|
||||
|
||||
// 出牌
|
||||
onPlayTile(data: {
|
||||
playerId: string
|
||||
tile: Tile
|
||||
nextSeat: number
|
||||
playerId?: string
|
||||
player_id?: string
|
||||
PlayerID?: string
|
||||
tile?: Tile
|
||||
nextSeat?: number
|
||||
next_seat?: number
|
||||
}) {
|
||||
const player = this.players[data.playerId]
|
||||
const playerId =
|
||||
(typeof data.playerId === 'string' && data.playerId) ||
|
||||
(typeof data.player_id === 'string' && data.player_id) ||
|
||||
(typeof data.PlayerID === 'string' && data.PlayerID) ||
|
||||
''
|
||||
const tile = data.tile
|
||||
if (!playerId || !tile) return
|
||||
|
||||
const player = this.players[playerId]
|
||||
if (!player) return
|
||||
|
||||
// 如果是自己,移除手牌
|
||||
if (player.playerId === this.getMyPlayerId()) {
|
||||
const index = player.handTiles.findIndex(
|
||||
(t) => t.id === data.tile.id
|
||||
(t) => t.id === tile.id
|
||||
)
|
||||
if (index !== -1) {
|
||||
player.handTiles.splice(index, 1)
|
||||
}
|
||||
}
|
||||
player.handCount = Math.max(0, player.handCount - 1)
|
||||
|
||||
// 加入出牌区
|
||||
player.discardTiles.push(data.tile)
|
||||
// 加入出牌<EFBFBD>?
|
||||
player.discardTiles.push(tile)
|
||||
|
||||
// 更新回合
|
||||
this.currentTurn = data.nextSeat
|
||||
const nextSeat =
|
||||
typeof data.nextSeat === 'number'
|
||||
? data.nextSeat
|
||||
: typeof data.next_seat === 'number'
|
||||
? data.next_seat
|
||||
: this.currentTurn
|
||||
this.currentTurn = nextSeat
|
||||
this.needDraw = true
|
||||
|
||||
// 等待其他玩家响应
|
||||
this.phase = GAME_PHASE.ACTION
|
||||
},
|
||||
|
||||
// 触发操作窗口(碰/杠/胡)
|
||||
// 触发操作窗口(碰/<EFBFBD>?胡)
|
||||
onPendingClaim(data: PendingClaimState) {
|
||||
this.pendingClaim = data
|
||||
this.needDraw = false
|
||||
this.phase = GAME_PHASE.ACTION
|
||||
},
|
||||
|
||||
@@ -131,7 +197,8 @@ export const useGameStore = defineStore('game', {
|
||||
const seatRaw = raw.Index ?? raw.index ?? index
|
||||
const seatIndex =
|
||||
typeof seatRaw === 'number' && Number.isFinite(seatRaw) ? seatRaw : index
|
||||
const readyRaw = raw.Ready ?? raw.ready
|
||||
const readyRaw = raw.Ready ?? raw.ready ?? raw.is_ready
|
||||
const ready = parseBooleanish(readyRaw)
|
||||
const displayNameRaw = raw.PlayerName ?? raw.player_name
|
||||
const avatarUrlRaw = raw.AvatarUrl ?? raw.avatar_url
|
||||
const missingSuitRaw = raw.MissingSuit ?? raw.missing_suit
|
||||
@@ -147,17 +214,20 @@ export const useGameStore = defineStore('game', {
|
||||
typeof avatarUrlRaw === 'string'
|
||||
? avatarUrlRaw
|
||||
: previous?.avatarURL,
|
||||
isTrustee: previous?.isTrustee ?? false,
|
||||
missingSuit:
|
||||
typeof missingSuitRaw === 'string' || missingSuitRaw === null
|
||||
? missingSuitRaw
|
||||
: previous?.missingSuit,
|
||||
handTiles: previous?.handTiles ?? [],
|
||||
handCount: previous?.handCount ?? 0,
|
||||
melds: previous?.melds ?? [],
|
||||
discardTiles: previous?.discardTiles ?? [],
|
||||
hasHu: previous?.hasHu ?? false,
|
||||
score: previous?.score ?? 0,
|
||||
isReady:
|
||||
typeof readyRaw === 'boolean'
|
||||
? readyRaw
|
||||
ready !== null
|
||||
? ready
|
||||
: (previous?.isReady ?? false),
|
||||
}
|
||||
})
|
||||
@@ -173,10 +243,13 @@ export const useGameStore = defineStore('game', {
|
||||
seatIndex: previous?.seatIndex ?? index,
|
||||
displayName: previous?.displayName ?? playerId,
|
||||
avatarURL: previous?.avatarURL,
|
||||
isTrustee: previous?.isTrustee ?? false,
|
||||
missingSuit: previous?.missingSuit,
|
||||
handTiles: previous?.handTiles ?? [],
|
||||
handCount: previous?.handCount ?? 0,
|
||||
melds: previous?.melds ?? [],
|
||||
discardTiles: previous?.discardTiles ?? [],
|
||||
hasHu: previous?.hasHu ?? false,
|
||||
score: previous?.score ?? 0,
|
||||
isReady: previous?.isReady ?? false,
|
||||
}
|
||||
@@ -186,15 +259,66 @@ export const useGameStore = defineStore('game', {
|
||||
this.players = nextPlayers
|
||||
},
|
||||
|
||||
onRoomTrustee(payload: RoomTrusteePayload) {
|
||||
const playerId =
|
||||
(typeof payload.player_id === 'string' && payload.player_id) ||
|
||||
(typeof payload.playerId === 'string' && payload.playerId) ||
|
||||
''
|
||||
if (!playerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const player = this.players[playerId]
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
player.isTrustee = typeof payload.trustee === 'boolean' ? payload.trustee : true
|
||||
},
|
||||
|
||||
// 清理操作窗口
|
||||
onPlayerTurn(payload: PlayerTurnPayload) {
|
||||
const playerId =
|
||||
(typeof payload.player_id === 'string' && payload.player_id) ||
|
||||
(typeof payload.playerId === 'string' && payload.playerId) ||
|
||||
(typeof payload.PlayerID === 'string' && payload.PlayerID) ||
|
||||
''
|
||||
if (!playerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const player = this.players[playerId]
|
||||
if (player) {
|
||||
this.currentTurn = player.seatIndex
|
||||
}
|
||||
this.currentPlayerId = playerId
|
||||
|
||||
this.needDraw = false
|
||||
this.pendingClaim = undefined
|
||||
this.phase = GAME_PHASE.PLAYING
|
||||
},
|
||||
|
||||
clearPendingClaim() {
|
||||
this.pendingClaim = undefined
|
||||
this.phase = GAME_PHASE.PLAYING
|
||||
},
|
||||
|
||||
// 获取当前玩家ID(后续建议放到 userStore)
|
||||
// 获取当前玩家ID(后续建议放<EFBFBD>?userStore<EFBFBD>?
|
||||
getMyPlayerId(): string {
|
||||
return Object.keys(this.players)[0] || ''
|
||||
const auth = readStoredAuth()
|
||||
const source = auth?.user as Record<string, unknown> | undefined
|
||||
const rawId =
|
||||
source?.id ??
|
||||
source?.userID ??
|
||||
source?.user_id
|
||||
if (typeof rawId === 'string' && rawId.trim()) {
|
||||
return rawId
|
||||
}
|
||||
if (typeof rawId === 'number') {
|
||||
return String(rawId)
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
import type {
|
||||
ActiveRoomState,
|
||||
ActiveRoomSelectionInput,
|
||||
RoomMetaSnapshotInput,
|
||||
RoomMetaSnapshotState,
|
||||
} from './state'
|
||||
import { readActiveRoomSnapshot, saveActiveRoom } from './storage'
|
||||
import { clearRoomMetaSnapshot, readRoomMetaSnapshot, saveRoomMetaSnapshot } from './storage'
|
||||
|
||||
const activeRoom = ref<ActiveRoomState | null>(readActiveRoomSnapshot())
|
||||
const roomMetaSnapshot = ref<RoomMetaSnapshotState | null>(readRoomMetaSnapshot())
|
||||
|
||||
function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState {
|
||||
function normalizeRoom(input: RoomMetaSnapshotInput): RoomMetaSnapshotState {
|
||||
return {
|
||||
roomId: input.roomId,
|
||||
roomName: input.roomName ?? '',
|
||||
@@ -19,8 +19,8 @@ function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState {
|
||||
createdAt: input.createdAt ?? '',
|
||||
updatedAt: input.updatedAt ?? '',
|
||||
players: input.players ?? [],
|
||||
myHand: [],
|
||||
game: {
|
||||
myHand: input.myHand ?? [],
|
||||
game: input.game ?? {
|
||||
state: {
|
||||
wall: [],
|
||||
scores: {},
|
||||
@@ -32,14 +32,17 @@ function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState {
|
||||
}
|
||||
}
|
||||
|
||||
// 设置当前房间
|
||||
export function setActiveRoom(input: ActiveRoomSelectionInput) {
|
||||
export function setRoomMetaSnapshot(input: RoomMetaSnapshotInput) {
|
||||
const next = normalizeRoom(input)
|
||||
activeRoom.value = next
|
||||
saveActiveRoom(next)
|
||||
roomMetaSnapshot.value = next
|
||||
saveRoomMetaSnapshot(next)
|
||||
}
|
||||
|
||||
// 使用房间状态
|
||||
export function useActiveRoomState() {
|
||||
return activeRoom
|
||||
}
|
||||
export function clearRoomMetaSnapshotState() {
|
||||
roomMetaSnapshot.value = null
|
||||
clearRoomMetaSnapshot()
|
||||
}
|
||||
|
||||
export function useRoomMetaSnapshotState() {
|
||||
return roomMetaSnapshot
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// 房间玩家状态
|
||||
export interface RoomPlayerState {
|
||||
export interface RoomMetaPlayerState {
|
||||
index: number
|
||||
playerId: string
|
||||
displayName?: string
|
||||
missingSuit?: string | null
|
||||
ready: boolean
|
||||
trustee?: boolean
|
||||
hand: string[]
|
||||
melds: string[]
|
||||
outTiles: string[]
|
||||
@@ -12,7 +13,7 @@ export interface RoomPlayerState {
|
||||
}
|
||||
|
||||
// 房间整体状态
|
||||
export interface ActiveRoomState {
|
||||
export interface RoomMetaSnapshotState {
|
||||
roomId: string
|
||||
roomName: string
|
||||
gameType: string
|
||||
@@ -22,7 +23,7 @@ export interface ActiveRoomState {
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
players: RoomPlayerState[]
|
||||
players: RoomMetaPlayerState[]
|
||||
myHand: string[]
|
||||
game?: {
|
||||
state?: {
|
||||
@@ -35,7 +36,7 @@ export interface ActiveRoomState {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ActiveRoomSelectionInput {
|
||||
export interface RoomMetaSnapshotInput {
|
||||
roomId: string
|
||||
roomName?: string
|
||||
gameType?: string
|
||||
@@ -45,5 +46,7 @@ export interface ActiveRoomSelectionInput {
|
||||
status?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
players?: RoomPlayerState[]
|
||||
}
|
||||
players?: RoomMetaPlayerState[]
|
||||
myHand?: string[]
|
||||
game?: RoomMetaSnapshotState['game']
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ActiveRoomState } from './state'
|
||||
import type { RoomMetaSnapshotState } from './state'
|
||||
|
||||
const KEY = 'mahjong_active_room'
|
||||
|
||||
// 读取缓存
|
||||
export function readActiveRoomSnapshot(): ActiveRoomState | null {
|
||||
export function readRoomMetaSnapshot(): RoomMetaSnapshotState | null {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return null
|
||||
|
||||
@@ -15,6 +15,11 @@ export function readActiveRoomSnapshot(): ActiveRoomState | null {
|
||||
}
|
||||
|
||||
// 写入缓存
|
||||
export function saveActiveRoom(state: ActiveRoomState) {
|
||||
export function saveRoomMetaSnapshot(state: RoomMetaSnapshotState) {
|
||||
localStorage.setItem(KEY, JSON.stringify(state))
|
||||
}
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
export function clearRoomMetaSnapshot() {
|
||||
localStorage.removeItem(KEY)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ export interface GameState {
|
||||
|
||||
// 当前操作玩家(座位)
|
||||
currentTurn: number
|
||||
// 当前操作玩家ID
|
||||
currentPlayerId: string
|
||||
|
||||
// 当前回合是否需要先摸牌
|
||||
needDraw: boolean
|
||||
|
||||
// 玩家列表
|
||||
players: Record<string, PlayerState>
|
||||
@@ -28,4 +33,10 @@ export interface GameState {
|
||||
|
||||
// 分数(playerId -> score)
|
||||
scores: Record<string, number>
|
||||
}
|
||||
|
||||
// 当前第几局
|
||||
currentRound: number
|
||||
|
||||
// 总局数
|
||||
totalRounds: number
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import type {Tile} from "../tile.ts";
|
||||
|
||||
export interface PendingClaimState {
|
||||
// 当前被响应的牌
|
||||
tile: Tile
|
||||
tile?: Tile
|
||||
|
||||
// 出牌人
|
||||
fromPlayerId: string
|
||||
fromPlayerId?: string
|
||||
|
||||
// 当前玩家可执行操作
|
||||
options: ClaimOptionState[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,18 @@ export interface PlayerState {
|
||||
seatIndex: number
|
||||
displayName?: string
|
||||
missingSuit?: string | null
|
||||
isTrustee: boolean
|
||||
|
||||
// 手牌(只有自己有完整数据,后端可控制)
|
||||
handTiles: Tile[]
|
||||
handCount: number
|
||||
|
||||
// 副露(碰/杠)
|
||||
melds: MeldState[]
|
||||
|
||||
// 出牌区
|
||||
discardTiles: Tile[]
|
||||
hasHu: boolean
|
||||
|
||||
// 分数
|
||||
score: number
|
||||
|
||||
@@ -1,579 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
import tiaoIcon from '../assets/images/flowerClolor/tiao.png'
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import deskImage from '../assets/images/desk/desk_01_1920_945.png'
|
||||
import robotIcon from '../assets/images/icons/robot.svg'
|
||||
import exitIcon from '../assets/images/icons/exit.svg'
|
||||
import '../assets/styles/room.css'
|
||||
import topBackImage from '../assets/images/tiles/top/tbgs_2.png'
|
||||
import rightBackImage from '../assets/images/tiles/right/tbgs_1.png'
|
||||
import bottomBackImage from '../assets/images/tiles/bottom/tdbgs_4.png'
|
||||
import leftBackImage from '../assets/images/tiles/left/tbgs_3.png'
|
||||
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 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'
|
||||
import WindSquare from '../components/game/WindSquare.vue'
|
||||
import ChengduBottomActions from '../components/chengdu/ChengduBottomActions.vue'
|
||||
import ChengduDeskZones from '../components/chengdu/ChengduDeskZones.vue'
|
||||
import ChengduSettlementOverlay from '../components/chengdu/ChengduSettlementOverlay.vue'
|
||||
import ChengduTableHeader from '../components/chengdu/ChengduTableHeader.vue'
|
||||
import ChengduWallSeats from '../components/chengdu/ChengduWallSeats.vue'
|
||||
import { useGameStore } from '../store/gameStore'
|
||||
import { useRoomMetaSnapshotState } from '../store'
|
||||
import { useChengduGameActions } from './chengdu/composables/useChengduGameActions'
|
||||
import { useChengduGameSession } from './chengdu/composables/useChengduGameSession'
|
||||
import { useChengduGameSocket } from './chengdu/composables/useChengduGameSocket'
|
||||
import { formatTile, useChengduTableView } from './chengdu/composables/useChengduTableView'
|
||||
import type { ActionCountdownView, DisplayPlayer } from './chengdu/types'
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const activeRoom = useActiveRoomState()
|
||||
const roomMeta = useRoomMetaSnapshotState()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = ref(readStoredAuth())
|
||||
|
||||
type DisplayPlayer = PlayerState & {
|
||||
displayName?: string
|
||||
missingSuit?: string | null
|
||||
}
|
||||
const session = useChengduGameSession({
|
||||
route,
|
||||
router,
|
||||
gameStore,
|
||||
roomMeta,
|
||||
})
|
||||
const {
|
||||
now,
|
||||
wsStatus,
|
||||
wsError,
|
||||
roomCountdown,
|
||||
leaveRoomPending,
|
||||
readyTogglePending,
|
||||
nextRoundPending: startNextRoundPending,
|
||||
dingQuePending,
|
||||
discardPending,
|
||||
claimActionPending,
|
||||
turnActionPending,
|
||||
selectedDiscardTileId,
|
||||
menuOpen,
|
||||
isTrustMode,
|
||||
menuTriggerActive,
|
||||
loggedInUserId,
|
||||
networkLabel,
|
||||
formattedClock,
|
||||
toggleMenu,
|
||||
toggleTrustMode,
|
||||
backHall,
|
||||
} = session
|
||||
|
||||
type GameActionPayload<TType extends GameAction['type']> = Extract<GameAction, { type: TType }>['payload']
|
||||
const routeRoomName = computed(() => (typeof route.query.roomName === 'string' ? route.query.roomName : ''))
|
||||
const myPlayer = computed(() => gameStore.players[loggedInUserId.value] as DisplayPlayer | undefined)
|
||||
const myHandTiles = computed(() => myPlayer.value?.handTiles ?? [])
|
||||
const gamePlayers = computed<DisplayPlayer[]>(() =>
|
||||
Object.values(gameStore.players).sort((a, b) => a.seatIndex - b.seatIndex) as DisplayPlayer[],
|
||||
)
|
||||
|
||||
interface SeatViewModel {
|
||||
key: SeatKey
|
||||
player?: DisplayPlayer
|
||||
isSelf: boolean
|
||||
isTurn: boolean
|
||||
}
|
||||
|
||||
const now = ref(Date.now())
|
||||
const wsStatus = ref<WsStatus>('idle')
|
||||
const wsMessages = ref<string[]>([])
|
||||
const wsError = ref('')
|
||||
const selectedTile = ref<string | null>(null)
|
||||
const leaveRoomPending = ref(false)
|
||||
let clockTimer: number | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
const menuOpen = ref(false)
|
||||
const isTrustMode = ref(false)
|
||||
const menuTriggerActive = ref(false)
|
||||
let menuTriggerTimer: number | null = null
|
||||
let menuOpenTimer: number | null = null
|
||||
|
||||
const loggedInUserId = computed(() => {
|
||||
const rawId = auth.value?.user?.id
|
||||
if (typeof rawId === 'string') {
|
||||
return rawId
|
||||
}
|
||||
if (typeof rawId === 'number') {
|
||||
return String(rawId)
|
||||
}
|
||||
return ''
|
||||
const {
|
||||
roomName,
|
||||
roomState,
|
||||
seatWinds,
|
||||
currentTurnSeat,
|
||||
currentPhaseText,
|
||||
roomStatusText,
|
||||
roundText,
|
||||
wallSeats,
|
||||
deskSeats,
|
||||
seatDecor,
|
||||
settlementPlayers,
|
||||
} = useChengduTableView({
|
||||
roomMeta,
|
||||
gamePlayers,
|
||||
gameStore,
|
||||
localCachedAvatarUrl: session.localCachedAvatarUrl,
|
||||
loggedInUserId,
|
||||
loggedInUserName: session.loggedInUserName,
|
||||
myHandTiles,
|
||||
myPlayer,
|
||||
routeRoomName,
|
||||
})
|
||||
|
||||
const loggedInUserName = computed(() => {
|
||||
return auth.value?.user?.nickname || auth.value?.user?.username || ''
|
||||
const socket = useChengduGameSocket({
|
||||
route,
|
||||
router,
|
||||
gameStore,
|
||||
roomMeta,
|
||||
roomName,
|
||||
myHandTiles,
|
||||
myPlayer,
|
||||
session,
|
||||
})
|
||||
const { showSettlementOverlay, settlementCountdown } = socket
|
||||
|
||||
const {
|
||||
isLastRound,
|
||||
myReadyState,
|
||||
isRoomOwner,
|
||||
showStartGameButton,
|
||||
showWaitingOwnerTip,
|
||||
canStartGame,
|
||||
showReadyToggle,
|
||||
showDingQueChooser,
|
||||
selectedDiscardTile,
|
||||
discardBlockedReason,
|
||||
discardTileBlockedReason,
|
||||
canConfirmDiscard,
|
||||
confirmDiscardLabel,
|
||||
canDrawTile,
|
||||
visibleClaimOptions,
|
||||
showClaimActions,
|
||||
canSelfHu,
|
||||
canSelfGang,
|
||||
toggleReadyState,
|
||||
startGame,
|
||||
nextRound,
|
||||
chooseDingQue,
|
||||
selectDiscardTile,
|
||||
confirmDiscard,
|
||||
drawTile,
|
||||
submitSelfGang,
|
||||
submitSelfHu,
|
||||
submitClaim,
|
||||
} = useChengduGameActions({
|
||||
gameStore,
|
||||
roomMeta,
|
||||
gamePlayers,
|
||||
myPlayer,
|
||||
session,
|
||||
})
|
||||
|
||||
const localCachedAvatarUrl = computed(() => {
|
||||
const source = auth.value?.user as Record<string, unknown> | undefined
|
||||
if (!source) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const avatarCandidates = [
|
||||
source.avatar,
|
||||
source.avatar_url,
|
||||
source.avatarUrl,
|
||||
source.head_img,
|
||||
source.headImg,
|
||||
source.profile_image,
|
||||
source.profileImage,
|
||||
]
|
||||
|
||||
for (const candidate of avatarCandidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim()) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
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 : ''
|
||||
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 {
|
||||
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 = gamePlayers.value
|
||||
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
|
||||
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) => {
|
||||
const relativeIndex = (player.seatIndex - selfSeatIndex + 4) % 4
|
||||
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
||||
return {
|
||||
key: seatKey,
|
||||
player,
|
||||
isSelf: player.playerId === loggedInUserId.value,
|
||||
isTurn: player.seatIndex === currentTurn,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
|
||||
|
||||
const currentPhaseText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
waiting: '等待中',
|
||||
dealing: '发牌中',
|
||||
playing: '对局中',
|
||||
action: '操作中',
|
||||
settlement: '已结算',
|
||||
}
|
||||
return map[gameStore.phase] ?? gameStore.phase
|
||||
})
|
||||
|
||||
const roomStatusText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
waiting: '等待玩家',
|
||||
playing: '游戏中',
|
||||
finished: '已结束',
|
||||
}
|
||||
const status = roomState.value.status
|
||||
return map[status] ?? status ?? '--'
|
||||
})
|
||||
|
||||
const networkLabel = computed(() => {
|
||||
const map: Record<WsStatus, string> = {
|
||||
connected: '已连接',
|
||||
connecting: '连接中',
|
||||
error: '连接异常',
|
||||
idle: '未连接',
|
||||
closed: '未连接',
|
||||
}
|
||||
|
||||
return map[wsStatus.value] ?? '未连接'
|
||||
})
|
||||
|
||||
const formattedClock = computed(() => {
|
||||
return new Date(now.value).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
})
|
||||
|
||||
const wallBacks = computed<Record<SeatKey, string[]>>(() => {
|
||||
const wallSize = remainingTiles.value
|
||||
const perSide = Math.max(6, Math.ceil((wallSize || 48) / 4 / 2))
|
||||
|
||||
return {
|
||||
top: Array.from({length: perSide}, (_, index) => `top-${index}`),
|
||||
right: Array.from({length: perSide}, (_, index) => `right-${index}`),
|
||||
bottom: Array.from({length: perSide}, (_, index) => `bottom-${index}`),
|
||||
left: Array.from({length: perSide}, (_, index) => `left-${index}`),
|
||||
}
|
||||
})
|
||||
|
||||
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
||||
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
|
||||
const defaultMissingSuitLabel = missingSuitLabel(null)
|
||||
|
||||
const emptySeat = (): SeatPlayerCardModel => ({
|
||||
avatarUrl: '',
|
||||
name: '空位',
|
||||
dealer: false,
|
||||
isTurn: false,
|
||||
missingSuitLabel: defaultMissingSuitLabel,
|
||||
})
|
||||
|
||||
const result: Record<SeatKey, SeatPlayerCardModel> = {
|
||||
top: emptySeat(),
|
||||
right: emptySeat(),
|
||||
bottom: emptySeat(),
|
||||
left: emptySeat(),
|
||||
}
|
||||
|
||||
for (const seat of seatViews.value) {
|
||||
if (!seat.player) {
|
||||
continue
|
||||
}
|
||||
|
||||
const displayName = seat.player.displayName || `玩家${seat.player.seatIndex + 1}`
|
||||
const avatarUrl = seat.isSelf
|
||||
? (localCachedAvatarUrl.value || seat.player.avatarURL || '')
|
||||
: (seat.player.avatarURL || '')
|
||||
const selfDisplayName = seat.player.displayName || loggedInUserName.value || '你自己'
|
||||
|
||||
result[seat.key] = {
|
||||
avatarUrl,
|
||||
name: seat.isSelf ? selfDisplayName : displayName,
|
||||
dealer: seat.player.seatIndex === dealerIndex,
|
||||
isTurn: seat.isTurn,
|
||||
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const floatingMissingSuit = computed(() => {
|
||||
const suitMap: Record<string, string> = {
|
||||
万: wanIcon,
|
||||
筒: tongIcon,
|
||||
条: tiaoIcon,
|
||||
}
|
||||
|
||||
const topLabel = seatDecor.value.top?.missingSuitLabel ?? ''
|
||||
const leftLabel = seatDecor.value.left?.missingSuitLabel ?? ''
|
||||
const rightLabel = seatDecor.value.right?.missingSuitLabel ?? ''
|
||||
|
||||
return {
|
||||
top: suitMap[topLabel] ?? '',
|
||||
left: suitMap[leftLabel] ?? '',
|
||||
right: suitMap[rightLabel] ?? '',
|
||||
}
|
||||
})
|
||||
|
||||
function missingSuitLabel(value: string | null | undefined): string {
|
||||
const suitMap: Record<string, string> = {
|
||||
wan: '万',
|
||||
tong: '筒',
|
||||
tiao: '条',
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
return suitMap[value] ?? value
|
||||
}
|
||||
|
||||
function getBackImage(seat: SeatKey): string {
|
||||
const imageMap: Record<SeatKey, string> = {
|
||||
top: topBackImage,
|
||||
right: rightBackImage,
|
||||
bottom: bottomBackImage,
|
||||
left: leftBackImage,
|
||||
}
|
||||
|
||||
return imageMap[seat]
|
||||
}
|
||||
|
||||
function toggleMenu(): void {
|
||||
menuTriggerActive.value = true
|
||||
if (menuTriggerTimer !== null) {
|
||||
window.clearTimeout(menuTriggerTimer)
|
||||
}
|
||||
menuTriggerTimer = window.setTimeout(() => {
|
||||
menuTriggerActive.value = false
|
||||
menuTriggerTimer = null
|
||||
}, 180)
|
||||
|
||||
if (menuOpen.value) {
|
||||
menuOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (menuOpenTimer !== null) {
|
||||
window.clearTimeout(menuOpenTimer)
|
||||
}
|
||||
menuOpenTimer = window.setTimeout(() => {
|
||||
menuOpen.value = true
|
||||
menuOpenTimer = null
|
||||
}, 85)
|
||||
}
|
||||
|
||||
function toggleTrustMode(): void {
|
||||
isTrustMode.value = !isTrustMode.value
|
||||
menuOpen.value = false
|
||||
}
|
||||
|
||||
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') {
|
||||
const actionCountdown = computed<ActionCountdownView | null>(() => {
|
||||
const countdown = roomCountdown.value
|
||||
if (!countdown) {
|
||||
return null
|
||||
}
|
||||
|
||||
const source = message as Record<string, unknown>
|
||||
if (typeof source.type !== 'string') {
|
||||
const deadlineAt = countdown.actionDeadlineAt ? Date.parse(countdown.actionDeadlineAt) : Number.NaN
|
||||
const fallbackRemaining = countdown.remaining > 0 ? countdown.remaining : countdown.countdownSeconds
|
||||
const derivedRemaining = Number.isFinite(deadlineAt)
|
||||
? Math.ceil((deadlineAt - now.value) / 1000)
|
||||
: fallbackRemaining
|
||||
const remaining = Math.max(0, derivedRemaining)
|
||||
|
||||
if (remaining <= 0) {
|
||||
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 GameActionPayload<'GAME_INIT'>}
|
||||
}
|
||||
return null
|
||||
case 'GAME_START':
|
||||
if (payload && typeof payload === 'object') {
|
||||
return {type: 'GAME_START', payload: payload as GameActionPayload<'GAME_START'>}
|
||||
}
|
||||
return null
|
||||
case 'DRAW_TILE':
|
||||
if (payload && typeof payload === 'object') {
|
||||
return {type: 'DRAW_TILE', payload: payload as GameActionPayload<'DRAW_TILE'>}
|
||||
}
|
||||
return null
|
||||
case 'PLAY_TILE':
|
||||
if (payload && typeof payload === 'object') {
|
||||
return {type: 'PLAY_TILE', payload: payload as GameActionPayload<'PLAY_TILE'>}
|
||||
}
|
||||
return null
|
||||
case 'PENDING_CLAIM':
|
||||
if (payload && typeof payload === 'object') {
|
||||
return {type: 'PENDING_CLAIM', payload: payload as GameActionPayload<'PENDING_CLAIM'>}
|
||||
}
|
||||
return null
|
||||
case 'CLAIM_RESOLVED':
|
||||
if (payload && typeof payload === 'object') {
|
||||
return {type: 'CLAIM_RESOLVED', payload: payload as GameActionPayload<'CLAIM_RESOLVED'>}
|
||||
}
|
||||
return null
|
||||
case 'ROOM_PLAYER_UPDATE':
|
||||
if (payload && typeof payload === 'object') {
|
||||
return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameActionPayload<'ROOM_PLAYER_UPDATE'>}
|
||||
}
|
||||
return null
|
||||
case 'ROOM_MEMBER_JOINED':
|
||||
if (payload && typeof payload === 'object') {
|
||||
return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameActionPayload<'ROOM_PLAYER_UPDATE'>}
|
||||
}
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWsConnected(): void {
|
||||
const token = auth.value?.token
|
||||
if (!token) {
|
||||
wsError.value = '未找到登录凭证,无法建立连接'
|
||||
return
|
||||
const targetPlayerIds = countdown.playerIds.filter((playerId) => typeof playerId === 'string' && playerId.trim())
|
||||
if (targetPlayerIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
wsError.value = ''
|
||||
wsClient.connect(buildWsUrl(token), token)
|
||||
}
|
||||
const playerLabel = targetPlayerIds
|
||||
.map((playerId) => {
|
||||
if (playerId === loggedInUserId.value) {
|
||||
return '你'
|
||||
}
|
||||
const targetPlayer = gameStore.players[playerId]
|
||||
if (targetPlayer?.displayName) {
|
||||
return targetPlayer.displayName
|
||||
}
|
||||
if (targetPlayer) {
|
||||
return `玩家${targetPlayer.seatIndex + 1}`
|
||||
}
|
||||
return '玩家'
|
||||
})
|
||||
.join('、')
|
||||
const duration = countdown.duration > 0 ? countdown.duration : Math.max(remaining, fallbackRemaining, 1)
|
||||
const includesSelf = targetPlayerIds.includes(loggedInUserId.value)
|
||||
|
||||
function reconnectWs(): void {
|
||||
const token = auth.value?.token
|
||||
if (!token) {
|
||||
wsError.value = '未找到登录凭证,无法建立连接'
|
||||
return
|
||||
return {
|
||||
playerLabel,
|
||||
remaining,
|
||||
duration,
|
||||
isSelf: includesSelf,
|
||||
progress: Math.max(0, Math.min(100, (remaining / duration) * 100)),
|
||||
}
|
||||
|
||||
wsError.value = ''
|
||||
wsClient.reconnect(buildWsUrl(token), token)
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function handleLeaveRoom(): void {
|
||||
menuOpen.value = false
|
||||
backHall()
|
||||
}
|
||||
|
||||
function handleGlobalClick(event: MouseEvent): void {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
if (target.closest('.menu-trigger-wrap')) {
|
||||
return
|
||||
}
|
||||
|
||||
menuOpen.value = false
|
||||
}
|
||||
|
||||
function handleGlobalEsc(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
menuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
avatarURL: previous?.avatarURL,
|
||||
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 : ''
|
||||
hydrateFromActiveRoom(routeRoomId)
|
||||
if (routeRoomId) {
|
||||
gameStore.roomId = routeRoomId
|
||||
}
|
||||
|
||||
const handler = (status: WsStatus) => {
|
||||
wsStatus.value = status
|
||||
}
|
||||
|
||||
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
|
||||
wsMessages.value.push(`[error] ${message}`)
|
||||
})
|
||||
|
||||
unsubscribe = wsClient.onStatusChange(handler)
|
||||
ensureWsConnected()
|
||||
|
||||
clockTimer = window.setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
|
||||
window.addEventListener('click', handleGlobalClick)
|
||||
window.addEventListener('keydown', handleGlobalEsc)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
unsubscribe = null
|
||||
}
|
||||
|
||||
if (clockTimer !== null) {
|
||||
window.clearInterval(clockTimer)
|
||||
clockTimer = null
|
||||
}
|
||||
|
||||
window.removeEventListener('click', handleGlobalClick)
|
||||
window.removeEventListener('keydown', handleGlobalEsc)
|
||||
|
||||
if (menuTriggerTimer !== null) {
|
||||
window.clearTimeout(menuTriggerTimer)
|
||||
menuTriggerTimer = null
|
||||
}
|
||||
|
||||
if (menuOpenTimer !== null) {
|
||||
window.clearTimeout(menuOpenTimer)
|
||||
menuOpenTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -587,142 +204,109 @@ onBeforeUnmount(() => {
|
||||
<div class="inner-outline outer"></div>
|
||||
<div class="inner-outline mid"></div>
|
||||
|
||||
<div class="top-left-tools">
|
||||
<div class="menu-trigger-wrap">
|
||||
<button
|
||||
class="metal-circle menu-trigger"
|
||||
:class="{ 'is-feedback': menuTriggerActive }"
|
||||
type="button"
|
||||
:disabled="leaveRoomPending"
|
||||
@click.stop="toggleMenu"
|
||||
>
|
||||
<span class="menu-trigger-icon">☰</span>
|
||||
</button>
|
||||
<transition name="menu-pop">
|
||||
<div v-if="menuOpen" class="menu-popover" @click.stop>
|
||||
<div class="menu-list">
|
||||
<button class="menu-item menu-item-delay-1" type="button" @click="toggleTrustMode">
|
||||
<span class="menu-item-icon">
|
||||
<img :src="robotIcon" alt=""/>
|
||||
</span>
|
||||
<span>{{ isTrustMode ? '取消托管' : '托管' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="menu-item menu-item-danger menu-item-delay-2"
|
||||
type="button"
|
||||
:disabled="leaveRoomPending"
|
||||
@click="handleLeaveRoom"
|
||||
>
|
||||
<span class="menu-item-icon">
|
||||
<img :src="exitIcon" alt=""/>
|
||||
</span>
|
||||
<span>{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<div class="left-counter">
|
||||
<span class="counter-light"></span>
|
||||
<strong>{{ roomState.game?.state?.wall?.length ?? 48 }}</strong>
|
||||
</div>
|
||||
<span v-if="isTrustMode" class="trust-chip">托管中</span>
|
||||
</div>
|
||||
|
||||
<div class="top-right-clock">
|
||||
<div class="signal-chip">
|
||||
<span class="wifi-dot" :class="`is-${wsStatus}`"></span>
|
||||
<strong>{{ networkLabel }}</strong>
|
||||
</div>
|
||||
<span>{{ formattedClock }}</span>
|
||||
</div>
|
||||
|
||||
<ChengduTableHeader
|
||||
:leave-room-pending="leaveRoomPending"
|
||||
:menu-open="menuOpen"
|
||||
:menu-trigger-active="menuTriggerActive"
|
||||
:is-trust-mode="isTrustMode"
|
||||
:wall-count="roomState.game.state.wall.length || 48"
|
||||
:network-label="networkLabel"
|
||||
:ws-status="wsStatus"
|
||||
:formatted-clock="formattedClock"
|
||||
:room-name="roomState.name || roomName"
|
||||
:current-phase-text="currentPhaseText"
|
||||
:player-count="roomState.playerCount"
|
||||
:max-players="roomState.maxPlayers"
|
||||
:round-text="roundText"
|
||||
:room-status-text="roomStatusText"
|
||||
:ws-error="wsError"
|
||||
:action-countdown="actionCountdown"
|
||||
@toggle-menu="toggleMenu"
|
||||
@toggle-trust-mode="toggleTrustMode"
|
||||
@leave-room="handleLeaveRoom"
|
||||
>
|
||||
<template #robot-icon>
|
||||
<span class="menu-item-icon">
|
||||
<img :src="robotIcon" alt=""/>
|
||||
</span>
|
||||
</template>
|
||||
<template #exit-icon>
|
||||
<span class="menu-item-icon">
|
||||
<img :src="exitIcon" alt=""/>
|
||||
</span>
|
||||
</template>
|
||||
</ChengduTableHeader>
|
||||
|
||||
<TopPlayerCard :player="seatDecor.top"/>
|
||||
<RightPlayerCard :player="seatDecor.right"/>
|
||||
<BottomPlayerCard :player="seatDecor.bottom"/>
|
||||
<LeftPlayerCard :player="seatDecor.left"/>
|
||||
|
||||
<div class="wall wall-top">
|
||||
<img v-for="key in wallBacks.top" :key="key" :src="getBackImage('top')" alt=""/>
|
||||
</div>
|
||||
<div class="wall wall-right">
|
||||
<img v-for="key in wallBacks.right" :key="key" :src="getBackImage('right')" alt=""/>
|
||||
</div>
|
||||
<div class="wall wall-bottom">
|
||||
<img v-for="key in wallBacks.bottom" :key="key" :src="getBackImage('bottom')" alt=""/>
|
||||
</div>
|
||||
<div class="wall wall-left">
|
||||
<img v-for="key in wallBacks.left" :key="key" :src="getBackImage('left')" alt=""/>
|
||||
</div>
|
||||
<ChengduDeskZones :desk-seats="deskSeats"/>
|
||||
|
||||
<div class="floating-status top">
|
||||
<img v-if="floatingMissingSuit.top" :src="floatingMissingSuit.top" alt=""/>
|
||||
<span>{{ seatDecor.top.missingSuitLabel }}</span>
|
||||
</div>
|
||||
<div class="floating-status left">
|
||||
<img v-if="floatingMissingSuit.left" :src="floatingMissingSuit.left" alt=""/>
|
||||
<span>{{ seatDecor.left.missingSuitLabel }}</span>
|
||||
</div>
|
||||
<div class="floating-status right">
|
||||
<img v-if="floatingMissingSuit.right" :src="floatingMissingSuit.right" alt=""/>
|
||||
<span>{{ seatDecor.right.missingSuitLabel }}</span>
|
||||
</div>
|
||||
<ChengduWallSeats
|
||||
:wall-seats="wallSeats"
|
||||
:selected-discard-tile-id="selectedDiscardTileId"
|
||||
:discard-blocked-reason="discardBlockedReason"
|
||||
:discard-tile-blocked-reason="discardTileBlockedReason"
|
||||
:format-tile="formatTile"
|
||||
@select-discard-tile="selectDiscardTile"
|
||||
/>
|
||||
|
||||
<WindSquare class="center-wind-square" :seat-winds="seatWinds"
|
||||
:active-position="currentTurnSeat"/>
|
||||
|
||||
<div class="bottom-control-panel">
|
||||
<ChengduSettlementOverlay
|
||||
:show="showSettlementOverlay"
|
||||
:is-last-round="isLastRound"
|
||||
:current-round="gameStore.currentRound"
|
||||
:total-rounds="gameStore.totalRounds"
|
||||
:settlement-players="settlementPlayers"
|
||||
:logged-in-user-id="loggedInUserId"
|
||||
:is-room-owner="isRoomOwner"
|
||||
:self-is-ready="myReadyState"
|
||||
:ready-toggle-pending="readyTogglePending"
|
||||
:start-next-round-pending="startNextRoundPending"
|
||||
:leave-room-pending="leaveRoomPending"
|
||||
:settlement-countdown="settlementCountdown"
|
||||
@ready="toggleReadyState"
|
||||
@start-next-round="nextRound"
|
||||
@exit="backHall"
|
||||
@back-hall="backHall"
|
||||
/>
|
||||
|
||||
<div class="player-hand" v-if="myHandTiles.length > 0">
|
||||
<button
|
||||
v-for="tile in myHandTiles"
|
||||
:key="tile.id"
|
||||
class="tile-chip"
|
||||
:class="{ selected: selectedTile === formatTile(tile) }"
|
||||
type="button"
|
||||
@click="selectTile(formatTile(tile))"
|
||||
>
|
||||
{{ formatTile(tile) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ChengduBottomActions
|
||||
:show-ding-que-chooser="showDingQueChooser"
|
||||
:show-ready-toggle="showReadyToggle"
|
||||
:show-start-game-button="showStartGameButton"
|
||||
:selected-discard-tile="selectedDiscardTile"
|
||||
:ding-que-pending="dingQuePending"
|
||||
:can-confirm-discard="canConfirmDiscard"
|
||||
:discard-pending="discardPending"
|
||||
:confirm-discard-label="confirmDiscardLabel"
|
||||
:ready-toggle-pending="readyTogglePending"
|
||||
:my-ready-state="myReadyState"
|
||||
:can-draw-tile="canDrawTile"
|
||||
:can-start-game="canStartGame"
|
||||
:is-room-owner="isRoomOwner"
|
||||
:can-self-gang="canSelfGang"
|
||||
:can-self-hu="canSelfHu"
|
||||
:show-claim-actions="showClaimActions"
|
||||
:turn-action-pending="turnActionPending"
|
||||
:visible-claim-options="visibleClaimOptions"
|
||||
:claim-action-pending="claimActionPending"
|
||||
:show-waiting-owner-tip="showWaitingOwnerTip"
|
||||
@choose-ding-que="chooseDingQue"
|
||||
@confirm-discard="confirmDiscard"
|
||||
@toggle-ready-state="toggleReadyState"
|
||||
@draw-tile="drawTile"
|
||||
@start-game="startGame"
|
||||
@submit-self-gang="submitSelfGang"
|
||||
@submit-self-hu="submitSelfHu"
|
||||
@submit-claim="submitClaim"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="ws-sidebar">
|
||||
<div class="sidebar-head">
|
||||
<div>
|
||||
<p class="sidebar-title">WebSocket 消息</p>
|
||||
<small>{{ networkLabel }} · {{ loggedInUserName || '未登录昵称' }}</small>
|
||||
</div>
|
||||
<button class="sidebar-btn" type="button" @click="reconnectWs">重连</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-stats">
|
||||
<div class="sidebar-stat">
|
||||
<span>房间</span>
|
||||
<strong>{{ roomState.name || roomName || '未命名' }}</strong>
|
||||
</div>
|
||||
<div class="sidebar-stat">
|
||||
<span>阶段</span>
|
||||
<strong>{{ currentPhaseText }}</strong>
|
||||
</div>
|
||||
<div class="sidebar-stat">
|
||||
<span>人数</span>
|
||||
<strong>{{ roomState.playerCount }}/{{ roomState.maxPlayers }}</strong>
|
||||
</div>
|
||||
<div class="sidebar-stat">
|
||||
<span>状态</span>
|
||||
<strong>{{ roomStatusText }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="wsError" class="sidebar-error">{{ wsError }}</p>
|
||||
|
||||
<div class="sidebar-log">
|
||||
<p v-if="rightMessages.length === 0" class="sidebar-empty">等待服务器消息...</p>
|
||||
<p v-for="(line, index) in rightMessages" :key="index" class="sidebar-line">{{ line }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { setRoomMetaSnapshot } from '../store'
|
||||
import type { RoomPlayerState } from '../store/state'
|
||||
import type { StoredAuth } from '../types/session'
|
||||
import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
|
||||
@@ -32,6 +32,7 @@ const createRoomForm = ref({
|
||||
name: '',
|
||||
gameType: 'chengdu',
|
||||
maxPlayers: 4,
|
||||
totalRounds: 8,
|
||||
})
|
||||
|
||||
const quickJoinRoomId = ref('')
|
||||
@@ -136,6 +137,7 @@ function mapRoomPlayers(room: RoomItem): RoomPlayerState[] {
|
||||
(typeof item.PlayerName === 'string' && item.PlayerName) ||
|
||||
(item.player_id === currentUserId.value ? displayName.value : undefined),
|
||||
ready: Boolean(item.ready),
|
||||
trustee: false,
|
||||
hand: [],
|
||||
melds: [],
|
||||
outTiles: [],
|
||||
@@ -188,7 +190,7 @@ function connectGameWs(): void {
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
wsClient.connect(buildWsUrl(token), token)
|
||||
wsClient.connect(buildWsUrl(), token)
|
||||
}
|
||||
|
||||
async function refreshRooms(): Promise<void> {
|
||||
@@ -246,12 +248,13 @@ async function submitCreateRoom(): Promise<void> {
|
||||
name: createRoomForm.value.name.trim(),
|
||||
gameType: createRoomForm.value.gameType,
|
||||
maxPlayers: Number(createRoomForm.value.maxPlayers),
|
||||
totalRounds: Number(createRoomForm.value.totalRounds),
|
||||
},
|
||||
syncAuth,
|
||||
)
|
||||
|
||||
createdRoom.value = room
|
||||
setActiveRoom({
|
||||
setRoomMetaSnapshot({
|
||||
roomId: room.room_id,
|
||||
roomName: room.name,
|
||||
gameType: room.game_type,
|
||||
@@ -265,6 +268,7 @@ async function submitCreateRoom(): Promise<void> {
|
||||
})
|
||||
quickJoinRoomId.value = room.room_id
|
||||
createRoomForm.value.name = ''
|
||||
createRoomForm.value.totalRounds = 8
|
||||
showCreateModal.value = false
|
||||
showCreatedModal.value = true
|
||||
await refreshRooms()
|
||||
@@ -297,7 +301,7 @@ async function handleJoinRoom(room?: { roomId?: string; roomName?: string }): Pr
|
||||
roomSubmitting.value = true
|
||||
try {
|
||||
const joinedRoom = await joinRoom(session, { roomId: targetRoomId }, syncAuth)
|
||||
setActiveRoom({
|
||||
setRoomMetaSnapshot({
|
||||
roomId: joinedRoom.room_id,
|
||||
roomName: joinedRoom.name,
|
||||
gameType: joinedRoom.game_type,
|
||||
@@ -351,7 +355,7 @@ async function enterCreatedRoom(): Promise<void> {
|
||||
}
|
||||
|
||||
showCreatedModal.value = false
|
||||
setActiveRoom({
|
||||
setRoomMetaSnapshot({
|
||||
roomId: createdRoom.value.room_id,
|
||||
roomName: createdRoom.value.name,
|
||||
gameType: createdRoom.value.game_type,
|
||||
@@ -449,6 +453,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
<button
|
||||
class="primary-btn"
|
||||
:data-testid="`room-enter-${room.room_id}`"
|
||||
type="button"
|
||||
:disabled="roomSubmitting"
|
||||
@click="handleJoinRoom({ roomId: room.room_id, roomName: room.name })"
|
||||
@@ -459,7 +464,7 @@ onMounted(async () => {
|
||||
</ul>
|
||||
|
||||
<div class="room-actions-footer">
|
||||
<button class="primary-btn wide-btn" type="button" @click="openCreateModal">创建房间</button>
|
||||
<button class="primary-btn wide-btn" data-testid="open-create-room" type="button" @click="openCreateModal">创建房间</button>
|
||||
<button class="ghost-btn wide-btn" type="button" @click="logoutToLogin">退出大厅</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -471,8 +476,8 @@ onMounted(async () => {
|
||||
|
||||
<h3>快速加入</h3>
|
||||
<form class="join-line" @submit.prevent="handleJoinRoom()">
|
||||
<input v-model.trim="quickJoinRoomId" type="text" placeholder="输入 room_id" />
|
||||
<button class="primary-btn" type="submit" :disabled="roomSubmitting">加入</button>
|
||||
<input v-model.trim="quickJoinRoomId" data-testid="quick-join-room-id" type="text" placeholder="输入 room_id" />
|
||||
<button class="primary-btn" data-testid="quick-join-submit" type="submit" :disabled="roomSubmitting">加入</button>
|
||||
</form>
|
||||
</aside>
|
||||
</section>
|
||||
@@ -486,7 +491,7 @@ onMounted(async () => {
|
||||
<form class="form" @submit.prevent="submitCreateRoom">
|
||||
<label class="field">
|
||||
<span>房间名</span>
|
||||
<input v-model.trim="createRoomForm.name" type="text" maxlength="24" placeholder="例如:test001" />
|
||||
<input v-model.trim="createRoomForm.name" data-testid="create-room-name" type="text" maxlength="24" placeholder="例如:test001" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>玩法</span>
|
||||
@@ -498,14 +503,19 @@ onMounted(async () => {
|
||||
|
||||
<fieldset class="radio-group">
|
||||
<legend>人数</legend>
|
||||
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="2" /> 2人</label>
|
||||
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="3" /> 3人</label>
|
||||
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="4" /> 4人</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="radio-group">
|
||||
<legend>局数</legend>
|
||||
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="4" /> 4局</label>
|
||||
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="8" /> 8局</label>
|
||||
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="16" /> 16局</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="ghost-btn" type="button" @click="closeCreateModal">取消</button>
|
||||
<button class="primary-btn" type="submit" :disabled="roomSubmitting">
|
||||
<button class="primary-btn" data-testid="submit-create-room" type="submit" :disabled="roomSubmitting">
|
||||
{{ roomSubmitting ? '创建中...' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -528,7 +538,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="primary-btn" type="button" @click="enterCreatedRoom">进入房间</button>
|
||||
<button class="primary-btn" data-testid="enter-created-room" type="button" @click="enterCreatedRoom">进入房间</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -70,13 +70,13 @@ async function handleSubmit(): Promise<void> {
|
||||
<form class="form" @submit.prevent="handleSubmit">
|
||||
<label class="field">
|
||||
<span>登录ID</span>
|
||||
<input v-model.trim="form.loginId" type="text" placeholder="请输入手机号或账号" />
|
||||
<input v-model.trim="form.loginId" data-testid="login-id" type="text" placeholder="请输入手机号或账号" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>密码</span>
|
||||
<input v-model="form.password" type="password" placeholder="请输入密码" />
|
||||
<input v-model="form.password" data-testid="login-password" type="password" placeholder="请输入密码" />
|
||||
</label>
|
||||
<button class="primary-btn" type="submit" :disabled="submitting">
|
||||
<button class="primary-btn" data-testid="login-submit" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
433
src/views/chengdu/composables/useChengduGameActions.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import {computed} from 'vue'
|
||||
import {sendWsMessage} from '../../../ws/sender'
|
||||
import {formatTile} from './useChengduTableView'
|
||||
import type {ClaimOptionState} from '../../../types/state'
|
||||
import type {Tile} from '../../../types/tile'
|
||||
import type {DisplayPlayer} from '../types'
|
||||
|
||||
interface UseChengduGameActionsOptions {
|
||||
gameStore: {
|
||||
roomId: string
|
||||
phase: string
|
||||
currentTurn: number
|
||||
needDraw: boolean
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
pendingClaim?: {
|
||||
tile?: Tile
|
||||
options: ClaimOptionState[]
|
||||
}
|
||||
}
|
||||
roomMeta: { value: { roomId: string; ownerId: string } | null }
|
||||
gamePlayers: { value: DisplayPlayer[] }
|
||||
myPlayer: { value: DisplayPlayer | undefined }
|
||||
session: any
|
||||
}
|
||||
|
||||
export function useChengduGameActions(options: UseChengduGameActionsOptions) {
|
||||
const isLastRound = computed(
|
||||
() => options.gameStore.currentRound >= options.gameStore.totalRounds && options.gameStore.totalRounds > 0,
|
||||
)
|
||||
|
||||
const myReadyState = computed(() => Boolean(options.myPlayer.value?.isReady))
|
||||
|
||||
const isRoomOwner = computed(() => {
|
||||
const room = options.roomMeta.value
|
||||
return Boolean(
|
||||
room &&
|
||||
room.roomId === options.gameStore.roomId &&
|
||||
room.ownerId &&
|
||||
options.session.loggedInUserId.value &&
|
||||
room.ownerId === options.session.loggedInUserId.value,
|
||||
)
|
||||
})
|
||||
|
||||
const allPlayersReady = computed(
|
||||
() => options.gamePlayers.value.length === 4 && options.gamePlayers.value.every((player) => Boolean(player.isReady)),
|
||||
)
|
||||
|
||||
const hasRoundStarted = computed(() =>
|
||||
options.gamePlayers.value.some(
|
||||
(player) =>
|
||||
player.handCount > 0 ||
|
||||
player.handTiles.length > 0 ||
|
||||
player.melds.length > 0 ||
|
||||
player.discardTiles.length > 0,
|
||||
),
|
||||
)
|
||||
|
||||
const showStartGameButton = computed(
|
||||
() => options.gameStore.phase === 'waiting' && allPlayersReady.value && !hasRoundStarted.value,
|
||||
)
|
||||
const showWaitingOwnerTip = computed(() => showStartGameButton.value && !isRoomOwner.value)
|
||||
const canStartGame = computed(
|
||||
() => showStartGameButton.value && isRoomOwner.value && !options.session.startGamePending.value,
|
||||
)
|
||||
|
||||
const showReadyToggle = computed(() => {
|
||||
if (options.gameStore.phase !== 'waiting' || !options.gameStore.roomId || hasRoundStarted.value) {
|
||||
return false
|
||||
}
|
||||
if (showStartGameButton.value) {
|
||||
return !isRoomOwner.value
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const showDingQueChooser = computed(() => {
|
||||
const player = options.myPlayer.value
|
||||
if (!player || options.gameStore.phase === 'settlement') {
|
||||
return false
|
||||
}
|
||||
return player.handTiles.length > 0 && !player.missingSuit
|
||||
})
|
||||
|
||||
const selectedDiscardTile = computed(() => {
|
||||
const player = options.myPlayer.value
|
||||
if (!player || options.session.selectedDiscardTileId.value === null) {
|
||||
return null
|
||||
}
|
||||
return player.handTiles.find((tile) => tile.id === options.session.selectedDiscardTileId.value) ?? null
|
||||
})
|
||||
|
||||
const hasMissingSuitTiles = computed(() => {
|
||||
const player = options.myPlayer.value
|
||||
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
|
||||
if (!player || !missingSuit) {
|
||||
return false
|
||||
}
|
||||
return player.handTiles.some((tile) => tile.suit === missingSuit)
|
||||
})
|
||||
|
||||
function missingSuitLabel(value: string | null | undefined): string {
|
||||
const suitMap: Record<string, string> = {
|
||||
w: '万',
|
||||
t: '筒',
|
||||
b: '条',
|
||||
wan: '万',
|
||||
tong: '筒',
|
||||
tiao: '条',
|
||||
}
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
return suitMap[value.trim().toLowerCase()] ?? value
|
||||
}
|
||||
|
||||
const discardBlockedReason = computed(() => {
|
||||
const player = options.myPlayer.value
|
||||
if (!player || !options.gameStore.roomId) {
|
||||
return '未进入房间'
|
||||
}
|
||||
if (options.session.wsStatus.value !== 'connected') {
|
||||
return 'WebSocket 未连接'
|
||||
}
|
||||
if (showDingQueChooser.value) {
|
||||
return '请先完成定缺'
|
||||
}
|
||||
if (options.gameStore.phase !== 'playing') {
|
||||
return '当前不是出牌阶段'
|
||||
}
|
||||
if (player.seatIndex !== options.gameStore.currentTurn) {
|
||||
return '未轮到你出牌'
|
||||
}
|
||||
if (options.gameStore.needDraw) {
|
||||
return '请先摸牌'
|
||||
}
|
||||
if (options.gameStore.pendingClaim) {
|
||||
return '等待当前操作结算'
|
||||
}
|
||||
if (player.handTiles.length === 0) {
|
||||
return '当前没有可出的手牌'
|
||||
}
|
||||
if (options.session.discardPending.value) {
|
||||
return '正在提交出牌'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
function discardTileBlockedReason(tile: Tile): string {
|
||||
if (discardBlockedReason.value) {
|
||||
return discardBlockedReason.value
|
||||
}
|
||||
|
||||
const player = options.myPlayer.value
|
||||
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
|
||||
if (player && missingSuit && hasMissingSuitTiles.value && tile.suit !== missingSuit) {
|
||||
return `当前必须先打${missingSuitLabel(missingSuit)}牌`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const canConfirmDiscard = computed(() => {
|
||||
const tile = selectedDiscardTile.value
|
||||
return Boolean(tile && !discardTileBlockedReason(tile))
|
||||
})
|
||||
|
||||
const confirmDiscardLabel = computed(() => {
|
||||
const tile = selectedDiscardTile.value
|
||||
return tile ? `出牌 ${formatTile(tile)}` : '出牌'
|
||||
})
|
||||
|
||||
const canDrawTile = computed(() => {
|
||||
const player = options.myPlayer.value
|
||||
if (!player || !options.gameStore.roomId) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
options.gameStore.phase === 'playing' &&
|
||||
options.gameStore.needDraw &&
|
||||
player.seatIndex === options.gameStore.currentTurn
|
||||
)
|
||||
})
|
||||
|
||||
const myClaimState = computed(() => options.gameStore.pendingClaim)
|
||||
const visibleClaimOptions = computed<ClaimOptionState[]>(() => {
|
||||
const current = myClaimState.value?.options ?? []
|
||||
const order: ClaimOptionState[] = ['hu', 'gang', 'peng', 'pass']
|
||||
return order.filter((option) => current.includes(option))
|
||||
})
|
||||
const showClaimActions = computed(() => visibleClaimOptions.value.length > 0)
|
||||
|
||||
const canSelfHu = computed(() => {
|
||||
const player = options.myPlayer.value
|
||||
if (!player || !options.gameStore.roomId || options.session.wsStatus.value !== 'connected') {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
showDingQueChooser.value ||
|
||||
options.gameStore.phase !== 'playing' ||
|
||||
options.gameStore.needDraw ||
|
||||
options.gameStore.pendingClaim
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (player.seatIndex !== options.gameStore.currentTurn || options.session.turnActionPending.value) {
|
||||
return false
|
||||
}
|
||||
return options.session.selfTurnAllowActions.value.includes('hu')
|
||||
})
|
||||
|
||||
const canSelfGang = computed(() => {
|
||||
const player = options.myPlayer.value
|
||||
if (!player || !options.gameStore.roomId || options.session.wsStatus.value !== 'connected') {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
showDingQueChooser.value ||
|
||||
options.gameStore.phase !== 'playing' ||
|
||||
options.gameStore.needDraw ||
|
||||
options.gameStore.pendingClaim
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (player.seatIndex !== options.gameStore.currentTurn || options.session.turnActionPending.value) {
|
||||
return false
|
||||
}
|
||||
return options.session.selfTurnAllowActions.value.includes('gang')
|
||||
})
|
||||
|
||||
function toggleReadyState(): void {
|
||||
if (options.session.readyTogglePending.value) {
|
||||
return
|
||||
}
|
||||
const nextReady = !myReadyState.value
|
||||
options.session.readyTogglePending.value = true
|
||||
sendWsMessage({
|
||||
type: 'set_ready',
|
||||
roomId: options.gameStore.roomId,
|
||||
payload: {
|
||||
ready: nextReady,
|
||||
isReady: nextReady,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function startGame(): void {
|
||||
if (!canStartGame.value) {
|
||||
return
|
||||
}
|
||||
options.session.startGamePending.value = true
|
||||
sendWsMessage({
|
||||
type: 'start_game',
|
||||
roomId: options.gameStore.roomId,
|
||||
payload: {
|
||||
room_id: options.gameStore.roomId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function nextRound(): void {
|
||||
if (
|
||||
options.session.nextRoundPending.value ||
|
||||
!options.gameStore.roomId ||
|
||||
options.gameStore.phase !== 'settlement'
|
||||
) {
|
||||
return
|
||||
}
|
||||
options.session.settlementOverlayDismissed.value = true
|
||||
options.session.nextRoundPending.value = true
|
||||
sendWsMessage({
|
||||
type: 'next_round',
|
||||
roomId: options.gameStore.roomId,
|
||||
payload: {},
|
||||
})
|
||||
}
|
||||
|
||||
function chooseDingQue(suit: Tile['suit']): void {
|
||||
if (options.session.dingQuePending.value || !showDingQueChooser.value) {
|
||||
return
|
||||
}
|
||||
options.session.dingQuePending.value = true
|
||||
sendWsMessage({
|
||||
type: 'ding_que',
|
||||
roomId: options.gameStore.roomId,
|
||||
payload: {suit},
|
||||
})
|
||||
}
|
||||
|
||||
function selectDiscardTile(tile: Tile): void {
|
||||
const blockedReason = discardTileBlockedReason(tile)
|
||||
if (blockedReason) {
|
||||
options.session.wsError.value = blockedReason
|
||||
options.session.wsMessages.value.push(`[client-blocked] select ${formatTile(tile)}: ${blockedReason}`)
|
||||
options.session.selectedDiscardTileId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
options.session.wsError.value = ''
|
||||
options.session.selectedDiscardTileId.value =
|
||||
options.session.selectedDiscardTileId.value === tile.id ? null : tile.id
|
||||
}
|
||||
|
||||
function confirmDiscard(): void {
|
||||
const tile = selectedDiscardTile.value
|
||||
if (!tile) {
|
||||
return
|
||||
}
|
||||
|
||||
const blockedReason = discardTileBlockedReason(tile)
|
||||
if (blockedReason || !options.gameStore.roomId) {
|
||||
if (blockedReason) {
|
||||
options.session.wsError.value = blockedReason
|
||||
options.session.wsMessages.value.push(`[client-blocked] discard ${formatTile(tile)}: ${blockedReason}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
options.session.wsError.value = ''
|
||||
options.session.markDiscardPendingWithFallback()
|
||||
sendWsMessage({
|
||||
type: 'discard',
|
||||
roomId: options.gameStore.roomId,
|
||||
payload: {
|
||||
tile: {
|
||||
id: tile.id,
|
||||
suit: tile.suit,
|
||||
value: tile.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function drawTile(): void {
|
||||
if (!canDrawTile.value) {
|
||||
return
|
||||
}
|
||||
sendWsMessage({
|
||||
type: 'draw',
|
||||
roomId: options.gameStore.roomId,
|
||||
payload: {
|
||||
room_id: options.gameStore.roomId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function submitSelfGang(): void {
|
||||
if (!options.gameStore.roomId || !canSelfGang.value || options.session.turnActionPending.value) {
|
||||
return
|
||||
}
|
||||
options.session.markTurnActionPending('gang')
|
||||
sendWsMessage({
|
||||
type: 'gang',
|
||||
roomId: options.gameStore.roomId,
|
||||
payload: {
|
||||
room_id: options.gameStore.roomId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function submitSelfHu(): void {
|
||||
if (!options.gameStore.roomId || !canSelfHu.value || options.session.turnActionPending.value) {
|
||||
return
|
||||
}
|
||||
options.session.markTurnActionPending('hu')
|
||||
sendWsMessage({
|
||||
type: 'hu',
|
||||
roomId: options.gameStore.roomId,
|
||||
payload: {
|
||||
room_id: options.gameStore.roomId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function submitClaim(action: ClaimOptionState): void {
|
||||
if (
|
||||
options.session.claimActionPending.value ||
|
||||
!options.gameStore.roomId ||
|
||||
!visibleClaimOptions.value.includes(action)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const claimTile = options.gameStore.pendingClaim?.tile
|
||||
options.session.claimActionPending.value = true
|
||||
sendWsMessage({
|
||||
type: action,
|
||||
roomId: options.gameStore.roomId,
|
||||
payload: {
|
||||
room_id: options.gameStore.roomId,
|
||||
...(action !== 'pass' && claimTile
|
||||
? {
|
||||
tile: {
|
||||
id: claimTile.id,
|
||||
suit: claimTile.suit,
|
||||
value: claimTile.value,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isLastRound,
|
||||
myReadyState,
|
||||
isRoomOwner,
|
||||
showStartGameButton,
|
||||
showWaitingOwnerTip,
|
||||
canStartGame,
|
||||
showReadyToggle,
|
||||
showDingQueChooser,
|
||||
selectedDiscardTile,
|
||||
discardBlockedReason,
|
||||
discardTileBlockedReason,
|
||||
canConfirmDiscard,
|
||||
confirmDiscardLabel,
|
||||
canDrawTile,
|
||||
visibleClaimOptions,
|
||||
showClaimActions,
|
||||
canSelfHu,
|
||||
canSelfGang,
|
||||
toggleReadyState,
|
||||
startGame,
|
||||
nextRound,
|
||||
chooseDingQue,
|
||||
selectDiscardTile,
|
||||
confirmDiscard,
|
||||
drawTile,
|
||||
submitSelfGang,
|
||||
submitSelfHu,
|
||||
submitClaim,
|
||||
}
|
||||
}
|
||||
459
src/views/chengdu/composables/useChengduGameSession.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||
import { refreshAccessToken } from '../../../api/auth'
|
||||
import { AuthExpiredError, type AuthSession } from '../../../api/authed-request'
|
||||
import { getUserInfo } from '../../../api/user'
|
||||
import type { RoomMetaSnapshotState } from '../../../store/state'
|
||||
import { clearAuth, readStoredAuth, writeStoredAuth } 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 type { PlayerActionTimer } from '../types'
|
||||
|
||||
interface UseChengduGameSessionOptions {
|
||||
route: RouteLocationNormalizedLoaded
|
||||
router: Router
|
||||
gameStore: {
|
||||
roomId: string
|
||||
}
|
||||
roomMeta: { value: RoomMetaSnapshotState | null }
|
||||
}
|
||||
|
||||
export function useChengduGameSession(options: UseChengduGameSessionOptions) {
|
||||
const auth = ref(readStoredAuth())
|
||||
const now = ref(Date.now())
|
||||
const wsStatus = ref<WsStatus>('idle')
|
||||
const wsMessages = ref<string[]>([])
|
||||
const wsError = ref('')
|
||||
const roomCountdown = ref<PlayerActionTimer | null>(null)
|
||||
|
||||
const leaveRoomPending = ref(false)
|
||||
const readyTogglePending = ref(false)
|
||||
const startGamePending = ref(false)
|
||||
const dingQuePending = ref(false)
|
||||
const discardPending = ref(false)
|
||||
const claimActionPending = ref(false)
|
||||
const turnActionPending = ref(false)
|
||||
const nextRoundPending = ref(false)
|
||||
const settlementOverlayDismissed = ref(false)
|
||||
const settlementDeadlineMs = ref<number | null>(null)
|
||||
const selectedDiscardTileId = ref<number | null>(null)
|
||||
const selfTurnAllowActions = ref<string[]>([])
|
||||
|
||||
const menuOpen = ref(false)
|
||||
const isTrustMode = ref(false)
|
||||
const menuTriggerActive = ref(false)
|
||||
|
||||
let clockTimer: number | null = null
|
||||
let discardPendingTimer: number | null = null
|
||||
let turnActionPendingTimer: number | null = null
|
||||
let menuTriggerTimer: number | null = null
|
||||
let menuOpenTimer: number | null = null
|
||||
let refreshingWsToken = false
|
||||
let lastForcedRefreshAt = 0
|
||||
|
||||
const loggedInUserId = computed(() => {
|
||||
const source = auth.value?.user as Record<string, unknown> | undefined
|
||||
const rawId = source?.id ?? source?.userID ?? source?.user_id
|
||||
|
||||
if (typeof rawId === 'string' && rawId.trim()) {
|
||||
return rawId
|
||||
}
|
||||
if (typeof rawId === 'number') {
|
||||
return String(rawId)
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const loggedInUserName = computed(() => auth.value?.user?.nickname || auth.value?.user?.username || '')
|
||||
|
||||
const localCachedAvatarUrl = computed(() => {
|
||||
const source = auth.value?.user as Record<string, unknown> | undefined
|
||||
if (!source) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const avatarCandidates = [
|
||||
source.avatar,
|
||||
source.avatar_url,
|
||||
source.avatarUrl,
|
||||
source.head_img,
|
||||
source.headImg,
|
||||
source.profile_image,
|
||||
source.profileImage,
|
||||
]
|
||||
|
||||
for (const candidate of avatarCandidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim()) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const networkLabel = computed(() => {
|
||||
const map: Record<WsStatus, string> = {
|
||||
connected: '已连接',
|
||||
connecting: '连接中',
|
||||
error: '连接异常',
|
||||
idle: '未连接',
|
||||
closed: '未连接',
|
||||
}
|
||||
|
||||
return map[wsStatus.value] ?? '未连接'
|
||||
})
|
||||
|
||||
const formattedClock = computed(() =>
|
||||
new Date(now.value).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}),
|
||||
)
|
||||
|
||||
function toggleMenu(): void {
|
||||
menuTriggerActive.value = true
|
||||
if (menuTriggerTimer !== null) {
|
||||
window.clearTimeout(menuTriggerTimer)
|
||||
}
|
||||
menuTriggerTimer = window.setTimeout(() => {
|
||||
menuTriggerActive.value = false
|
||||
menuTriggerTimer = null
|
||||
}, 180)
|
||||
|
||||
if (menuOpen.value) {
|
||||
menuOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (menuOpenTimer !== null) {
|
||||
window.clearTimeout(menuOpenTimer)
|
||||
}
|
||||
menuOpenTimer = window.setTimeout(() => {
|
||||
menuOpen.value = true
|
||||
menuOpenTimer = null
|
||||
}, 85)
|
||||
}
|
||||
|
||||
function toggleTrustMode(): void {
|
||||
isTrustMode.value = !isTrustMode.value
|
||||
menuOpen.value = false
|
||||
}
|
||||
|
||||
function clearTurnActionPending(): void {
|
||||
turnActionPending.value = false
|
||||
if (turnActionPendingTimer !== null) {
|
||||
window.clearTimeout(turnActionPendingTimer)
|
||||
turnActionPendingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function markTurnActionPending(kind: 'gang' | 'hu'): void {
|
||||
clearTurnActionPending()
|
||||
turnActionPending.value = true
|
||||
turnActionPendingTimer = window.setTimeout(() => {
|
||||
turnActionPending.value = false
|
||||
turnActionPendingTimer = null
|
||||
wsError.value = `${kind === 'gang' ? '杠牌' : '胡牌'}未收到服务器确认`
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
function clearDiscardPendingTimer(): void {
|
||||
if (discardPendingTimer !== null) {
|
||||
window.clearTimeout(discardPendingTimer)
|
||||
discardPendingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function markDiscardCompleted(): void {
|
||||
clearDiscardPendingTimer()
|
||||
discardPending.value = false
|
||||
selectedDiscardTileId.value = null
|
||||
}
|
||||
|
||||
function markDiscardPendingWithFallback(): void {
|
||||
clearDiscardPendingTimer()
|
||||
discardPending.value = true
|
||||
discardPendingTimer = window.setTimeout(() => {
|
||||
discardPending.value = false
|
||||
selectedDiscardTileId.value = null
|
||||
discardPendingTimer = null
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function logoutToLogin(): void {
|
||||
clearAuth()
|
||||
auth.value = null
|
||||
wsClient.close()
|
||||
void options.router.replace('/login')
|
||||
}
|
||||
|
||||
function currentSession(): AuthSession | null {
|
||||
const current = auth.value
|
||||
if (!current?.token) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
token: current.token,
|
||||
tokenType: current.tokenType,
|
||||
refreshToken: current.refreshToken,
|
||||
expiresIn: current.expiresIn,
|
||||
}
|
||||
}
|
||||
|
||||
function syncAuthSession(next: AuthSession): void {
|
||||
if (!auth.value) {
|
||||
return
|
||||
}
|
||||
|
||||
auth.value = {
|
||||
...auth.value,
|
||||
token: next.token,
|
||||
tokenType: next.tokenType ?? auth.value.tokenType,
|
||||
refreshToken: next.refreshToken ?? auth.value.refreshToken,
|
||||
expiresIn: next.expiresIn,
|
||||
}
|
||||
writeStoredAuth(auth.value)
|
||||
}
|
||||
|
||||
function syncCurrentUserID(userID: string): void {
|
||||
if (!userID || loggedInUserId.value || !auth.value) {
|
||||
return
|
||||
}
|
||||
|
||||
auth.value = {
|
||||
...auth.value,
|
||||
user: {
|
||||
...(auth.value.user ?? {}),
|
||||
id: userID,
|
||||
},
|
||||
}
|
||||
writeStoredAuth(auth.value)
|
||||
}
|
||||
|
||||
async function ensureCurrentUserLoaded(): Promise<void> {
|
||||
if (loggedInUserId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentAuth = auth.value
|
||||
const session = currentSession()
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const userInfo = await getUserInfo(session, syncAuthSession)
|
||||
const resolvedId = userInfo.userID ?? userInfo.user_id ?? userInfo.id ?? currentAuth?.user?.id
|
||||
const nextUser = {
|
||||
...(currentAuth?.user ?? {}),
|
||||
...userInfo,
|
||||
id: typeof resolvedId === 'string' || typeof resolvedId === 'number' ? resolvedId : undefined,
|
||||
}
|
||||
|
||||
if (!currentAuth) {
|
||||
return
|
||||
}
|
||||
|
||||
auth.value = {
|
||||
...currentAuth,
|
||||
user: nextUser,
|
||||
}
|
||||
writeStoredAuth(auth.value)
|
||||
} catch (error) {
|
||||
if (error instanceof AuthExpiredError) {
|
||||
logoutToLogin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decodeJwtExpMs(token: string): number | null {
|
||||
const payloadPart = token.split('.')[1]
|
||||
if (!payloadPart) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = payloadPart.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
const payload = JSON.parse(window.atob(padded)) as { exp?: number }
|
||||
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRefreshWsToken(token: string): boolean {
|
||||
const expMs = decodeJwtExpMs(token)
|
||||
return Boolean(expMs && expMs <= Date.now() + 30_000)
|
||||
}
|
||||
|
||||
async function resolveWsToken(forceRefresh = false, logoutOnRefreshFail = false): Promise<string | null> {
|
||||
const current = auth.value
|
||||
if (!current?.token) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!forceRefresh && !shouldRefreshWsToken(current.token)) {
|
||||
return current.token
|
||||
}
|
||||
|
||||
if (!current.refreshToken || refreshingWsToken) {
|
||||
return current.token
|
||||
}
|
||||
|
||||
refreshingWsToken = true
|
||||
try {
|
||||
const refreshed = await refreshAccessToken({
|
||||
token: current.token,
|
||||
tokenType: current.tokenType,
|
||||
refreshToken: current.refreshToken,
|
||||
})
|
||||
|
||||
const nextAuth = {
|
||||
...current,
|
||||
token: refreshed.token,
|
||||
tokenType: refreshed.tokenType ?? current.tokenType,
|
||||
refreshToken: refreshed.refreshToken ?? current.refreshToken,
|
||||
expiresIn: refreshed.expiresIn,
|
||||
}
|
||||
auth.value = nextAuth
|
||||
writeStoredAuth(nextAuth)
|
||||
return nextAuth.token
|
||||
} catch {
|
||||
if (logoutOnRefreshFail) {
|
||||
logoutToLogin()
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
refreshingWsToken = false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWsConnected(forceRefresh = false): Promise<void> {
|
||||
const token = await resolveWsToken(forceRefresh, false)
|
||||
if (!token) {
|
||||
wsError.value = '未找到登录凭证,无法建立连接'
|
||||
return
|
||||
}
|
||||
|
||||
wsError.value = ''
|
||||
wsClient.connect(buildWsUrl(), token)
|
||||
}
|
||||
|
||||
function backHall(): void {
|
||||
leaveRoomPending.value = true
|
||||
const roomId = options.gameStore.roomId
|
||||
sendWsMessage({
|
||||
type: 'leave_room',
|
||||
roomId,
|
||||
payload: {
|
||||
room_id: roomId,
|
||||
},
|
||||
})
|
||||
wsClient.close()
|
||||
void options.router.push('/hall').finally(() => {
|
||||
leaveRoomPending.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function handleGlobalClick(event: MouseEvent): void {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target || target.closest('.menu-trigger-wrap')) {
|
||||
return
|
||||
}
|
||||
menuOpen.value = false
|
||||
}
|
||||
|
||||
function handleGlobalEsc(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
menuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
clockTimer = window.setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
|
||||
window.addEventListener('click', handleGlobalClick)
|
||||
window.addEventListener('keydown', handleGlobalEsc)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (clockTimer !== null) {
|
||||
window.clearInterval(clockTimer)
|
||||
clockTimer = null
|
||||
}
|
||||
clearDiscardPendingTimer()
|
||||
clearTurnActionPending()
|
||||
|
||||
window.removeEventListener('click', handleGlobalClick)
|
||||
window.removeEventListener('keydown', handleGlobalEsc)
|
||||
|
||||
if (menuTriggerTimer !== null) {
|
||||
window.clearTimeout(menuTriggerTimer)
|
||||
menuTriggerTimer = null
|
||||
}
|
||||
if (menuOpenTimer !== null) {
|
||||
window.clearTimeout(menuOpenTimer)
|
||||
menuOpenTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
auth,
|
||||
now,
|
||||
wsStatus,
|
||||
wsMessages,
|
||||
wsError,
|
||||
roomCountdown,
|
||||
leaveRoomPending,
|
||||
readyTogglePending,
|
||||
startGamePending,
|
||||
dingQuePending,
|
||||
discardPending,
|
||||
claimActionPending,
|
||||
turnActionPending,
|
||||
nextRoundPending,
|
||||
settlementOverlayDismissed,
|
||||
settlementDeadlineMs,
|
||||
selectedDiscardTileId,
|
||||
selfTurnAllowActions,
|
||||
menuOpen,
|
||||
isTrustMode,
|
||||
menuTriggerActive,
|
||||
loggedInUserId,
|
||||
loggedInUserName,
|
||||
localCachedAvatarUrl,
|
||||
networkLabel,
|
||||
formattedClock,
|
||||
toggleMenu,
|
||||
toggleTrustMode,
|
||||
clearTurnActionPending,
|
||||
markTurnActionPending,
|
||||
clearDiscardPendingTimer,
|
||||
markDiscardCompleted,
|
||||
markDiscardPendingWithFallback,
|
||||
logoutToLogin,
|
||||
currentSession,
|
||||
syncAuthSession,
|
||||
syncCurrentUserID,
|
||||
ensureCurrentUserLoaded,
|
||||
resolveWsToken,
|
||||
ensureWsConnected,
|
||||
backHall,
|
||||
lastForcedRefreshAtRef: {
|
||||
get value() {
|
||||
return lastForcedRefreshAt
|
||||
},
|
||||
set value(next: number) {
|
||||
lastForcedRefreshAt = next
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
143
src/views/chengdu/composables/useChengduGameSocket.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { computed, onBeforeUnmount, onMounted, type ComputedRef } from 'vue'
|
||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||
import type { RoomMetaSnapshotState } from '../../../store/state'
|
||||
import type { PlayerState, Tile } from '../../../types/state'
|
||||
import { wsClient, type WsStatus } from '../../../ws/client'
|
||||
import { buildWsUrl } from '../../../ws/url'
|
||||
import type { DisplayPlayer } from '../types'
|
||||
import { useChengduGameSession } from './useChengduGameSession'
|
||||
import { createChengduMessageHandlers } from '../socket/createMessageHandlers'
|
||||
|
||||
interface UseChengduGameSocketOptions {
|
||||
route: RouteLocationNormalizedLoaded
|
||||
router: Router
|
||||
gameStore: {
|
||||
roomId: string
|
||||
phase: string
|
||||
players: Record<string, PlayerState>
|
||||
dealerIndex: number
|
||||
currentTurn: number
|
||||
remainingTiles: number
|
||||
needDraw: boolean
|
||||
pendingClaim?: any
|
||||
scores: Record<string, number>
|
||||
winners: string[]
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
resetGame: () => void
|
||||
}
|
||||
roomMeta: { value: RoomMetaSnapshotState | null }
|
||||
roomName: ComputedRef<string>
|
||||
myHandTiles: ComputedRef<Tile[]>
|
||||
myPlayer: ComputedRef<DisplayPlayer | undefined>
|
||||
session: ReturnType<typeof useChengduGameSession>
|
||||
}
|
||||
|
||||
export function useChengduGameSocket(options: UseChengduGameSocketOptions) {
|
||||
let unsubscribe: (() => void) | null = null
|
||||
let needsInitialRoomInfo = false
|
||||
|
||||
const showSettlementOverlay = computed(
|
||||
() => options.gameStore.phase === 'settlement' && !options.session.settlementOverlayDismissed.value,
|
||||
)
|
||||
|
||||
const settlementCountdown = computed(() => {
|
||||
if (!showSettlementOverlay.value || !options.session.settlementDeadlineMs.value) {
|
||||
return null
|
||||
}
|
||||
return Math.max(
|
||||
0,
|
||||
Math.ceil((options.session.settlementDeadlineMs.value - options.session.now.value) / 1000),
|
||||
)
|
||||
})
|
||||
|
||||
const handlers = createChengduMessageHandlers({
|
||||
router: options.router,
|
||||
gameStore: options.gameStore,
|
||||
roomMeta: options.roomMeta,
|
||||
roomName: options.roomName,
|
||||
myHandTiles: options.myHandTiles,
|
||||
myPlayer: options.myPlayer,
|
||||
session: options.session,
|
||||
})
|
||||
|
||||
function requestRoomInfo(): void {
|
||||
const routeRoomId = typeof options.route.params.roomId === 'string' ? options.route.params.roomId : ''
|
||||
const roomId = routeRoomId || options.gameStore.roomId || options.roomMeta.value?.roomId || ''
|
||||
if (!roomId || options.session.wsStatus.value !== 'connected') {
|
||||
return
|
||||
}
|
||||
|
||||
needsInitialRoomInfo = false
|
||||
options.session.wsMessages.value.push(`[client] get_room_info ${roomId}`)
|
||||
wsClient.send({
|
||||
type: 'get_room_info',
|
||||
roomId,
|
||||
payload: {
|
||||
room_id: roomId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleSocketError(message: string): void {
|
||||
options.session.markDiscardCompleted()
|
||||
options.session.clearTurnActionPending()
|
||||
options.session.wsError.value = message
|
||||
options.session.wsMessages.value.push(`[error] ${message}`)
|
||||
|
||||
const nowMs = Date.now()
|
||||
if (nowMs - options.session.lastForcedRefreshAtRef.value > 5000) {
|
||||
options.session.lastForcedRefreshAtRef.value = nowMs
|
||||
void options.session
|
||||
.resolveWsToken(true, true)
|
||||
.then((refreshedToken) => {
|
||||
if (!refreshedToken) {
|
||||
return
|
||||
}
|
||||
options.session.wsError.value = ''
|
||||
wsClient.reconnect(buildWsUrl(), refreshedToken)
|
||||
})
|
||||
.catch(() => {
|
||||
options.session.logoutToLogin()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const routeRoomId = typeof options.route.params.roomId === 'string' ? options.route.params.roomId : ''
|
||||
needsInitialRoomInfo = true
|
||||
void options.session.ensureCurrentUserLoaded().finally(() => {
|
||||
handlers.hydrateFromActiveRoom(routeRoomId)
|
||||
if (routeRoomId) {
|
||||
options.gameStore.roomId = routeRoomId
|
||||
}
|
||||
if (options.session.wsStatus.value === 'connected' && needsInitialRoomInfo) {
|
||||
requestRoomInfo()
|
||||
}
|
||||
})
|
||||
|
||||
const statusHandler = (status: WsStatus) => {
|
||||
options.session.wsStatus.value = status
|
||||
if (status === 'connected' && needsInitialRoomInfo) {
|
||||
requestRoomInfo()
|
||||
}
|
||||
}
|
||||
|
||||
wsClient.onMessage(handlers.handleSocketMessage)
|
||||
wsClient.onError(handleSocketError)
|
||||
unsubscribe = wsClient.onStatusChange(statusHandler)
|
||||
void options.session.ensureWsConnected()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
unsubscribe = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
showSettlementOverlay,
|
||||
settlementCountdown,
|
||||
}
|
||||
}
|
||||
431
src/views/chengdu/composables/useChengduTableView.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { computed } from 'vue'
|
||||
import eastWind from '../../../assets/images/direction/dong.png'
|
||||
import southWind from '../../../assets/images/direction/nan.png'
|
||||
import westWind from '../../../assets/images/direction/xi.png'
|
||||
import northWind from '../../../assets/images/direction/bei.png'
|
||||
import type { SeatPlayerCardModel } from '../../../components/game/seat-player-card'
|
||||
import { getTileImage as getBottomTileImage } from '../../../config/bottomTileMap'
|
||||
import { getTileImage as getLeftTileImage } from '../../../config/leftTileMap'
|
||||
import { getTileImage as getRightTileImage } from '../../../config/rightTileMap'
|
||||
import { getTileImage as getTopTileImage } from '../../../config/topTileMap'
|
||||
import type { SeatKey } from '../../../game/seat'
|
||||
import type { Tile } from '../../../types/tile'
|
||||
import type {
|
||||
DeskSeatState,
|
||||
DisplayPlayer,
|
||||
HandSuitLabel,
|
||||
TableTileImageType,
|
||||
TableViewDeps,
|
||||
TableViewResult,
|
||||
WallSeatState,
|
||||
WallTileItem,
|
||||
} from '../types'
|
||||
|
||||
const handSuitOrder: Record<Tile['suit'], number> = { W: 0, T: 1, B: 2 }
|
||||
const handSuitLabelMap: Record<Tile['suit'], HandSuitLabel> = { W: '万', T: '筒', B: '条' }
|
||||
|
||||
function buildWallTileImage(
|
||||
seat: SeatKey,
|
||||
tile: Tile | undefined,
|
||||
imageType: TableTileImageType,
|
||||
): string {
|
||||
switch (seat) {
|
||||
case 'top':
|
||||
return getTopTileImage(tile, imageType, 'top')
|
||||
case 'right':
|
||||
return getRightTileImage(tile, imageType, 'right')
|
||||
case 'left':
|
||||
return getLeftTileImage(tile, imageType, 'left')
|
||||
case 'bottom':
|
||||
default:
|
||||
return tile ? getBottomTileImage(tile, imageType, 'bottom') : ''
|
||||
}
|
||||
}
|
||||
|
||||
export function missingSuitLabel(value: string | null | undefined): string {
|
||||
const suitMap: Record<string, string> = {
|
||||
w: '万',
|
||||
t: '筒',
|
||||
b: '条',
|
||||
wan: '万',
|
||||
tong: '筒',
|
||||
tiao: '条',
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase()
|
||||
return suitMap[normalized] ?? value
|
||||
}
|
||||
|
||||
export function formatTile(tile: Tile): string {
|
||||
return `${tile.suit}${tile.value}`
|
||||
}
|
||||
|
||||
function emptyWallSeat(): WallSeatState {
|
||||
return { tiles: [] }
|
||||
}
|
||||
|
||||
function emptyDeskSeat(): DeskSeatState {
|
||||
return { tiles: [], hasHu: false }
|
||||
}
|
||||
|
||||
export function useChengduTableView(deps: TableViewDeps): TableViewResult {
|
||||
const visibleHandTileGroups = computed(() => {
|
||||
const grouped = new Map<HandSuitLabel, Tile[]>()
|
||||
|
||||
deps.myHandTiles.value
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const suitDiff = handSuitOrder[left.suit] - handSuitOrder[right.suit]
|
||||
if (suitDiff !== 0) {
|
||||
return suitDiff
|
||||
}
|
||||
|
||||
const valueDiff = left.value - right.value
|
||||
if (valueDiff !== 0) {
|
||||
return valueDiff
|
||||
}
|
||||
|
||||
return left.id - right.id
|
||||
})
|
||||
.forEach((tile) => {
|
||||
const label = handSuitLabelMap[tile.suit]
|
||||
const current = grouped.get(label) ?? []
|
||||
current.push(tile)
|
||||
grouped.set(label, current)
|
||||
})
|
||||
|
||||
return (['万', '筒', '条'] as HandSuitLabel[])
|
||||
.map((suit) => ({
|
||||
suit,
|
||||
tiles: grouped.get(suit) ?? [],
|
||||
}))
|
||||
.filter((group) => group.tiles.length > 0)
|
||||
})
|
||||
|
||||
const sortedVisibleHandTiles = computed(() => visibleHandTileGroups.value.flatMap((group) => group.tiles))
|
||||
|
||||
const roomName = computed(() => {
|
||||
const activeRoomName =
|
||||
deps.roomMeta.value && deps.roomMeta.value.roomId === deps.gameStore.roomId
|
||||
? deps.roomMeta.value.roomName
|
||||
: ''
|
||||
return deps.routeRoomName.value || activeRoomName || `房间 ${deps.gameStore.roomId || '--'}`
|
||||
})
|
||||
|
||||
const roomState = computed(() => {
|
||||
const status =
|
||||
deps.gameStore.phase === 'waiting'
|
||||
? 'waiting'
|
||||
: deps.gameStore.phase === 'settlement'
|
||||
? 'finished'
|
||||
: 'playing'
|
||||
const wall = Array.from({ length: deps.gameStore.remainingTiles }, (_, index) => `wall-${index}`)
|
||||
const maxPlayers =
|
||||
deps.roomMeta.value && deps.roomMeta.value.roomId === deps.gameStore.roomId
|
||||
? deps.roomMeta.value.maxPlayers
|
||||
: 4
|
||||
|
||||
return {
|
||||
roomId: deps.gameStore.roomId,
|
||||
name: roomName.value,
|
||||
playerCount: deps.gamePlayers.value.length,
|
||||
maxPlayers,
|
||||
status,
|
||||
game: {
|
||||
state: {
|
||||
wall,
|
||||
dealerIndex: deps.gameStore.dealerIndex,
|
||||
currentTurn: deps.gameStore.currentTurn,
|
||||
phase: deps.gameStore.phase,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const seatViews = computed(() => {
|
||||
const players = deps.gamePlayers.value
|
||||
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
|
||||
const selfSeatIndex =
|
||||
deps.myPlayer.value?.seatIndex ??
|
||||
players.find((player) => player.playerId === deps.loggedInUserId.value)?.seatIndex ??
|
||||
0
|
||||
|
||||
return players.slice(0, 4).map((player) => {
|
||||
const relativeIndex = (selfSeatIndex - player.seatIndex + 4) % 4
|
||||
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
||||
return {
|
||||
key: seatKey,
|
||||
player,
|
||||
isSelf: player.playerId === deps.loggedInUserId.value,
|
||||
isTurn: player.seatIndex === deps.gameStore.currentTurn,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const seatWinds = computed<Record<SeatKey, string>>(() => {
|
||||
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
|
||||
const players = deps.gamePlayers.value
|
||||
const selfSeatIndex =
|
||||
deps.myPlayer.value?.seatIndex ??
|
||||
players.find((player) => player.playerId === deps.loggedInUserId.value)?.seatIndex ??
|
||||
0
|
||||
|
||||
const directionBySeatIndex = [eastWind, southWind, westWind, northWind]
|
||||
const result: Record<SeatKey, string> = {
|
||||
top: northWind,
|
||||
right: eastWind,
|
||||
bottom: southWind,
|
||||
left: westWind,
|
||||
}
|
||||
|
||||
for (let absoluteSeat = 0; absoluteSeat < 4; absoluteSeat += 1) {
|
||||
const relativeIndex = (selfSeatIndex - absoluteSeat + 4) % 4
|
||||
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
||||
result[seatKey] = directionBySeatIndex[absoluteSeat] ?? northWind
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const currentTurnSeat = computed<SeatKey | ''>(() => seatViews.value.find((seat) => seat.isTurn)?.key ?? '')
|
||||
|
||||
const currentPhaseText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
waiting: '等待中',
|
||||
dealing: '发牌中',
|
||||
playing: '对局中',
|
||||
action: '操作中',
|
||||
settlement: '已结算',
|
||||
}
|
||||
return map[deps.gameStore.phase] ?? deps.gameStore.phase
|
||||
})
|
||||
|
||||
const roomStatusText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
waiting: '等待玩家',
|
||||
playing: '游戏中',
|
||||
finished: '已结束',
|
||||
}
|
||||
const status = roomState.value.status
|
||||
return map[status] ?? status ?? '--'
|
||||
})
|
||||
|
||||
const roundText = computed(() => {
|
||||
if (deps.gameStore.totalRounds > 0) {
|
||||
return `${deps.gameStore.currentRound}/${deps.gameStore.totalRounds}`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const settlementPlayers = computed(() => {
|
||||
const winnerSet = new Set(deps.gameStore.winners)
|
||||
return Object.values(deps.gameStore.players as Record<string, DisplayPlayer>)
|
||||
.map((player) => ({
|
||||
playerId: player.playerId,
|
||||
displayName: player.displayName || `玩家${player.seatIndex + 1}`,
|
||||
score: deps.gameStore.scores[player.playerId] ?? 0,
|
||||
isWinner: winnerSet.has(player.playerId),
|
||||
seatIndex: player.seatIndex,
|
||||
isReady: Boolean(player.isReady),
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
})
|
||||
|
||||
const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
|
||||
const emptyState: Record<SeatKey, WallSeatState> = {
|
||||
top: emptyWallSeat(),
|
||||
right: emptyWallSeat(),
|
||||
bottom: emptyWallSeat(),
|
||||
left: emptyWallSeat(),
|
||||
}
|
||||
|
||||
if (deps.gameStore.phase === 'waiting' && deps.myHandTiles.value.length === 0) {
|
||||
return emptyState
|
||||
}
|
||||
|
||||
for (const seat of seatViews.value) {
|
||||
if (!seat.player) {
|
||||
continue
|
||||
}
|
||||
|
||||
const seatTiles: WallTileItem[] = []
|
||||
const targetSeat = seat.key
|
||||
|
||||
if (seat.isSelf) {
|
||||
const missingSuit = seat.player.missingSuit as Tile['suit'] | null | undefined
|
||||
sortedVisibleHandTiles.value.forEach((tile, index) => {
|
||||
const src = buildWallTileImage(targetSeat, tile, 'hand')
|
||||
if (!src) {
|
||||
return
|
||||
}
|
||||
const previousTile = index > 0 ? sortedVisibleHandTiles.value[index - 1] : undefined
|
||||
const isMissingSuitGroupStart = Boolean(
|
||||
missingSuit &&
|
||||
tile.suit === missingSuit &&
|
||||
(!previousTile || previousTile.suit !== tile.suit),
|
||||
)
|
||||
|
||||
seatTiles.push({
|
||||
key: `hand-${tile.id}-${index}`,
|
||||
src,
|
||||
alt: formatTile(tile),
|
||||
imageType: 'hand',
|
||||
showLackTag: isMissingSuitGroupStart,
|
||||
suit: tile.suit,
|
||||
tile,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
for (let index = 0; index < seat.player.handCount; index += 1) {
|
||||
const src = buildWallTileImage(targetSeat, undefined, 'hand')
|
||||
if (!src) {
|
||||
continue
|
||||
}
|
||||
|
||||
seatTiles.push({
|
||||
key: `concealed-${index}`,
|
||||
src,
|
||||
alt: '手牌背面',
|
||||
imageType: 'hand',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emptyState[targetSeat] = { tiles: seatTiles }
|
||||
}
|
||||
|
||||
return emptyState
|
||||
})
|
||||
|
||||
const deskSeats = computed<Record<SeatKey, DeskSeatState>>(() => {
|
||||
const emptyState: Record<SeatKey, DeskSeatState> = {
|
||||
top: emptyDeskSeat(),
|
||||
right: emptyDeskSeat(),
|
||||
bottom: emptyDeskSeat(),
|
||||
left: emptyDeskSeat(),
|
||||
}
|
||||
|
||||
if (deps.gameStore.phase === 'waiting' && deps.myHandTiles.value.length === 0) {
|
||||
return emptyState
|
||||
}
|
||||
|
||||
for (const seat of seatViews.value) {
|
||||
if (!seat.player) {
|
||||
continue
|
||||
}
|
||||
|
||||
const seatTiles: WallTileItem[] = []
|
||||
const targetSeat = seat.key
|
||||
|
||||
seat.player.discardTiles.forEach((tile, index) => {
|
||||
const src = buildWallTileImage(targetSeat, tile, 'exposed')
|
||||
if (!src) {
|
||||
return
|
||||
}
|
||||
|
||||
seatTiles.push({
|
||||
key: `discard-${tile.id}-${index}`,
|
||||
src,
|
||||
alt: formatTile(tile),
|
||||
imageType: 'exposed',
|
||||
suit: tile.suit,
|
||||
})
|
||||
})
|
||||
|
||||
seat.player.melds.forEach((meld, meldIndex) => {
|
||||
meld.tiles.forEach((tile, tileIndex) => {
|
||||
const imageType: TableTileImageType = meld.type === 'an_gang' ? 'covered' : 'exposed'
|
||||
const src = buildWallTileImage(targetSeat, tile, imageType)
|
||||
if (!src) {
|
||||
return
|
||||
}
|
||||
|
||||
seatTiles.push({
|
||||
key: `desk-${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`,
|
||||
src,
|
||||
alt: formatTile(tile),
|
||||
imageType,
|
||||
isGroupStart: tileIndex === 0,
|
||||
suit: tile.suit,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
emptyState[targetSeat] = {
|
||||
tiles: seatTiles,
|
||||
hasHu: seat.player.hasHu,
|
||||
}
|
||||
}
|
||||
|
||||
return emptyState
|
||||
})
|
||||
|
||||
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
||||
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
|
||||
const defaultMissingSuitLabel = missingSuitLabel(null)
|
||||
|
||||
const emptySeat = (): SeatPlayerCardModel => ({
|
||||
avatarUrl: '',
|
||||
name: '空位',
|
||||
dealer: false,
|
||||
isTurn: false,
|
||||
isReady: false,
|
||||
isTrustee: false,
|
||||
missingSuitLabel: defaultMissingSuitLabel,
|
||||
})
|
||||
|
||||
const result: Record<SeatKey, SeatPlayerCardModel> = {
|
||||
top: emptySeat(),
|
||||
right: emptySeat(),
|
||||
bottom: emptySeat(),
|
||||
left: emptySeat(),
|
||||
}
|
||||
|
||||
for (const seat of seatViews.value) {
|
||||
if (!seat.player) {
|
||||
continue
|
||||
}
|
||||
|
||||
const displayName = seat.player.displayName || `玩家${seat.player.seatIndex + 1}`
|
||||
const avatarUrl = seat.isSelf
|
||||
? deps.localCachedAvatarUrl.value || seat.player.avatarURL || ''
|
||||
: seat.player.avatarURL || ''
|
||||
const selfDisplayName = seat.player.displayName || deps.loggedInUserName.value || '你自己'
|
||||
|
||||
result[seat.key] = {
|
||||
avatarUrl,
|
||||
name: Array.from(seat.isSelf ? selfDisplayName : displayName)
|
||||
.slice(0, 4)
|
||||
.join(''),
|
||||
dealer: seat.player.seatIndex === dealerIndex,
|
||||
isTurn: seat.isTurn,
|
||||
isReady: Boolean(seat.player.isReady),
|
||||
isTrustee: Boolean(seat.player.isTrustee),
|
||||
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
return {
|
||||
roomName,
|
||||
roomState,
|
||||
seatViews,
|
||||
seatWinds,
|
||||
currentTurnSeat,
|
||||
currentPhaseText,
|
||||
roomStatusText,
|
||||
roundText,
|
||||
visibleHandTileGroups,
|
||||
sortedVisibleHandTiles,
|
||||
wallSeats,
|
||||
deskSeats,
|
||||
seatDecor,
|
||||
settlementPlayers,
|
||||
}
|
||||
}
|
||||
51
src/views/chengdu/socket/createMessageHandlers.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createPlayerHandlers } from './handlers/playerHandlers'
|
||||
import { createRoomInfoHandlers } from './handlers/roomInfoHandlers'
|
||||
import { createRoomStateHandlers } from './handlers/roomStateHandlers'
|
||||
import { createStatusHandlers } from './handlers/statusHandlers'
|
||||
import { dispatchSocketGameAction } from './handlers/socketDispatch'
|
||||
import { createTurnHandlers } from './handlers/turnHandlers'
|
||||
import type { SocketHandlerContext } from './types'
|
||||
import { parseSocketEnvelope } from './parsers/socketEnvelope'
|
||||
import { hydrateGameStoreFromActiveRoom } from './room/roomSnapshotSync'
|
||||
import { createSocketMessageRouter } from './router/socketMessageRouter'
|
||||
import { pushWsMessage } from './session/sessionStateAdapter'
|
||||
|
||||
export function createChengduMessageHandlers(context: SocketHandlerContext) {
|
||||
const roomHandlers = {
|
||||
...createRoomInfoHandlers(context),
|
||||
...createRoomStateHandlers(context),
|
||||
}
|
||||
const playerHandlers = createPlayerHandlers(context)
|
||||
const turnHandlers = createTurnHandlers(context)
|
||||
const statusHandlers = createStatusHandlers(context)
|
||||
const router = createSocketMessageRouter({
|
||||
roomHandlers,
|
||||
playerHandlers,
|
||||
turnHandlers,
|
||||
statusHandlers,
|
||||
})
|
||||
|
||||
function handleSocketMessage(msg: unknown): void {
|
||||
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
|
||||
pushWsMessage(context.session, `[server] ${text}`)
|
||||
|
||||
const envelope = parseSocketEnvelope(msg)
|
||||
if (!envelope) {
|
||||
return
|
||||
}
|
||||
|
||||
router.route(envelope.normalizedType, envelope.source)
|
||||
|
||||
dispatchSocketGameAction(
|
||||
context,
|
||||
envelope.source,
|
||||
playerHandlers.syncReadyStatesFromRoomUpdate,
|
||||
playerHandlers.syncTrusteeState,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
hydrateFromActiveRoom: (routeRoomId: string) => hydrateGameStoreFromActiveRoom(context, routeRoomId),
|
||||
handleSocketMessage,
|
||||
}
|
||||
}
|
||||
170
src/views/chengdu/socket/handlers/playerHandlers.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
asRecord,
|
||||
normalizeTiles,
|
||||
normalizeWsType,
|
||||
readBoolean,
|
||||
readString,
|
||||
} from '../../../../game/chengdu/messageNormalizers'
|
||||
import type { RoomPlayerUpdatePayload, RoomTrusteePayload } from '../../../../game/actions'
|
||||
import {
|
||||
clearDingQuePending,
|
||||
clearReadyTogglePending,
|
||||
clearStartGamePending,
|
||||
clearTurnPending,
|
||||
completeDiscard,
|
||||
setTrustMode,
|
||||
syncCurrentUserId,
|
||||
} from '../session/sessionStateAdapter'
|
||||
import { setPlayerHandState, setPlayerMissingSuit, setPlayerReadyState, setPlayerTrusteeState } from '../store/gameStoreAdapter'
|
||||
import type { PlayerHandlerApi, SocketHandlerContext } from '../types'
|
||||
|
||||
export function createPlayerHandlers(context: SocketHandlerContext): PlayerHandlerApi {
|
||||
function applyPlayerReadyState(playerId: string, ready: boolean): void {
|
||||
setPlayerReadyState(context.gameStore, playerId, ready)
|
||||
}
|
||||
|
||||
function syncReadyStatesFromRoomUpdate(payload: RoomPlayerUpdatePayload): void {
|
||||
if (!Array.isArray(payload.players)) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const item of payload.players) {
|
||||
const playerId =
|
||||
(typeof item.PlayerID === 'string' && item.PlayerID) ||
|
||||
(typeof item.player_id === 'string' && item.player_id) ||
|
||||
''
|
||||
const ready =
|
||||
typeof item.Ready === 'boolean'
|
||||
? item.Ready
|
||||
: typeof item.ready === 'boolean'
|
||||
? item.ready
|
||||
: typeof item.is_ready === 'boolean'
|
||||
? item.is_ready
|
||||
: undefined
|
||||
|
||||
if (!playerId || typeof ready !== 'boolean') {
|
||||
continue
|
||||
}
|
||||
|
||||
applyPlayerReadyState(playerId, ready)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlayerHandResponse(message: unknown): void {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_HAND') {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = asRecord(source.payload)
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
|
||||
syncCurrentUserId(context.session, readString(source, 'target'))
|
||||
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId')
|
||||
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
|
||||
return
|
||||
}
|
||||
|
||||
const handTiles = normalizeTiles(payload.hand)
|
||||
if (!context.session.loggedInUserId.value || handTiles.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTurnPending(context.session)
|
||||
setPlayerHandState(context.gameStore, context.session.loggedInUserId.value, handTiles)
|
||||
clearDingQuePending(context.session)
|
||||
|
||||
completeDiscard(context.session)
|
||||
if (context.gameStore.phase !== 'waiting') {
|
||||
clearStartGamePending(context.session)
|
||||
}
|
||||
}
|
||||
|
||||
function syncTrusteeState(payload: RoomTrusteePayload): void {
|
||||
const playerId =
|
||||
(typeof payload.player_id === 'string' && payload.player_id) ||
|
||||
(typeof payload.playerId === 'string' && payload.playerId) ||
|
||||
''
|
||||
if (!playerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const trustee = typeof payload.trustee === 'boolean' ? payload.trustee : true
|
||||
setPlayerTrusteeState(context.gameStore, playerId, trustee)
|
||||
if (playerId === context.session.loggedInUserId.value) {
|
||||
setTrustMode(context.session, trustee)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function handleReadyStateResponse(message: unknown): void {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_READY') {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = asRecord(source.payload)
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
|
||||
const roomId = typeof payload.room_id === 'string' ? payload.room_id : readString(source, 'roomId')
|
||||
const userId =
|
||||
(typeof payload.player_id === 'string' && payload.player_id) ||
|
||||
(typeof payload.user_id === 'string' && payload.user_id) ||
|
||||
readString(source, 'target')
|
||||
const ready = readBoolean(payload, 'is_ready', 'ready', 'Ready')
|
||||
|
||||
if (roomId && roomId !== context.gameStore.roomId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (ready !== null && userId) {
|
||||
applyPlayerReadyState(userId, ready)
|
||||
}
|
||||
if (userId && userId === context.session.loggedInUserId.value) {
|
||||
clearReadyTogglePending(context.session)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlayerDingQueResponse(message: unknown): void {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_DING_QUE') {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = asRecord(source.payload)
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
|
||||
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId')
|
||||
if (roomId && roomId !== context.gameStore.roomId) {
|
||||
return
|
||||
}
|
||||
|
||||
const userId =
|
||||
readString(payload, 'user_id', 'userId', 'player_id', 'playerId') || readString(source, 'target')
|
||||
const suit = readString(payload, 'suit', 'Suit')
|
||||
if (!userId || !suit) {
|
||||
return
|
||||
}
|
||||
|
||||
setPlayerMissingSuit(context.gameStore, userId, suit)
|
||||
|
||||
if (userId === context.session.loggedInUserId.value) {
|
||||
clearDingQuePending(context.session)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
applyPlayerReadyState,
|
||||
syncReadyStatesFromRoomUpdate,
|
||||
handlePlayerHandResponse,
|
||||
handleReadyStateResponse,
|
||||
handlePlayerDingQueResponse,
|
||||
syncTrusteeState,
|
||||
}
|
||||
}
|
||||
96
src/views/chengdu/socket/handlers/roomInfoHandlers.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { asRecord, normalizeWsType, readString } from '../../../../game/chengdu/messageNormalizers'
|
||||
import { parseRoomInfoSnapshot } from '../parsers/roomInfoSnapshot'
|
||||
import { clearRoomAndRedirect, syncActiveRoomFromRoomInfo } from '../room/roomSnapshotSync'
|
||||
import {
|
||||
clearClaimAndTurnPending,
|
||||
clearRoomCountdown,
|
||||
clearDingQuePending,
|
||||
clearSelfTurnAllowActions,
|
||||
clearTurnPending,
|
||||
setRoomCountdown,
|
||||
setSettlementDeadline,
|
||||
syncCurrentUserId,
|
||||
} from '../session/sessionStateAdapter'
|
||||
import { applyRoomSnapshot } from '../store/gameStoreAdapter'
|
||||
import type { SocketHandlerContext } from '../types'
|
||||
|
||||
export function createRoomInfoHandlers(context: SocketHandlerContext) {
|
||||
function handleRoomInfoResponse(message: unknown): void {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedType = normalizeWsType(source.type)
|
||||
if (normalizedType !== 'GET_ROOM_INFO' && normalizedType !== 'ROOM_INFO') {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = asRecord(source.payload) ?? source
|
||||
syncCurrentUserId(context.session, readString(source, 'target'))
|
||||
const room = asRecord(payload.room)
|
||||
const gameState = asRecord(payload.game_state)
|
||||
const playerView = asRecord(payload.player_view)
|
||||
|
||||
if (!room && !gameState && !playerView) {
|
||||
clearRoomAndRedirect(context)
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = parseRoomInfoSnapshot({
|
||||
message: source,
|
||||
loggedInUserId: context.session.loggedInUserId.value,
|
||||
loggedInUserName: context.session.loggedInUserName.value,
|
||||
previousPlayers: context.gameStore.players,
|
||||
})
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Object.keys(snapshot.nextPlayers).length > 0) {
|
||||
clearDingQuePending(context.session)
|
||||
}
|
||||
|
||||
applyRoomSnapshot(context.gameStore, {
|
||||
roomId: snapshot.roomId,
|
||||
players: snapshot.nextPlayers,
|
||||
phase: snapshot.phase,
|
||||
wallCount: snapshot.wallCount,
|
||||
dealerIndex: snapshot.dealerIndex,
|
||||
currentTurn: snapshot.currentTurn,
|
||||
needDraw: snapshot.needDraw,
|
||||
pendingClaim: snapshot.pendingClaim,
|
||||
scores: snapshot.scores,
|
||||
winners: snapshot.winners,
|
||||
currentRound: snapshot.currentRound,
|
||||
totalRounds: snapshot.totalRounds,
|
||||
})
|
||||
|
||||
if (!snapshot.pendingClaim) {
|
||||
clearClaimAndTurnPending(context.session)
|
||||
} else {
|
||||
clearTurnPending(context.session)
|
||||
}
|
||||
if (snapshot.actionTimer) {
|
||||
setRoomCountdown(context.session, snapshot.actionTimer)
|
||||
} else {
|
||||
clearRoomCountdown(context.session)
|
||||
}
|
||||
if (typeof snapshot.settlementDeadlineMs === 'number' && snapshot.settlementDeadlineMs > 0) {
|
||||
setSettlementDeadline(context.session, snapshot.settlementDeadlineMs)
|
||||
}
|
||||
if (context.gameStore.phase !== 'playing' || snapshot.currentTurnPlayerId !== context.session.loggedInUserId.value) {
|
||||
clearSelfTurnAllowActions(context.session)
|
||||
}
|
||||
|
||||
syncActiveRoomFromRoomInfo(context, {
|
||||
roomId: snapshot.roomId,
|
||||
room: snapshot.room,
|
||||
status: snapshot.status,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
handleRoomInfoResponse,
|
||||
}
|
||||
}
|
||||
103
src/views/chengdu/socket/handlers/roomStateHandlers.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
asRecord,
|
||||
normalizeWsType,
|
||||
readNumber,
|
||||
readString,
|
||||
} from '../../../../game/chengdu/messageNormalizers'
|
||||
import { parseRoomStateSnapshot } from '../parsers/roomStateSnapshot'
|
||||
import { syncActiveRoomFromRoomState } from '../room/roomSnapshotSync'
|
||||
import {
|
||||
clearClaimAndTurnPending,
|
||||
clearRoomCountdown,
|
||||
clearSelfTurnAllowActions,
|
||||
clearStartGamePending,
|
||||
clearTurnPending,
|
||||
completeDiscard,
|
||||
resetSettlementOverlayState,
|
||||
setRoomCountdown,
|
||||
setSettlementDeadline,
|
||||
} from '../session/sessionStateAdapter'
|
||||
import { applyRoomSnapshot } from '../store/gameStoreAdapter'
|
||||
import type { SocketHandlerContext } from '../types'
|
||||
|
||||
export function createRoomStateHandlers(context: SocketHandlerContext) {
|
||||
function handleRoomStateResponse(message: unknown): void {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'ROOM_STATE') {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = asRecord(source.payload)
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
|
||||
const roomId = readString(payload, 'room_id', 'roomId') || context.gameStore.roomId
|
||||
if (!roomId || (context.gameStore.roomId && roomId !== context.gameStore.roomId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = parseRoomStateSnapshot({
|
||||
payload,
|
||||
roomId,
|
||||
loggedInUserId: context.session.loggedInUserId.value,
|
||||
previousPlayers: context.gameStore.players,
|
||||
})
|
||||
|
||||
applyRoomSnapshot(context.gameStore, {
|
||||
roomId: snapshot.roomId,
|
||||
players: snapshot.nextPlayers,
|
||||
phase: snapshot.phase,
|
||||
wallCount: snapshot.wallCount,
|
||||
currentTurn: snapshot.currentTurn,
|
||||
needDraw: snapshot.needDraw,
|
||||
pendingClaim: snapshot.pendingClaim,
|
||||
scores: snapshot.scores,
|
||||
winners: snapshot.winners,
|
||||
currentRound: snapshot.currentRound,
|
||||
totalRounds: snapshot.totalRounds,
|
||||
})
|
||||
|
||||
if (typeof snapshot.settlementDeadlineMs === 'number' && snapshot.settlementDeadlineMs > 0) {
|
||||
setSettlementDeadline(context.session, snapshot.settlementDeadlineMs)
|
||||
} else if (snapshot.phase !== 'settlement') {
|
||||
setSettlementDeadline(context.session, null)
|
||||
}
|
||||
if (snapshot.actionTimer) {
|
||||
setRoomCountdown(context.session, snapshot.actionTimer)
|
||||
} else {
|
||||
clearRoomCountdown(context.session)
|
||||
}
|
||||
|
||||
if (!snapshot.pendingClaim) {
|
||||
clearClaimAndTurnPending(context.session)
|
||||
} else {
|
||||
clearTurnPending(context.session)
|
||||
}
|
||||
|
||||
syncActiveRoomFromRoomState(context, {
|
||||
roomId,
|
||||
phase: snapshot.phase,
|
||||
})
|
||||
|
||||
if (snapshot.phase !== 'waiting') {
|
||||
clearStartGamePending(context.session)
|
||||
}
|
||||
if (snapshot.phase !== 'settlement') {
|
||||
resetSettlementOverlayState(context.session)
|
||||
}
|
||||
if (snapshot.phase !== 'playing' || snapshot.currentTurnPlayerId !== context.session.loggedInUserId.value) {
|
||||
clearSelfTurnAllowActions(context.session)
|
||||
}
|
||||
if (
|
||||
snapshot.currentTurnPlayerId &&
|
||||
snapshot.currentTurnPlayerId !== context.session.loggedInUserId.value
|
||||
) {
|
||||
completeDiscard(context.session)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleRoomStateResponse,
|
||||
}
|
||||
}
|
||||
19
src/views/chengdu/socket/handlers/socketDispatch.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { dispatchGameAction } from '../../../../game/dispatcher'
|
||||
import { parseGameActionMessage } from '../parsers/gameActionMessage'
|
||||
import { applyGameActionSessionEffects } from '../session/gameActionEffects'
|
||||
import type { SocketHandlerContext } from '../types'
|
||||
|
||||
export function dispatchSocketGameAction(
|
||||
context: SocketHandlerContext,
|
||||
msg: unknown,
|
||||
onRoomPlayerUpdate: (payload: any) => void,
|
||||
onRoomTrustee: (payload: any) => void,
|
||||
): void {
|
||||
const gameAction = parseGameActionMessage(msg)
|
||||
if (!gameAction) {
|
||||
return
|
||||
}
|
||||
|
||||
dispatchGameAction(gameAction)
|
||||
applyGameActionSessionEffects(context, gameAction, onRoomPlayerUpdate, onRoomTrustee)
|
||||
}
|
||||
59
src/views/chengdu/socket/handlers/statusHandlers.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
asRecord,
|
||||
normalizeWsType,
|
||||
readString,
|
||||
} from '../../../../game/chengdu/messageNormalizers'
|
||||
import { clearClaimAndTurnPending, clearRoomCountdown, pushWsMessage, setWsError } from '../session/sessionStateAdapter'
|
||||
import type { SocketHandlerContext, StatusHandlerApi } from '../types'
|
||||
|
||||
export function createStatusHandlers(context: SocketHandlerContext): StatusHandlerApi {
|
||||
function handleActionAck(message: unknown): void {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'ACTION_ACK') {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = asRecord(source.payload)
|
||||
const roomId = readString(payload ?? {}, 'room_id', 'roomId') || readString(source, 'roomId')
|
||||
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
|
||||
return
|
||||
}
|
||||
|
||||
const action = normalizeWsType(readString(payload ?? {}, 'action') || readString(source, 'action'))
|
||||
if (
|
||||
action === 'DISCARD' ||
|
||||
action === 'DRAW' ||
|
||||
action === 'PENG' ||
|
||||
action === 'GANG' ||
|
||||
action === 'HU' ||
|
||||
action === 'PASS' ||
|
||||
action === 'DING_QUE'
|
||||
) {
|
||||
clearRoomCountdown(context.session)
|
||||
}
|
||||
}
|
||||
|
||||
function handleActionError(message: unknown): void {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'ACTION_ERROR') {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = asRecord(source.payload)
|
||||
const roomId = readString(payload ?? {}, 'room_id', 'roomId') || readString(source, 'roomId')
|
||||
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
|
||||
return
|
||||
}
|
||||
|
||||
const action = readString(payload ?? {}, 'action') || 'unknown'
|
||||
const messageText = readString(payload ?? {}, 'message') || '操作失败'
|
||||
clearClaimAndTurnPending(context.session)
|
||||
setWsError(context.session, messageText)
|
||||
pushWsMessage(context.session, `[action-error] ${action}: ${messageText}`)
|
||||
}
|
||||
|
||||
return {
|
||||
handleActionAck,
|
||||
handleActionError,
|
||||
}
|
||||
}
|
||||
197
src/views/chengdu/socket/handlers/turnHandlers.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
asRecord,
|
||||
normalizeTimestampMs,
|
||||
normalizeWsType,
|
||||
readNumber,
|
||||
readPlayerTurnAllowActions,
|
||||
readPlayerTurnPlayerId,
|
||||
readString,
|
||||
readStringArray,
|
||||
} from '../../../../game/chengdu/messageNormalizers'
|
||||
import {
|
||||
clearClaimAndTurnPending,
|
||||
clearDingQuePending,
|
||||
clearNextRoundPending,
|
||||
clearRoomCountdown,
|
||||
clearSelfTurnAllowActions,
|
||||
clearTurnPending,
|
||||
completeDiscard,
|
||||
resetSettlementOverlayState,
|
||||
setRoomCountdown,
|
||||
setSelfTurnAllowActions,
|
||||
} from '../session/sessionStateAdapter'
|
||||
import { resetRoundResolutionState } from '../store/gameStoreAdapter'
|
||||
import type { SocketHandlerContext, TurnHandlerApi, TurnPayloadRecord } from '../types'
|
||||
|
||||
export function createTurnHandlers(context: SocketHandlerContext): TurnHandlerApi {
|
||||
function handlePlayerAllowAction(message: unknown): void {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_ALLOW_ACTION') {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = asRecord(source.payload) ?? source
|
||||
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId', 'room_id')
|
||||
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
|
||||
return
|
||||
}
|
||||
|
||||
const playerId = readPlayerTurnPlayerId(payload)
|
||||
const timeout =
|
||||
readNumber(payload, 'timeout', 'Timeout') ??
|
||||
readNumber(source, 'timeout', 'Timeout') ??
|
||||
0
|
||||
const startAtRaw =
|
||||
readNumber(payload, 'start_at', 'startAt', 'StartAt') ??
|
||||
readNumber(source, 'start_at', 'startAt', 'StartAt')
|
||||
|
||||
if (!playerId || timeout <= 0) {
|
||||
clearRoomCountdown(context.session)
|
||||
return
|
||||
}
|
||||
|
||||
const startAtMs = normalizeTimestampMs(startAtRaw)
|
||||
const deadlineAtMs = startAtMs !== null ? startAtMs + timeout * 1000 : null
|
||||
const remaining =
|
||||
deadlineAtMs !== null ? Math.max(0, Math.ceil((deadlineAtMs - context.session.now.value) / 1000)) : timeout
|
||||
|
||||
setRoomCountdown(context.session, {
|
||||
playerIds: [playerId],
|
||||
actionDeadlineAt: deadlineAtMs !== null ? new Date(deadlineAtMs).toISOString() : null,
|
||||
countdownSeconds: timeout,
|
||||
duration: timeout,
|
||||
remaining,
|
||||
})
|
||||
}
|
||||
|
||||
function handleDingQueCountdown(message: unknown): void {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'DING_QUE_COUNTDOWN') {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = asRecord(source.payload) ?? source
|
||||
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId')
|
||||
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
|
||||
return
|
||||
}
|
||||
|
||||
const playerIds = readStringArray(payload, 'player_ids', 'playerIds', 'PlayerIDs')
|
||||
const fallbackPlayerId = readString(payload, 'player_id', 'playerId', 'PlayerID')
|
||||
const normalizedPlayerIds = playerIds.length > 0 ? playerIds : fallbackPlayerId ? [fallbackPlayerId] : []
|
||||
if (normalizedPlayerIds.length === 0) {
|
||||
clearRoomCountdown(context.session)
|
||||
return
|
||||
}
|
||||
|
||||
const countdownSeconds = readNumber(payload, 'countdown_seconds', 'CountdownSeconds') ?? 0
|
||||
const duration = readNumber(payload, 'duration', 'Duration') ?? countdownSeconds
|
||||
const remaining = readNumber(payload, 'remaining', 'Remaining') ?? countdownSeconds
|
||||
const actionDeadlineAt = readString(payload, 'action_deadline_at', 'ActionDeadlineAt') || null
|
||||
|
||||
if (countdownSeconds <= 0 && remaining <= 0 && !actionDeadlineAt) {
|
||||
clearRoomCountdown(context.session)
|
||||
return
|
||||
}
|
||||
|
||||
setRoomCountdown(context.session, {
|
||||
playerIds: normalizedPlayerIds,
|
||||
actionDeadlineAt,
|
||||
countdownSeconds,
|
||||
duration,
|
||||
remaining,
|
||||
})
|
||||
}
|
||||
|
||||
function applyPlayerTurnCountdown(payload: TurnPayloadRecord): void {
|
||||
const playerId = readPlayerTurnPlayerId(payload)
|
||||
const timeout =
|
||||
(typeof payload.timeout === 'number' && Number.isFinite(payload.timeout) ? payload.timeout : null) ??
|
||||
(typeof payload.Timeout === 'number' && Number.isFinite(payload.Timeout) ? payload.Timeout : null) ??
|
||||
0
|
||||
const startAtRaw =
|
||||
(typeof payload.start_at === 'number' && Number.isFinite(payload.start_at) ? payload.start_at : null) ??
|
||||
(typeof payload.startAt === 'number' && Number.isFinite(payload.startAt) ? payload.startAt : null) ??
|
||||
(typeof payload.StartAt === 'number' && Number.isFinite(payload.StartAt) ? payload.StartAt : null)
|
||||
|
||||
if (!playerId || timeout <= 0) {
|
||||
clearRoomCountdown(context.session)
|
||||
return
|
||||
}
|
||||
|
||||
const startAtMs = normalizeTimestampMs(startAtRaw)
|
||||
const deadlineAtMs = startAtMs !== null ? startAtMs + timeout * 1000 : null
|
||||
const remaining =
|
||||
deadlineAtMs !== null ? Math.max(0, Math.ceil((deadlineAtMs - context.session.now.value) / 1000)) : timeout
|
||||
|
||||
setRoomCountdown(context.session, {
|
||||
playerIds: [playerId],
|
||||
actionDeadlineAt: deadlineAtMs !== null ? new Date(deadlineAtMs).toISOString() : null,
|
||||
countdownSeconds: timeout,
|
||||
duration: timeout,
|
||||
remaining,
|
||||
})
|
||||
}
|
||||
|
||||
function resetRoundStateForNextTurn(payload: Record<string, unknown>): void {
|
||||
const nextRound = readNumber(payload, 'current_round', 'currentRound')
|
||||
const totalRounds = readNumber(payload, 'total_rounds', 'totalRounds')
|
||||
if (typeof nextRound !== 'number' && typeof totalRounds !== 'number') {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof nextRound === 'number') {
|
||||
context.gameStore.currentRound = nextRound
|
||||
}
|
||||
if (typeof totalRounds === 'number') {
|
||||
context.gameStore.totalRounds = totalRounds
|
||||
}
|
||||
|
||||
clearNextRoundPending(context.session)
|
||||
resetSettlementOverlayState(context.session)
|
||||
clearDingQuePending(context.session)
|
||||
clearRoomCountdown(context.session)
|
||||
clearClaimAndTurnPending(context.session)
|
||||
clearSelfTurnAllowActions(context.session)
|
||||
resetRoundResolutionState(context.gameStore)
|
||||
|
||||
completeDiscard(context.session)
|
||||
clearTurnPending(context.session)
|
||||
}
|
||||
|
||||
function handlePlayerTurn(message: unknown): void {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedType = normalizeWsType(source.type)
|
||||
if (normalizedType !== 'PLAYER_TURN' && normalizedType !== 'NEXT_TURN') {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = asRecord(source.payload) ?? source
|
||||
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId')
|
||||
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
|
||||
return
|
||||
}
|
||||
|
||||
resetRoundStateForNextTurn(payload)
|
||||
const turnPayload = payload as TurnPayloadRecord
|
||||
const turnPlayerID = readPlayerTurnPlayerId(turnPayload)
|
||||
if (turnPlayerID && turnPlayerID === context.session.loggedInUserId.value) {
|
||||
setSelfTurnAllowActions(context.session, readPlayerTurnAllowActions(turnPayload))
|
||||
} else {
|
||||
clearSelfTurnAllowActions(context.session)
|
||||
}
|
||||
if (normalizedType === 'PLAYER_TURN') {
|
||||
applyPlayerTurnCountdown(turnPayload)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleDingQueCountdown,
|
||||
handlePlayerAllowAction,
|
||||
handlePlayerTurn,
|
||||
}
|
||||
}
|
||||
27
src/views/chengdu/socket/parsers/actionTimerSnapshot.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { asRecord, readNumber, readString, readStringArray } from '../../../../game/chengdu/messageNormalizers'
|
||||
import type { PlayerActionTimer } from '../../types'
|
||||
|
||||
export function parseActionTimerSnapshot(source: unknown): PlayerActionTimer | null {
|
||||
const timer = asRecord(source)
|
||||
if (!timer) {
|
||||
return null
|
||||
}
|
||||
|
||||
const playerIds = readStringArray(timer, 'player_ids', 'playerIds', 'PlayerIDs')
|
||||
const countdownSeconds = readNumber(timer, 'countdown_seconds', 'countdownSeconds', 'CountdownSeconds') ?? 0
|
||||
const duration = readNumber(timer, 'duration', 'Duration') ?? countdownSeconds
|
||||
const remaining = readNumber(timer, 'remaining', 'Remaining') ?? countdownSeconds
|
||||
const actionDeadlineAt = readString(timer, 'action_deadline_at', 'actionDeadlineAt', 'ActionDeadlineAt') || null
|
||||
|
||||
if (playerIds.length === 0 && countdownSeconds <= 0 && remaining <= 0 && !actionDeadlineAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
playerIds,
|
||||
actionDeadlineAt,
|
||||
countdownSeconds,
|
||||
duration,
|
||||
remaining,
|
||||
}
|
||||
}
|
||||
100
src/views/chengdu/socket/parsers/gameActionMessage.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { DiscardActionPayload, DrawActionPayload, GameAction, PlayerTurnPayload, RoomTrusteePayload } from '../../../../game/actions'
|
||||
import type { ClaimOptionState } from '../../../../types/state'
|
||||
import { asRecord, normalizeTile, normalizeWsType, readNumber, readString } from '../../../../game/chengdu/messageNormalizers'
|
||||
|
||||
export function parseGameActionMessage(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 = normalizeWsType(source.type)
|
||||
const payload = source.payload
|
||||
|
||||
switch (type) {
|
||||
case 'GAME_INIT':
|
||||
case 'GAME_START':
|
||||
case 'DRAW_TILE':
|
||||
case 'PLAY_TILE':
|
||||
case 'PENDING_CLAIM':
|
||||
case 'CLAIM_RESOLVED':
|
||||
case 'ROOM_PLAYER_UPDATE':
|
||||
return payload && typeof payload === 'object' ? ({ type, payload } as GameAction) : null
|
||||
case 'ROOM_MEMBER_JOINED':
|
||||
return payload && typeof payload === 'object'
|
||||
? ({ type: 'ROOM_PLAYER_UPDATE', payload } as GameAction)
|
||||
: null
|
||||
case 'ROOM_TRUSTEE':
|
||||
case 'PLAYER_TRUSTEE':
|
||||
return payload && typeof payload === 'object'
|
||||
? ({ type: 'ROOM_TRUSTEE', payload } as GameAction)
|
||||
: ({ type: 'ROOM_TRUSTEE', payload: source as unknown as RoomTrusteePayload } as GameAction)
|
||||
case 'DRAW': {
|
||||
const resolvedPayload = asRecord(payload)
|
||||
const playerId =
|
||||
readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') ||
|
||||
readString(source, 'target')
|
||||
if (!playerId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
type: 'DRAW_TILE',
|
||||
payload: {
|
||||
...(resolvedPayload as DrawActionPayload | null),
|
||||
player_id: playerId,
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'DISCARD': {
|
||||
const resolvedPayload = asRecord(payload)
|
||||
const playerId =
|
||||
readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') ||
|
||||
readString(source, 'target')
|
||||
const tile = normalizeTile(resolvedPayload?.tile)
|
||||
if (!playerId || !tile) {
|
||||
return null
|
||||
}
|
||||
const nextSeat = readNumber(resolvedPayload ?? {}, 'next_seat', 'nextSeat')
|
||||
return {
|
||||
type: 'PLAY_TILE',
|
||||
payload: {
|
||||
...(resolvedPayload as DiscardActionPayload | null),
|
||||
player_id: playerId,
|
||||
tile,
|
||||
...(typeof nextSeat === 'number' ? { next_seat: nextSeat } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'PLAYER_TURN':
|
||||
case 'NEXT_TURN':
|
||||
return payload && typeof payload === 'object'
|
||||
? ({ type: 'PLAYER_TURN', payload } as GameAction)
|
||||
: ({ type: 'PLAYER_TURN', payload: source as unknown as PlayerTurnPayload } as GameAction)
|
||||
case 'PENG':
|
||||
case 'GANG':
|
||||
case 'HU':
|
||||
case 'PASS': {
|
||||
const resolvedPayload = asRecord(payload)
|
||||
const playerId =
|
||||
readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') ||
|
||||
readString(source, 'target')
|
||||
const action = type.toLowerCase()
|
||||
if (!playerId || !['peng', 'gang', 'hu', 'pass'].includes(action)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
type: 'CLAIM_RESOLVED',
|
||||
payload: {
|
||||
playerId,
|
||||
action: action as ClaimOptionState,
|
||||
},
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
281
src/views/chengdu/socket/parsers/roomInfoSnapshot.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
asRecord,
|
||||
normalizeMelds,
|
||||
normalizePendingClaim,
|
||||
normalizeTiles,
|
||||
readBoolean,
|
||||
readMissingSuit,
|
||||
readMissingSuitWithPresence,
|
||||
readNumber,
|
||||
readString,
|
||||
readStringArray,
|
||||
tileToText,
|
||||
} from '../../../../game/chengdu/messageNormalizers'
|
||||
import type { RoomMetaSnapshotState } from '../../../../store/state'
|
||||
import type { PendingClaimState, PlayerState } from '../../../../types/state'
|
||||
import type { PlayerActionTimer } from '../../types'
|
||||
import { parseActionTimerSnapshot } from './actionTimerSnapshot'
|
||||
|
||||
interface RoomInfoSnapshotPlayerPair {
|
||||
roomPlayer: RoomMetaSnapshotState['players'][number]
|
||||
gamePlayer: PlayerState
|
||||
}
|
||||
|
||||
export interface ParsedRoomInfoSnapshot {
|
||||
room: Record<string, unknown> | null
|
||||
gameState: Record<string, unknown> | null
|
||||
playerView: Record<string, unknown> | null
|
||||
roomId: string
|
||||
roomPlayers: RoomInfoSnapshotPlayerPair[]
|
||||
nextPlayers: Record<string, PlayerState>
|
||||
status: string
|
||||
phase: string
|
||||
wallCount: number | null
|
||||
dealerIndex: number | null
|
||||
currentTurnPlayerId: string
|
||||
currentTurn: number | null
|
||||
needDraw: boolean
|
||||
pendingClaim?: PendingClaimState
|
||||
scores?: Record<string, number>
|
||||
winners: string[]
|
||||
currentRound: number | null
|
||||
totalRounds: number | null
|
||||
settlementDeadlineMs: number | null
|
||||
actionTimer: PlayerActionTimer | null
|
||||
}
|
||||
|
||||
interface ParseRoomInfoSnapshotOptions {
|
||||
message: Record<string, unknown>
|
||||
loggedInUserId: string
|
||||
loggedInUserName: string
|
||||
previousPlayers: Record<string, PlayerState>
|
||||
}
|
||||
|
||||
function buildPlayerPairs(options: ParseRoomInfoSnapshotOptions, payload: Record<string, unknown>) {
|
||||
const room = asRecord(payload.room)
|
||||
const gameState = asRecord(payload.game_state)
|
||||
const playerView = asRecord(payload.player_view)
|
||||
const roomPlayers = Array.isArray(room?.players) ? room.players : []
|
||||
const gamePlayers = Array.isArray(gameState?.players) ? gameState.players : []
|
||||
const playerMap = new Map<string, RoomInfoSnapshotPlayerPair>()
|
||||
|
||||
roomPlayers.forEach((item, fallbackIndex) => {
|
||||
const player = asRecord(item)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
const playerId = readString(player, 'player_id', 'PlayerID', 'id', 'user_id')
|
||||
if (!playerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const seatIndex = readNumber(player, 'index', 'Index', 'seat_index', 'seatIndex') ?? fallbackIndex
|
||||
const displayName =
|
||||
readString(player, 'player_name', 'PlayerName', 'display_name', 'displayName', 'nickname', 'username') ||
|
||||
(playerId === options.loggedInUserId ? options.loggedInUserName : '')
|
||||
const ready = readBoolean(player, 'ready', 'Ready') ?? false
|
||||
const missingSuit = readMissingSuit(player)
|
||||
|
||||
playerMap.set(playerId, {
|
||||
roomPlayer: {
|
||||
index: seatIndex,
|
||||
playerId,
|
||||
displayName: displayName || undefined,
|
||||
missingSuit,
|
||||
ready,
|
||||
trustee: false,
|
||||
hand: [],
|
||||
melds: [],
|
||||
outTiles: [],
|
||||
hasHu: false,
|
||||
},
|
||||
gamePlayer: {
|
||||
playerId,
|
||||
seatIndex,
|
||||
displayName: displayName || undefined,
|
||||
avatarURL: readString(player, 'avatar_url', 'AvatarUrl', 'avatar', 'avatarUrl') || undefined,
|
||||
missingSuit,
|
||||
isReady: ready,
|
||||
isTrustee: false,
|
||||
handTiles: [],
|
||||
handCount: 0,
|
||||
melds: [],
|
||||
discardTiles: [],
|
||||
hasHu: false,
|
||||
score: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
gamePlayers.forEach((item, fallbackIndex) => {
|
||||
const player = asRecord(item)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
const playerId = readString(player, 'player_id', 'PlayerID')
|
||||
if (!playerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = playerMap.get(playerId)
|
||||
const seatIndex =
|
||||
existing?.gamePlayer.seatIndex ??
|
||||
readNumber(player, 'index', 'Index', 'seat_index', 'seatIndex') ??
|
||||
fallbackIndex
|
||||
const displayName =
|
||||
existing?.gamePlayer.displayName || (playerId === options.loggedInUserId ? options.loggedInUserName : '')
|
||||
const missingSuit = readMissingSuitWithPresence(player)
|
||||
const handCount = readNumber(player, 'hand_count', 'handCount') ?? 0
|
||||
const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles)
|
||||
const melds = normalizeMelds(player.melds ?? player.exposed_melds ?? player.exposedMelds ?? player.claims)
|
||||
const hasHu = Boolean(player.has_hu ?? player.hasHu)
|
||||
|
||||
playerMap.set(playerId, {
|
||||
roomPlayer: {
|
||||
index: seatIndex,
|
||||
playerId,
|
||||
displayName: displayName || undefined,
|
||||
missingSuit: missingSuit.present ? missingSuit.value : (existing?.gamePlayer.missingSuit ?? null),
|
||||
ready: existing?.roomPlayer.ready ?? false,
|
||||
trustee: existing?.roomPlayer.trustee ?? false,
|
||||
hand: Array.from({ length: handCount }, () => ''),
|
||||
melds: melds.map((meld) => meld.type),
|
||||
outTiles: outTiles.map((tile) => tileToText(tile)),
|
||||
hasHu,
|
||||
},
|
||||
gamePlayer: {
|
||||
playerId,
|
||||
seatIndex,
|
||||
displayName: displayName || undefined,
|
||||
avatarURL: existing?.gamePlayer.avatarURL,
|
||||
missingSuit: missingSuit.present ? missingSuit.value : (existing?.gamePlayer.missingSuit ?? null),
|
||||
isReady: existing?.gamePlayer.isReady ?? false,
|
||||
isTrustee: existing?.gamePlayer.isTrustee ?? false,
|
||||
handTiles: existing?.gamePlayer.handTiles ?? [],
|
||||
handCount,
|
||||
melds: melds.length > 0 ? melds : existing?.gamePlayer.melds ?? [],
|
||||
discardTiles: outTiles,
|
||||
hasHu,
|
||||
score: existing?.gamePlayer.score ?? 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const privateHandTiles = normalizeTiles(playerView?.hand)
|
||||
const privateHand = privateHandTiles.map((tile) => tileToText(tile))
|
||||
if (options.loggedInUserId && playerMap.has(options.loggedInUserId)) {
|
||||
const current = playerMap.get(options.loggedInUserId)
|
||||
if (current) {
|
||||
const selfMissingSuit = readMissingSuitWithPresence(playerView)
|
||||
current.roomPlayer.hand = privateHand
|
||||
if (selfMissingSuit.present) {
|
||||
current.roomPlayer.missingSuit = selfMissingSuit.value
|
||||
}
|
||||
current.gamePlayer.handTiles = privateHandTiles
|
||||
current.gamePlayer.handCount = privateHandTiles.length
|
||||
if (selfMissingSuit.present) {
|
||||
current.gamePlayer.missingSuit = selfMissingSuit.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
room,
|
||||
gameState,
|
||||
playerView,
|
||||
roomPlayers: Array.from(playerMap.values()).sort((a, b) => a.gamePlayer.seatIndex - b.gamePlayer.seatIndex),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRoomInfoSnapshot(
|
||||
options: ParseRoomInfoSnapshotOptions,
|
||||
): ParsedRoomInfoSnapshot | null {
|
||||
const payload = asRecord(options.message.payload) ?? options.message
|
||||
const { room, gameState, playerView, roomPlayers } = buildPlayerPairs(options, payload)
|
||||
|
||||
const roomId =
|
||||
readString(room ?? {}, 'room_id', 'roomId') ||
|
||||
readString(gameState ?? {}, 'room_id', 'roomId') ||
|
||||
readString(playerView ?? {}, 'room_id', 'roomId') ||
|
||||
readString(payload, 'room_id', 'roomId') ||
|
||||
readString(options.message, 'roomId')
|
||||
if (!roomId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextPlayers: Record<string, PlayerState> = {}
|
||||
roomPlayers.forEach(({ gamePlayer }) => {
|
||||
const previous = options.previousPlayers[gamePlayer.playerId]
|
||||
const score =
|
||||
gameState?.scores && typeof gameState.scores === 'object'
|
||||
? (gameState.scores as Record<string, unknown>)[gamePlayer.playerId]
|
||||
: undefined
|
||||
nextPlayers[gamePlayer.playerId] = {
|
||||
playerId: gamePlayer.playerId,
|
||||
seatIndex: gamePlayer.seatIndex,
|
||||
displayName: gamePlayer.displayName ?? previous?.displayName,
|
||||
avatarURL: gamePlayer.avatarURL ?? previous?.avatarURL,
|
||||
missingSuit:
|
||||
typeof gamePlayer.missingSuit === 'undefined' ? (previous?.missingSuit ?? null) : gamePlayer.missingSuit,
|
||||
isTrustee: previous?.isTrustee ?? gamePlayer.isTrustee,
|
||||
handTiles: gamePlayer.handTiles.length > 0 ? gamePlayer.handTiles : previous?.handTiles ?? [],
|
||||
handCount:
|
||||
gamePlayer.handCount > 0
|
||||
? gamePlayer.handCount
|
||||
: gamePlayer.handTiles.length > 0
|
||||
? gamePlayer.handTiles.length
|
||||
: (previous?.handCount ?? 0),
|
||||
melds: gamePlayer.melds.length > 0 ? gamePlayer.melds : previous?.melds ?? [],
|
||||
discardTiles: gamePlayer.discardTiles.length > 0 ? gamePlayer.discardTiles : previous?.discardTiles ?? [],
|
||||
hasHu: gamePlayer.hasHu || previous?.hasHu || false,
|
||||
score: typeof score === 'number' ? score : previous?.score ?? gamePlayer.score ?? 0,
|
||||
isReady: gamePlayer.isReady,
|
||||
}
|
||||
})
|
||||
|
||||
const status =
|
||||
readString(gameState ?? {}, 'status') ||
|
||||
readString(room ?? {}, 'status') ||
|
||||
readString(gameState ?? {}, 'phase') ||
|
||||
'waiting'
|
||||
const rawPendingClaim = asRecord(gameState?.pending_claim ?? gameState?.pendingClaim)
|
||||
const hasPendingClaimWindow = Boolean(rawPendingClaim && Object.keys(rawPendingClaim).length > 0)
|
||||
const phase =
|
||||
hasPendingClaimWindow ? 'action' : readString(gameState ?? {}, 'phase') || readString(room ?? {}, 'status') || 'waiting'
|
||||
const wallCount = readNumber(gameState ?? {}, 'wall_count', 'wallCount')
|
||||
const dealerIndex = readNumber(gameState ?? {}, 'dealer_index', 'dealerIndex')
|
||||
const currentTurnSeat = readNumber(gameState ?? {}, 'current_turn', 'currentTurn')
|
||||
const currentTurnPlayerId = readString(gameState ?? {}, 'current_turn_player', 'currentTurnPlayer') || ''
|
||||
const currentTurn =
|
||||
currentTurnSeat ??
|
||||
(currentTurnPlayerId && nextPlayers[currentTurnPlayerId] ? nextPlayers[currentTurnPlayerId].seatIndex : null)
|
||||
|
||||
return {
|
||||
room,
|
||||
gameState,
|
||||
playerView,
|
||||
roomId,
|
||||
roomPlayers,
|
||||
nextPlayers,
|
||||
status,
|
||||
phase,
|
||||
wallCount,
|
||||
dealerIndex,
|
||||
currentTurnPlayerId,
|
||||
currentTurn,
|
||||
needDraw: readBoolean(gameState ?? {}, 'need_draw', 'needDraw') ?? false,
|
||||
pendingClaim: normalizePendingClaim(gameState, options.loggedInUserId),
|
||||
scores: asRecord(gameState?.scores)
|
||||
? (Object.fromEntries(
|
||||
Object.entries(asRecord(gameState?.scores) ?? {}).filter(([, value]) => typeof value === 'number'),
|
||||
) as Record<string, number>)
|
||||
: undefined,
|
||||
winners: readStringArray(gameState ?? {}, 'winners'),
|
||||
currentRound: readNumber(gameState ?? {}, 'current_round', 'currentRound'),
|
||||
totalRounds: readNumber(gameState ?? {}, 'total_rounds', 'totalRounds'),
|
||||
settlementDeadlineMs: readNumber(gameState ?? {}, 'settlement_deadline_ms', 'settlementDeadlineMs'),
|
||||
actionTimer: parseActionTimerSnapshot(gameState?.action_timer ?? gameState?.actionTimer),
|
||||
}
|
||||
}
|
||||
116
src/views/chengdu/socket/parsers/roomStateSnapshot.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
asRecord,
|
||||
normalizeMelds,
|
||||
normalizePendingClaim,
|
||||
normalizeTiles,
|
||||
readBoolean,
|
||||
readMissingSuitWithPresence,
|
||||
readNumber,
|
||||
readString,
|
||||
readStringArray,
|
||||
} from '../../../../game/chengdu/messageNormalizers'
|
||||
import type { PendingClaimState, PlayerState } from '../../../../types/state'
|
||||
import type { PlayerActionTimer } from '../../types'
|
||||
import { parseActionTimerSnapshot } from './actionTimerSnapshot'
|
||||
|
||||
export interface ParsedRoomStateSnapshot {
|
||||
roomId: string
|
||||
nextPlayers: Record<string, PlayerState>
|
||||
phase: string
|
||||
wallCount: number | null
|
||||
currentTurnPlayerId: string
|
||||
currentTurn: number | null
|
||||
needDraw: boolean
|
||||
pendingClaim?: PendingClaimState
|
||||
scores?: Record<string, number>
|
||||
winners: string[]
|
||||
currentRound: number | null
|
||||
totalRounds: number | null
|
||||
settlementDeadlineMs: number | null
|
||||
actionTimer: PlayerActionTimer | null
|
||||
}
|
||||
|
||||
interface ParseRoomStateSnapshotOptions {
|
||||
payload: Record<string, unknown>
|
||||
roomId: string
|
||||
loggedInUserId: string
|
||||
previousPlayers: Record<string, PlayerState>
|
||||
}
|
||||
|
||||
export function parseRoomStateSnapshot(
|
||||
options: ParseRoomStateSnapshotOptions,
|
||||
): ParsedRoomStateSnapshot {
|
||||
const { payload, previousPlayers } = options
|
||||
const nextPlayers: Record<string, PlayerState> = {}
|
||||
const gamePlayers = Array.isArray(payload.players) ? payload.players : []
|
||||
|
||||
gamePlayers.forEach((item, fallbackIndex) => {
|
||||
const player = asRecord(item)
|
||||
if (!player) {
|
||||
return
|
||||
}
|
||||
|
||||
const playerId = readString(player, 'player_id', 'PlayerID')
|
||||
if (!playerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const previous = previousPlayers[playerId]
|
||||
const seatIndex = previous?.seatIndex ?? fallbackIndex
|
||||
const handCount = readNumber(player, 'hand_count', 'handCount') ?? previous?.handCount ?? 0
|
||||
const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles)
|
||||
const melds = normalizeMelds(player.melds ?? player.exposed_melds ?? player.exposedMelds ?? player.claims)
|
||||
const hasHu = Boolean(player.has_hu ?? player.hasHu)
|
||||
const dingQue = readMissingSuitWithPresence(player)
|
||||
const scores = asRecord(payload.scores)
|
||||
const score = scores?.[playerId]
|
||||
|
||||
nextPlayers[playerId] = {
|
||||
playerId,
|
||||
seatIndex,
|
||||
displayName: previous?.displayName ?? playerId,
|
||||
avatarURL: previous?.avatarURL,
|
||||
isTrustee: previous?.isTrustee ?? false,
|
||||
missingSuit: dingQue.present ? dingQue.value : (previous?.missingSuit ?? null),
|
||||
handTiles: previous?.handTiles ?? [],
|
||||
handCount,
|
||||
melds,
|
||||
discardTiles: outTiles,
|
||||
hasHu,
|
||||
score: typeof score === 'number' ? score : previous?.score ?? 0,
|
||||
isReady: previous?.isReady ?? false,
|
||||
}
|
||||
})
|
||||
|
||||
const rawPendingClaim = asRecord(payload.pending_claim ?? payload.pendingClaim)
|
||||
const hasPendingClaimWindow = Boolean(rawPendingClaim && Object.keys(rawPendingClaim).length > 0)
|
||||
const phase =
|
||||
hasPendingClaimWindow ? 'action' : readString(payload, 'phase') || readString(payload, 'status') || 'waiting'
|
||||
const wallCount = readNumber(payload, 'wall_count', 'wallCount')
|
||||
const currentTurnSeat = readNumber(payload, 'current_turn', 'currentTurn')
|
||||
const currentTurnPlayerId = readString(payload, 'current_turn_player', 'currentTurnPlayer') || ''
|
||||
const currentTurn =
|
||||
currentTurnSeat ??
|
||||
(currentTurnPlayerId && nextPlayers[currentTurnPlayerId] ? nextPlayers[currentTurnPlayerId].seatIndex : null)
|
||||
|
||||
return {
|
||||
roomId: options.roomId,
|
||||
nextPlayers,
|
||||
phase,
|
||||
wallCount,
|
||||
currentTurnPlayerId,
|
||||
currentTurn,
|
||||
needDraw: readBoolean(payload, 'need_draw', 'needDraw') ?? false,
|
||||
pendingClaim: normalizePendingClaim(payload, options.loggedInUserId),
|
||||
scores: asRecord(payload.scores)
|
||||
? (Object.fromEntries(
|
||||
Object.entries(asRecord(payload.scores) ?? {}).filter(([, value]) => typeof value === 'number'),
|
||||
) as Record<string, number>)
|
||||
: undefined,
|
||||
winners: readStringArray(payload, 'winners'),
|
||||
currentRound: readNumber(payload, 'current_round', 'currentRound'),
|
||||
totalRounds: readNumber(payload, 'total_rounds', 'totalRounds'),
|
||||
settlementDeadlineMs: readNumber(payload, 'settlement_deadline_ms', 'settlementDeadlineMs'),
|
||||
actionTimer: parseActionTimerSnapshot(payload.action_timer ?? payload.actionTimer),
|
||||
}
|
||||
}
|
||||
20
src/views/chengdu/socket/parsers/socketEnvelope.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { asRecord, normalizeWsType } from '../../../../game/chengdu/messageNormalizers'
|
||||
|
||||
export interface SocketEnvelope {
|
||||
raw: unknown
|
||||
source: Record<string, unknown>
|
||||
normalizedType: string
|
||||
}
|
||||
|
||||
export function parseSocketEnvelope(message: unknown): SocketEnvelope | null {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
raw: message,
|
||||
source,
|
||||
normalizedType: normalizeWsType(source.type),
|
||||
}
|
||||
}
|
||||
83
src/views/chengdu/socket/room/roomSnapshotSync.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { clearRoomMetaSnapshotState, setRoomMetaSnapshot } from '../../../../store'
|
||||
import { wsClient } from '../../../../ws/client'
|
||||
import { readNumber, readString } from '../../../../game/chengdu/messageNormalizers'
|
||||
import type { SocketHandlerContext } from '../types'
|
||||
|
||||
interface SyncRoomStateSnapshotOptions {
|
||||
roomId: string
|
||||
phase: string
|
||||
}
|
||||
|
||||
interface SyncRoomInfoSnapshotOptions {
|
||||
roomId: string
|
||||
room: Record<string, unknown> | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export function clearRoomAndRedirect(context: SocketHandlerContext): void {
|
||||
clearRoomMetaSnapshotState()
|
||||
context.gameStore.resetGame()
|
||||
wsClient.close()
|
||||
void context.router.push('/hall')
|
||||
}
|
||||
|
||||
export function syncActiveRoomFromRoomState(
|
||||
context: SocketHandlerContext,
|
||||
options: SyncRoomStateSnapshotOptions,
|
||||
): void {
|
||||
const previousRoom = context.roomMeta.value
|
||||
|
||||
setRoomMetaSnapshot({
|
||||
roomId: options.roomId,
|
||||
roomName: previousRoom?.roomName || context.roomName.value,
|
||||
gameType: previousRoom?.gameType || 'chengdu',
|
||||
ownerId: previousRoom?.ownerId || '',
|
||||
maxPlayers: previousRoom?.maxPlayers ?? 4,
|
||||
playerCount: previousRoom?.playerCount ?? Object.keys(context.gameStore.players).length,
|
||||
status:
|
||||
options.phase === 'settlement' ? 'finished' : options.phase === 'waiting' ? 'waiting' : 'playing',
|
||||
createdAt: previousRoom?.createdAt || '',
|
||||
updatedAt: previousRoom?.updatedAt || '',
|
||||
players: previousRoom?.players ?? [],
|
||||
myHand: previousRoom?.myHand ?? [],
|
||||
game: previousRoom?.game,
|
||||
})
|
||||
}
|
||||
|
||||
export function syncActiveRoomFromRoomInfo(
|
||||
context: SocketHandlerContext,
|
||||
options: SyncRoomInfoSnapshotOptions,
|
||||
): void {
|
||||
const previousRoom = context.roomMeta.value
|
||||
setRoomMetaSnapshot({
|
||||
roomId: options.roomId,
|
||||
roomName:
|
||||
readString(options.room ?? {}, 'name', 'room_name') ||
|
||||
previousRoom?.roomName ||
|
||||
context.roomName.value,
|
||||
gameType: readString(options.room ?? {}, 'game_type') || previousRoom?.gameType || 'chengdu',
|
||||
ownerId: readString(options.room ?? {}, 'owner_id') || previousRoom?.ownerId || '',
|
||||
maxPlayers: readNumber(options.room ?? {}, 'max_players') ?? previousRoom?.maxPlayers ?? 4,
|
||||
playerCount: readNumber(options.room ?? {}, 'player_count') ?? previousRoom?.playerCount ?? 0,
|
||||
status: options.status,
|
||||
createdAt: readString(options.room ?? {}, 'created_at') || previousRoom?.createdAt || '',
|
||||
updatedAt: readString(options.room ?? {}, 'updated_at') || previousRoom?.updatedAt || '',
|
||||
players: previousRoom?.players ?? [],
|
||||
myHand: previousRoom?.myHand ?? [],
|
||||
game: previousRoom?.game,
|
||||
})
|
||||
}
|
||||
|
||||
export function hydrateGameStoreFromActiveRoom(context: SocketHandlerContext, routeRoomId: string): void {
|
||||
const room = context.roomMeta.value
|
||||
if (!room) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetRoomId = routeRoomId || room.roomId
|
||||
if (!targetRoomId || room.roomId !== targetRoomId) {
|
||||
return
|
||||
}
|
||||
|
||||
context.gameStore.roomId = room.roomId
|
||||
}
|
||||
40
src/views/chengdu/socket/router/socketMessageRouter.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { PlayerHandlerApi, RoomHandlerApi, StatusHandlerApi, TurnHandlerApi } from '../types'
|
||||
|
||||
interface SocketMessageRouterDeps {
|
||||
roomHandlers: RoomHandlerApi
|
||||
playerHandlers: PlayerHandlerApi
|
||||
turnHandlers: TurnHandlerApi
|
||||
statusHandlers: StatusHandlerApi
|
||||
}
|
||||
|
||||
type SocketRouteHandler = (message: unknown) => void
|
||||
|
||||
export function createSocketMessageRouter(deps: SocketMessageRouterDeps) {
|
||||
const routes = new Map<string, SocketRouteHandler[]>([
|
||||
['GET_ROOM_INFO', [deps.roomHandlers.handleRoomInfoResponse]],
|
||||
['ROOM_INFO', [deps.roomHandlers.handleRoomInfoResponse]],
|
||||
['ROOM_STATE', [deps.roomHandlers.handleRoomStateResponse]],
|
||||
['PLAYER_HAND', [deps.playerHandlers.handlePlayerHandResponse]],
|
||||
['PLAYER_ALLOW_ACTION', [deps.turnHandlers.handlePlayerAllowAction]],
|
||||
['PLAYER_TURN', [deps.turnHandlers.handlePlayerTurn]],
|
||||
['NEXT_TURN', [deps.turnHandlers.handlePlayerTurn]],
|
||||
['ACTION_ACK', [deps.statusHandlers.handleActionAck]],
|
||||
['ACTION_ERROR', [deps.statusHandlers.handleActionError]],
|
||||
['DING_QUE_COUNTDOWN', [deps.turnHandlers.handleDingQueCountdown]],
|
||||
['PLAYER_READY', [deps.playerHandlers.handleReadyStateResponse]],
|
||||
['PLAYER_DING_QUE', [deps.playerHandlers.handlePlayerDingQueResponse]],
|
||||
])
|
||||
|
||||
function route(normalizedType: string, message: unknown): void {
|
||||
const handlers = routes.get(normalizedType)
|
||||
if (!handlers) {
|
||||
return
|
||||
}
|
||||
|
||||
handlers.forEach((handler) => handler(message))
|
||||
}
|
||||
|
||||
return {
|
||||
route,
|
||||
}
|
||||
}
|
||||
49
src/views/chengdu/socket/session/gameActionEffects.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { GameAction, RoomPlayerUpdatePayload, RoomTrusteePayload } from '../../../../game/actions'
|
||||
import { readPlayerTurnPlayerId } from '../../../../game/chengdu/messageNormalizers'
|
||||
import {
|
||||
clearClaimAndTurnPending,
|
||||
clearReadyTogglePending,
|
||||
clearRoomCountdown,
|
||||
clearSelectedDiscard,
|
||||
clearStartGamePending,
|
||||
completeDiscard,
|
||||
} from './sessionStateAdapter'
|
||||
import type { SocketHandlerContext } from '../types'
|
||||
|
||||
export function applyGameActionSessionEffects(
|
||||
context: SocketHandlerContext,
|
||||
gameAction: GameAction,
|
||||
onRoomPlayerUpdate: (payload: RoomPlayerUpdatePayload) => void,
|
||||
onRoomTrustee: (payload: RoomTrusteePayload) => void,
|
||||
): void {
|
||||
if (gameAction.type === 'GAME_START') {
|
||||
clearStartGamePending(context.session)
|
||||
clearRoomCountdown(context.session)
|
||||
}
|
||||
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === context.session.loggedInUserId.value) {
|
||||
completeDiscard(context.session)
|
||||
}
|
||||
if (
|
||||
gameAction.type === 'PLAY_TILE' ||
|
||||
gameAction.type === 'PENDING_CLAIM' ||
|
||||
gameAction.type === 'CLAIM_RESOLVED'
|
||||
) {
|
||||
clearRoomCountdown(context.session)
|
||||
}
|
||||
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
|
||||
onRoomPlayerUpdate(gameAction.payload)
|
||||
clearReadyTogglePending(context.session)
|
||||
}
|
||||
if (gameAction.type === 'CLAIM_RESOLVED') {
|
||||
clearClaimAndTurnPending(context.session)
|
||||
}
|
||||
if (gameAction.type === 'ROOM_TRUSTEE') {
|
||||
onRoomTrustee(gameAction.payload)
|
||||
}
|
||||
if (
|
||||
gameAction.type === 'PLAYER_TURN' &&
|
||||
readPlayerTurnPlayerId(gameAction.payload as Record<string, unknown>) !== context.session.loggedInUserId.value
|
||||
) {
|
||||
clearSelectedDiscard(context.session)
|
||||
}
|
||||
}
|
||||
79
src/views/chengdu/socket/session/sessionStateAdapter.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { PlayerActionTimer } from '../../types'
|
||||
import type { useChengduGameSession } from '../../composables/useChengduGameSession'
|
||||
|
||||
type ChengduGameSession = ReturnType<typeof useChengduGameSession>
|
||||
|
||||
export function pushWsMessage(session: ChengduGameSession, message: string): void {
|
||||
session.wsMessages.value.push(message)
|
||||
}
|
||||
|
||||
export function setWsError(session: ChengduGameSession, message: string): void {
|
||||
session.wsError.value = message
|
||||
}
|
||||
|
||||
export function syncCurrentUserId(session: ChengduGameSession, userId: string): void {
|
||||
session.syncCurrentUserID(userId)
|
||||
}
|
||||
|
||||
export function clearClaimAndTurnPending(session: ChengduGameSession): void {
|
||||
session.claimActionPending.value = false
|
||||
session.clearTurnActionPending()
|
||||
}
|
||||
|
||||
export function clearTurnPending(session: ChengduGameSession): void {
|
||||
session.clearTurnActionPending()
|
||||
}
|
||||
|
||||
export function clearReadyTogglePending(session: ChengduGameSession): void {
|
||||
session.readyTogglePending.value = false
|
||||
}
|
||||
|
||||
export function clearStartGamePending(session: ChengduGameSession): void {
|
||||
session.startGamePending.value = false
|
||||
}
|
||||
|
||||
export function clearDingQuePending(session: ChengduGameSession): void {
|
||||
session.dingQuePending.value = false
|
||||
}
|
||||
|
||||
export function clearNextRoundPending(session: ChengduGameSession): void {
|
||||
session.nextRoundPending.value = false
|
||||
}
|
||||
|
||||
export function resetSettlementOverlayState(session: ChengduGameSession): void {
|
||||
session.nextRoundPending.value = false
|
||||
session.settlementOverlayDismissed.value = false
|
||||
session.settlementDeadlineMs.value = null
|
||||
}
|
||||
|
||||
export function setSettlementDeadline(session: ChengduGameSession, deadlineMs: number | null): void {
|
||||
session.settlementDeadlineMs.value = deadlineMs
|
||||
}
|
||||
|
||||
export function clearRoomCountdown(session: ChengduGameSession): void {
|
||||
session.roomCountdown.value = null
|
||||
}
|
||||
|
||||
export function setRoomCountdown(session: ChengduGameSession, countdown: PlayerActionTimer): void {
|
||||
session.roomCountdown.value = countdown
|
||||
}
|
||||
|
||||
export function completeDiscard(session: ChengduGameSession): void {
|
||||
session.markDiscardCompleted()
|
||||
}
|
||||
|
||||
export function clearSelectedDiscard(session: ChengduGameSession): void {
|
||||
session.selectedDiscardTileId.value = null
|
||||
}
|
||||
|
||||
export function setSelfTurnAllowActions(session: ChengduGameSession, actions: string[]): void {
|
||||
session.selfTurnAllowActions.value = actions
|
||||
}
|
||||
|
||||
export function clearSelfTurnAllowActions(session: ChengduGameSession): void {
|
||||
session.selfTurnAllowActions.value = []
|
||||
}
|
||||
|
||||
export function setTrustMode(session: ChengduGameSession, enabled: boolean): void {
|
||||
session.isTrustMode.value = enabled
|
||||
}
|
||||
109
src/views/chengdu/socket/store/gameStoreAdapter.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { PendingClaimState, PlayerState, Tile } from '../../../../types/state'
|
||||
import type { ChengduSocketGameStore } from '../types'
|
||||
|
||||
interface ApplyRoomSnapshotOptions {
|
||||
roomId: string
|
||||
players?: Record<string, PlayerState>
|
||||
phase: string
|
||||
wallCount?: number | null
|
||||
dealerIndex?: number | null
|
||||
currentTurn?: number | null
|
||||
needDraw: boolean
|
||||
pendingClaim?: PendingClaimState
|
||||
scores?: Record<string, number>
|
||||
winners?: string[]
|
||||
currentRound?: number | null
|
||||
totalRounds?: number | null
|
||||
}
|
||||
|
||||
const phaseMap: Record<string, ChengduSocketGameStore['phase']> = {
|
||||
waiting: 'waiting',
|
||||
dealing: 'dealing',
|
||||
ding_que: 'dealing',
|
||||
playing: 'playing',
|
||||
action: 'action',
|
||||
settlement: 'settlement',
|
||||
finished: 'settlement',
|
||||
}
|
||||
|
||||
function normalizePhase(phase: string, fallback: ChengduSocketGameStore['phase']): ChengduSocketGameStore['phase'] {
|
||||
return phaseMap[phase] ?? fallback
|
||||
}
|
||||
|
||||
export function applyRoomSnapshot(gameStore: ChengduSocketGameStore, options: ApplyRoomSnapshotOptions): void {
|
||||
gameStore.roomId = options.roomId
|
||||
if (options.players && Object.keys(options.players).length > 0) {
|
||||
gameStore.players = options.players
|
||||
}
|
||||
|
||||
gameStore.phase = normalizePhase(options.phase, gameStore.phase)
|
||||
|
||||
if (typeof options.wallCount === 'number') {
|
||||
gameStore.remainingTiles = options.wallCount
|
||||
}
|
||||
if (typeof options.dealerIndex === 'number') {
|
||||
gameStore.dealerIndex = options.dealerIndex
|
||||
}
|
||||
if (typeof options.currentTurn === 'number') {
|
||||
gameStore.currentTurn = options.currentTurn
|
||||
}
|
||||
|
||||
gameStore.needDraw = options.needDraw
|
||||
gameStore.pendingClaim = options.pendingClaim
|
||||
gameStore.scores = options.scores ?? {}
|
||||
gameStore.winners = options.winners ?? []
|
||||
|
||||
if (typeof options.currentRound === 'number') {
|
||||
gameStore.currentRound = options.currentRound
|
||||
}
|
||||
if (typeof options.totalRounds === 'number') {
|
||||
gameStore.totalRounds = options.totalRounds
|
||||
}
|
||||
}
|
||||
|
||||
export function setPlayerReadyState(gameStore: ChengduSocketGameStore, playerId: string, ready: boolean): void {
|
||||
const player = gameStore.players[playerId]
|
||||
if (player) {
|
||||
player.isReady = ready
|
||||
}
|
||||
}
|
||||
|
||||
export function setPlayerHandState(
|
||||
gameStore: ChengduSocketGameStore,
|
||||
playerId: string,
|
||||
handTiles: Tile[],
|
||||
): void {
|
||||
const player = gameStore.players[playerId]
|
||||
if (player) {
|
||||
player.handTiles = handTiles
|
||||
player.handCount = handTiles.length
|
||||
}
|
||||
}
|
||||
|
||||
export function setPlayerMissingSuit(
|
||||
gameStore: ChengduSocketGameStore,
|
||||
playerId: string,
|
||||
missingSuit: string | null,
|
||||
): void {
|
||||
const player = gameStore.players[playerId]
|
||||
if (player) {
|
||||
player.missingSuit = missingSuit
|
||||
}
|
||||
}
|
||||
|
||||
export function setPlayerTrusteeState(gameStore: ChengduSocketGameStore, playerId: string, trustee: boolean): void {
|
||||
const player = gameStore.players[playerId]
|
||||
if (player) {
|
||||
player.isTrustee = trustee
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRoundResolutionState(gameStore: ChengduSocketGameStore): void {
|
||||
gameStore.pendingClaim = undefined
|
||||
gameStore.winners = []
|
||||
|
||||
Object.values(gameStore.players).forEach((player) => {
|
||||
player.missingSuit = null
|
||||
player.hasHu = false
|
||||
})
|
||||
}
|
||||
63
src/views/chengdu/socket/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import type { PlayerTurnPayload, RoomPlayerUpdatePayload, RoomTrusteePayload } from '../../../game/actions'
|
||||
import type { RoomMetaSnapshotState } from '../../../store/state'
|
||||
import type { PlayerState, Tile } from '../../../types/state'
|
||||
import type { DisplayPlayer } from '../types'
|
||||
import type { useChengduGameSession } from '../composables/useChengduGameSession'
|
||||
|
||||
export interface ChengduSocketGameStore {
|
||||
roomId: string
|
||||
phase: string
|
||||
players: Record<string, PlayerState>
|
||||
dealerIndex: number
|
||||
currentTurn: number
|
||||
remainingTiles: number
|
||||
needDraw: boolean
|
||||
pendingClaim?: unknown
|
||||
scores: Record<string, number>
|
||||
winners: string[]
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
resetGame: () => void
|
||||
}
|
||||
|
||||
export interface SocketHandlerContext {
|
||||
router: { push: (to: string) => Promise<unknown> | unknown }
|
||||
gameStore: ChengduSocketGameStore
|
||||
roomMeta: Ref<RoomMetaSnapshotState | null>
|
||||
roomName: ComputedRef<string>
|
||||
myHandTiles: ComputedRef<Tile[]>
|
||||
myPlayer: ComputedRef<DisplayPlayer | undefined>
|
||||
session: ReturnType<typeof useChengduGameSession>
|
||||
}
|
||||
|
||||
export interface ReadyStateApi {
|
||||
applyPlayerReadyState: (playerId: string, ready: boolean) => void
|
||||
syncReadyStatesFromRoomUpdate: (payload: RoomPlayerUpdatePayload) => void
|
||||
}
|
||||
|
||||
export interface RoomHandlerApi {
|
||||
handleRoomStateResponse: (message: unknown) => void
|
||||
handleRoomInfoResponse: (message: unknown) => void
|
||||
hydrateFromActiveRoom: (routeRoomId: string) => void
|
||||
}
|
||||
|
||||
export interface PlayerHandlerApi extends ReadyStateApi {
|
||||
handlePlayerHandResponse: (message: unknown) => void
|
||||
handleReadyStateResponse: (message: unknown) => void
|
||||
handlePlayerDingQueResponse: (message: unknown) => void
|
||||
syncTrusteeState: (payload: RoomTrusteePayload) => void
|
||||
}
|
||||
|
||||
export interface TurnHandlerApi {
|
||||
handleDingQueCountdown: (message: unknown) => void
|
||||
handlePlayerAllowAction: (message: unknown) => void
|
||||
handlePlayerTurn: (message: unknown) => void
|
||||
}
|
||||
|
||||
export interface StatusHandlerApi {
|
||||
handleActionAck: (message: unknown) => void
|
||||
handleActionError: (message: unknown) => void
|
||||
}
|
||||
|
||||
export type TurnPayloadRecord = PlayerTurnPayload & Record<string, unknown>
|
||||
116
src/views/chengdu/types.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type {ComputedRef, Ref} from 'vue'
|
||||
import type {SeatPlayerCardModel} from '../../components/game/seat-player-card'
|
||||
import type {SeatKey} from '../../game/seat'
|
||||
import type {RoomMetaSnapshotState} from '../../store/state'
|
||||
import type {PlayerState} from '../../types/state'
|
||||
import type {Tile} from '../../types/tile'
|
||||
|
||||
export type DisplayPlayer = PlayerState & {
|
||||
displayName?: string
|
||||
missingSuit?: string | null
|
||||
}
|
||||
|
||||
export type HandSuitLabel = '万' | '筒' | '条'
|
||||
export type TableTileImageType = 'hand' | 'exposed' | 'covered'
|
||||
|
||||
export interface WallTileItem {
|
||||
key: string
|
||||
src: string
|
||||
alt: string
|
||||
imageType: TableTileImageType
|
||||
isGroupStart?: boolean
|
||||
showLackTag?: boolean
|
||||
suit?: Tile['suit']
|
||||
tile?: Tile
|
||||
}
|
||||
|
||||
export interface WallSeatState {
|
||||
tiles: WallTileItem[]
|
||||
}
|
||||
|
||||
export interface DeskSeatState {
|
||||
tiles: WallTileItem[]
|
||||
hasHu: boolean
|
||||
}
|
||||
|
||||
export interface SeatViewModel {
|
||||
key: SeatKey
|
||||
player?: DisplayPlayer
|
||||
isSelf: boolean
|
||||
isTurn: boolean
|
||||
}
|
||||
|
||||
export interface PlayerActionTimer {
|
||||
playerIds: string[]
|
||||
actionDeadlineAt?: string | null
|
||||
countdownSeconds: number
|
||||
duration: number
|
||||
remaining: number
|
||||
}
|
||||
|
||||
export interface ActionCountdownView {
|
||||
playerLabel: string
|
||||
remaining: number
|
||||
duration: number
|
||||
isSelf: boolean
|
||||
progress: number
|
||||
}
|
||||
|
||||
export interface TableViewDeps {
|
||||
roomMeta: Ref<RoomMetaSnapshotState | null>
|
||||
gamePlayers: ComputedRef<DisplayPlayer[]>
|
||||
gameStore: {
|
||||
roomId: string
|
||||
phase: string
|
||||
remainingTiles: number
|
||||
dealerIndex: number
|
||||
currentTurn: number
|
||||
currentRound: number
|
||||
totalRounds: number
|
||||
winners: string[]
|
||||
scores: Record<string, number>
|
||||
}
|
||||
localCachedAvatarUrl: ComputedRef<string>
|
||||
loggedInUserId: ComputedRef<string>
|
||||
loggedInUserName: ComputedRef<string>
|
||||
myHandTiles: ComputedRef<Tile[]>
|
||||
myPlayer: ComputedRef<DisplayPlayer | undefined>
|
||||
routeRoomName: ComputedRef<string>
|
||||
}
|
||||
|
||||
export interface TableViewResult {
|
||||
roomName: ComputedRef<string>
|
||||
roomState: ComputedRef<{
|
||||
roomId: string
|
||||
name: string
|
||||
playerCount: number
|
||||
maxPlayers: number
|
||||
status: string
|
||||
game: {
|
||||
state: {
|
||||
wall: string[]
|
||||
dealerIndex: number
|
||||
currentTurn: number
|
||||
phase: string
|
||||
}
|
||||
}
|
||||
}>
|
||||
seatViews: ComputedRef<SeatViewModel[]>
|
||||
seatWinds: ComputedRef<Record<SeatKey, string>>
|
||||
currentTurnSeat: ComputedRef<SeatKey | ''>
|
||||
currentPhaseText: ComputedRef<string>
|
||||
roomStatusText: ComputedRef<string>
|
||||
roundText: ComputedRef<string>
|
||||
visibleHandTileGroups: ComputedRef<Array<{ suit: HandSuitLabel; tiles: Tile[] }>>
|
||||
sortedVisibleHandTiles: ComputedRef<Tile[]>
|
||||
wallSeats: ComputedRef<Record<SeatKey, WallSeatState>>
|
||||
deskSeats: ComputedRef<Record<SeatKey, DeskSeatState>>
|
||||
seatDecor: ComputedRef<Record<SeatKey, SeatPlayerCardModel>>
|
||||
settlementPlayers: ComputedRef<Array<{
|
||||
playerId: string
|
||||
displayName: string
|
||||
score: number
|
||||
isWinner: boolean
|
||||
seatIndex: number
|
||||
}>>
|
||||
}
|
||||
@@ -38,10 +38,15 @@ class WsClient {
|
||||
private buildUrl(): string {
|
||||
if (!this.token) return this.url
|
||||
|
||||
const hasQuery = this.url.includes('?')
|
||||
const connector = hasQuery ? '&' : '?'
|
||||
|
||||
return `${this.url}${connector}token=${encodeURIComponent(this.token)}`
|
||||
try {
|
||||
const parsed = new URL(this.url)
|
||||
parsed.searchParams.set('token', this.token)
|
||||
return parsed.toString()
|
||||
} catch {
|
||||
const hasQuery = this.url.includes('?')
|
||||
const connector = hasQuery ? '&' : '?'
|
||||
return `${this.url}${connector}token=${encodeURIComponent(this.token)}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,8 +77,10 @@ class WsClient {
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
console.log('[WS:RECV]', data)
|
||||
this.messageHandlers.forEach(fn => fn(data))
|
||||
} catch {
|
||||
console.log('[WS:RECV]', event.data)
|
||||
this.messageHandlers.forEach(fn => fn(event.data))
|
||||
}
|
||||
}
|
||||
@@ -127,6 +134,7 @@ class WsClient {
|
||||
// 订阅状态变化
|
||||
onStatusChange(handler: StatusHandler) {
|
||||
this.statusHandlers.push(handler)
|
||||
handler(this.status)
|
||||
return () => {
|
||||
this.statusHandlers = this.statusHandlers.filter(fn => fn !== handler)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export function registerHandler(type: string, handler: Handler) {
|
||||
// 初始化监听
|
||||
export function initWsHandler() {
|
||||
wsClient.onMessage((msg) => {
|
||||
console.log('[WS] 收到消息:', msg)
|
||||
const handlers = handlerMap[msg.type]
|
||||
|
||||
if (handlers && handlers.length > 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws'
|
||||
|
||||
export function buildWsUrl(token: string): string {
|
||||
export function buildWsUrl(): string {
|
||||
const baseUrl = /^wss?:\/\//.test(WS_BASE_URL)
|
||||
? new URL(WS_BASE_URL)
|
||||
: new URL(
|
||||
@@ -8,6 +8,5 @@ export function buildWsUrl(token: string): string {
|
||||
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
|
||||
)
|
||||
|
||||
baseUrl.searchParams.set('token', token)
|
||||
return baseUrl.toString()
|
||||
}
|
||||
}
|
||||
|
||||
6
test-results/.last-run.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"782a5ad254513972d5b8-1549d022f94e0e4cc886"
|
||||
]
|
||||
}
|
||||
292
tests/e2e/room-flow.live.spec.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { expect, request, test, type Browser, type BrowserContext, type Page } from 'playwright/test'
|
||||
|
||||
const liveEnabled = process.env.PLAYWRIGHT_LIVE === '1'
|
||||
const apiBaseURL = process.env.E2E_LIVE_API_BASE_URL ?? 'http://127.0.0.1:19000'
|
||||
const password = process.env.E2E_LIVE_PASSWORD ?? 'Passw0rd!'
|
||||
const configuredUsers = (process.env.E2E_LIVE_USERS ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
type PlayerPage = {
|
||||
context: BrowserContext
|
||||
page: Page
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
test.skip(!liveEnabled, 'set PLAYWRIGHT_LIVE=1 to run live integration flow')
|
||||
|
||||
test('live room flow: create, join, ready, start, ding que, multi-turn discard', async ({ browser }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
const api = await request.newContext({
|
||||
baseURL: apiBaseURL,
|
||||
extraHTTPHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const players: PlayerPage[] = []
|
||||
|
||||
try {
|
||||
await expectLiveStackReady(api)
|
||||
|
||||
if (configuredUsers.length > 0) {
|
||||
for (const username of configuredUsers) {
|
||||
players.push(
|
||||
await openPlayerPage(browser, {
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const sessions = await Promise.all(
|
||||
Array.from({ length: 4 }, (_, index) => createLiveUserSession(api, index)),
|
||||
)
|
||||
for (const session of sessions) {
|
||||
players.push(await openPlayerPage(browser, session))
|
||||
}
|
||||
}
|
||||
|
||||
const [owner, guest2, guest3, guest4] = players
|
||||
|
||||
await owner.page.goto('/hall')
|
||||
await expect(owner.page.getByTestId('open-create-room')).toBeVisible()
|
||||
await owner.page.getByTestId('open-create-room').click()
|
||||
await owner.page.getByTestId('create-room-name').fill(`live-room-${Date.now()}`)
|
||||
const createRoomResponsePromise = owner.page.waitForResponse((response) => {
|
||||
return response.url().includes('/api/v1/game/mahjong/room/create') && response.request().method() === 'POST'
|
||||
})
|
||||
await owner.page.getByTestId('submit-create-room').click()
|
||||
|
||||
const createRoomResponse = await createRoomResponsePromise
|
||||
if (!createRoomResponse.ok()) {
|
||||
throw new Error(
|
||||
`create room failed: status=${createRoomResponse.status()} body=${await createRoomResponse.text()} request=${createRoomResponse.request().postData() ?? ''}`,
|
||||
)
|
||||
}
|
||||
const createRoomPayload = (await createRoomResponse.json()) as { data?: { room_id?: string; name?: string } }
|
||||
const roomID = createRoomPayload.data?.room_id
|
||||
if (!roomID) {
|
||||
throw new Error('live room id not found after creating room')
|
||||
}
|
||||
const roomName = createRoomPayload.data?.name ?? ''
|
||||
|
||||
const enterCreatedRoomButton = owner.page.getByTestId('enter-created-room')
|
||||
if (await enterCreatedRoomButton.isVisible().catch(() => false)) {
|
||||
await enterCreatedRoomButton.click()
|
||||
} else {
|
||||
await owner.page.goto(`/game/chengdu/${roomID}${roomName ? `?roomName=${encodeURIComponent(roomName)}` : ''}`)
|
||||
}
|
||||
|
||||
await expect(owner.page).toHaveURL(new RegExp(`/game/chengdu/${roomID}`))
|
||||
|
||||
for (const guest of [guest2, guest3, guest4]) {
|
||||
await guest.page.goto('/hall')
|
||||
await expect(guest.page.getByTestId('quick-join-room-id')).toBeVisible()
|
||||
await guest.page.getByTestId('quick-join-room-id').fill(roomID)
|
||||
await guest.page.getByTestId('quick-join-submit').click()
|
||||
await expect(guest.page).toHaveURL(new RegExp(`/game/chengdu/${roomID}(\\?.*)?$`))
|
||||
}
|
||||
|
||||
for (const player of players) {
|
||||
await expect(player.page.getByTestId('ready-toggle')).toBeVisible()
|
||||
await player.page.getByTestId('ready-toggle').click()
|
||||
}
|
||||
|
||||
await expect(owner.page.getByTestId('start-game')).toBeEnabled({ timeout: 20_000 })
|
||||
await owner.page.getByTestId('start-game').click()
|
||||
|
||||
const dingQueChoices: Array<'w' | 't' | 'b'> = ['w', 't', 'b', 'w']
|
||||
for (const [index, player] of players.entries()) {
|
||||
const suit = dingQueChoices[index]
|
||||
const dingQueButton = player.page.getByTestId(`ding-que-${suit}`)
|
||||
await expect(dingQueButton).toBeVisible({ timeout: 20_000 })
|
||||
await expect(dingQueButton).toBeEnabled()
|
||||
await dingQueButton.click()
|
||||
}
|
||||
|
||||
const discardActors: string[] = []
|
||||
let previousActor: PlayerPage | null = null
|
||||
|
||||
for (let turn = 0; turn < 4; turn += 1) {
|
||||
const actor = await findDiscardActor(players, 30_000, previousActor)
|
||||
const actorTiles = actor.page.locator('[data-testid^="hand-tile-"]')
|
||||
expect(await actorTiles.count()).toBeGreaterThan(0)
|
||||
expect(await countEnabledTiles(actor.page)).toBeGreaterThan(0)
|
||||
|
||||
await actorTiles.first().click()
|
||||
await expect
|
||||
.poll(() => countEnabledTiles(actor.page), { timeout: 20_000 })
|
||||
.toBe(0)
|
||||
|
||||
await resolvePendingClaims(players, 10_000)
|
||||
await drawIfNeeded(players, 10_000)
|
||||
|
||||
discardActors.push(actor.username)
|
||||
previousActor = actor
|
||||
}
|
||||
|
||||
expect(new Set(discardActors).size).toBeGreaterThan(1)
|
||||
} finally {
|
||||
await api.dispose()
|
||||
await Promise.all(players.map(async ({ context }) => context.close()))
|
||||
}
|
||||
})
|
||||
|
||||
async function expectLiveStackReady(api: Awaited<ReturnType<typeof request.newContext>>): Promise<void> {
|
||||
const response = await api.get('/healthz')
|
||||
expect(response.ok()).toBeTruthy()
|
||||
}
|
||||
|
||||
async function createLiveUserSession(
|
||||
api: Awaited<ReturnType<typeof request.newContext>>,
|
||||
index: number,
|
||||
): Promise<{ username: string; password: string }> {
|
||||
const seed = `${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const username = `pwlive_${seed}`
|
||||
const timestampDigits = String(Date.now()).slice(-8)
|
||||
const randomDigit = Math.floor(Math.random() * 10)
|
||||
const phone = `13${timestampDigits}${index}${randomDigit}`
|
||||
const email = `${username}@example.com`
|
||||
|
||||
const registerResponse = await api.post('/api/v1/auth/register', {
|
||||
data: {
|
||||
username,
|
||||
phone,
|
||||
email,
|
||||
password,
|
||||
},
|
||||
})
|
||||
expect(registerResponse.ok(), await registerResponse.text()).toBeTruthy()
|
||||
|
||||
const loginResponse = await api.post('/api/v1/auth/login', {
|
||||
data: {
|
||||
login_id: username,
|
||||
password,
|
||||
},
|
||||
})
|
||||
expect(loginResponse.ok(), await loginResponse.text()).toBeTruthy()
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
async function openPlayerPage(
|
||||
browser: Browser,
|
||||
session: { username: string; password: string },
|
||||
): Promise<PlayerPage> {
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
await page.goto('/login')
|
||||
await page.getByTestId('login-id').fill(session.username)
|
||||
await page.getByTestId('login-password').fill(session.password)
|
||||
const loginResponsePromise = page.waitForResponse((response) => {
|
||||
return response.url().includes('/api/v1/auth/login') && response.request().method() === 'POST'
|
||||
})
|
||||
await page.getByTestId('login-submit').click()
|
||||
const loginResponse = await loginResponsePromise
|
||||
if (!loginResponse.ok()) {
|
||||
throw new Error(
|
||||
`browser login failed: status=${loginResponse.status()} body=${await loginResponse.text()} request=${loginResponse.request().postData() ?? ''}`,
|
||||
)
|
||||
}
|
||||
await expect(page).toHaveURL(/\/hall$/)
|
||||
return {
|
||||
context,
|
||||
page,
|
||||
username: session.username,
|
||||
password: session.password,
|
||||
}
|
||||
}
|
||||
|
||||
async function findDiscardActor(
|
||||
players: PlayerPage[],
|
||||
timeoutMs: number,
|
||||
previousActor?: PlayerPage | null,
|
||||
): Promise<PlayerPage> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const player of players) {
|
||||
if (previousActor && player.username === previousActor.username) {
|
||||
continue
|
||||
}
|
||||
const firstTile = player.page.locator('[data-testid^="hand-tile-"]').first()
|
||||
const tileCount = await player.page.locator('[data-testid^="hand-tile-"]').count()
|
||||
if (tileCount === 0) {
|
||||
continue
|
||||
}
|
||||
if (await firstTile.isEnabled().catch(() => false)) {
|
||||
return player
|
||||
}
|
||||
}
|
||||
await players[0]?.page.waitForTimeout(250)
|
||||
}
|
||||
|
||||
const diagnostics = await Promise.all(
|
||||
players.map(async (player) => {
|
||||
const logs = await player.page.locator('.sidebar-line').allTextContents().catch(() => [])
|
||||
const claimBarVisible = await player.page.getByTestId('claim-action-bar').isVisible().catch(() => false)
|
||||
const passVisible = await player.page.getByTestId('claim-pass').isVisible().catch(() => false)
|
||||
const enabledTiles = await countEnabledTiles(player.page)
|
||||
return [
|
||||
`player=${player.username}`,
|
||||
`enabledTiles=${enabledTiles}`,
|
||||
`claimBar=${claimBarVisible}`,
|
||||
`claimPass=${passVisible}`,
|
||||
`logs=${logs.slice(0, 4).join(' || ')}`,
|
||||
].join(' ')
|
||||
}),
|
||||
)
|
||||
|
||||
throw new Error(`no player reached enabled discard state within timeout\n${diagnostics.join('\n')}`)
|
||||
}
|
||||
|
||||
async function countEnabledTiles(page: Page): Promise<number> {
|
||||
return page.locator('[data-testid^="hand-tile-"]:enabled').count()
|
||||
}
|
||||
|
||||
async function resolvePendingClaims(players: PlayerPage[], timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
let handled = false
|
||||
|
||||
for (const player of players) {
|
||||
const passButton = player.page.getByTestId('claim-pass')
|
||||
if (await passButton.isVisible().catch(() => false)) {
|
||||
await expect(passButton).toBeEnabled({ timeout: 5_000 })
|
||||
await passButton.click()
|
||||
handled = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!handled) {
|
||||
return
|
||||
}
|
||||
|
||||
await players[0]?.page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
|
||||
async function drawIfNeeded(players: PlayerPage[], timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
for (const player of players) {
|
||||
const drawButton = player.page.getByTestId('draw-tile')
|
||||
if (await drawButton.isVisible().catch(() => false)) {
|
||||
await expect(drawButton).toBeEnabled({ timeout: 5_000 })
|
||||
await drawButton.click()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await players[0]?.page.waitForTimeout(250)
|
||||
}
|
||||
}
|
||||
383
tests/e2e/room-flow.spec.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { expect, test } from 'playwright/test'
|
||||
|
||||
test('enter room, ready, start game, ding que, and discard tile', async ({ page }) => {
|
||||
let createdRoom: Record<string, unknown> | null = null
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
'mahjong_auth',
|
||||
JSON.stringify({
|
||||
token: 'mock-access-token',
|
||||
tokenType: 'Bearer',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
user: {
|
||||
id: 'u-e2e-1',
|
||||
username: '测试玩家',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
type Tile = { id: number; suit: 'W' | 'T' | 'B'; value: number }
|
||||
type Player = {
|
||||
index: number
|
||||
player_id: string
|
||||
player_name: string
|
||||
ready: boolean
|
||||
missing_suit?: string | null
|
||||
}
|
||||
|
||||
const state: {
|
||||
roomId: string
|
||||
status: 'waiting' | 'playing'
|
||||
dealerIndex: number
|
||||
currentTurn: number
|
||||
selfId: string
|
||||
players: Player[]
|
||||
hand: Tile[]
|
||||
missingSuit: string | null
|
||||
} = {
|
||||
roomId: 'room-e2e-001',
|
||||
status: 'waiting',
|
||||
dealerIndex: 0,
|
||||
currentTurn: 0,
|
||||
selfId: 'u-e2e-1',
|
||||
players: [
|
||||
{ index: 0, player_id: 'u-e2e-1', player_name: '测试玩家', ready: false, missing_suit: null },
|
||||
{ index: 1, player_id: 'bot-2', player_name: '机器人二号', ready: true, missing_suit: null },
|
||||
{ index: 2, player_id: 'bot-3', player_name: '机器人三号', ready: true, missing_suit: null },
|
||||
{ index: 3, player_id: 'bot-4', player_name: '机器人四号', ready: true, missing_suit: null },
|
||||
],
|
||||
hand: [
|
||||
{ id: 11, suit: 'W', value: 1 },
|
||||
{ id: 12, suit: 'W', value: 2 },
|
||||
{ id: 13, suit: 'W', value: 3 },
|
||||
{ id: 21, suit: 'T', value: 4 },
|
||||
{ id: 22, suit: 'T', value: 5 },
|
||||
{ id: 23, suit: 'T', value: 6 },
|
||||
{ id: 31, suit: 'B', value: 1 },
|
||||
{ id: 32, suit: 'B', value: 2 },
|
||||
{ id: 33, suit: 'B', value: 3 },
|
||||
{ id: 34, suit: 'B', value: 4 },
|
||||
{ id: 35, suit: 'B', value: 5 },
|
||||
{ id: 36, suit: 'B', value: 6 },
|
||||
{ id: 37, suit: 'B', value: 7 },
|
||||
],
|
||||
missingSuit: null,
|
||||
}
|
||||
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
|
||||
const buildRoomPayload = () => ({
|
||||
room: {
|
||||
room_id: state.roomId,
|
||||
name: 'E2E 测试房间',
|
||||
game_type: 'chengdu',
|
||||
owner_id: state.selfId,
|
||||
max_players: 4,
|
||||
player_count: state.players.length,
|
||||
players: clone(state.players),
|
||||
status: state.status,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
game_state:
|
||||
state.status === 'playing'
|
||||
? {
|
||||
room_id: state.roomId,
|
||||
phase: 'playing',
|
||||
status: 'playing',
|
||||
wall_count: 55,
|
||||
current_turn_player: state.players.find((player) => player.index === state.currentTurn)?.player_id ?? '',
|
||||
players: state.players.map((player) => ({
|
||||
player_id: player.player_id,
|
||||
ding_que: player.missing_suit ?? '',
|
||||
ding_que_done: Boolean(player.missing_suit),
|
||||
hand_count: player.player_id === state.selfId ? state.hand.length : 13,
|
||||
melds: [],
|
||||
out_tiles: [],
|
||||
has_hu: false,
|
||||
})),
|
||||
scores: {},
|
||||
winners: [],
|
||||
}
|
||||
: null,
|
||||
player_view:
|
||||
state.status === 'playing'
|
||||
? {
|
||||
room_id: state.roomId,
|
||||
ding_que: state.missingSuit ?? '',
|
||||
hand: clone(state.hand),
|
||||
}
|
||||
: {
|
||||
room_id: state.roomId,
|
||||
hand: [],
|
||||
},
|
||||
})
|
||||
|
||||
class MockWebSocket {
|
||||
url: string
|
||||
readyState = 0
|
||||
onopen: ((event: Event) => void) | null = null
|
||||
onmessage: ((event: MessageEvent<string>) => void) | null = null
|
||||
onerror: ((event: Event) => void) | null = null
|
||||
onclose: ((event: CloseEvent) => void) | null = null
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
window.setTimeout(() => {
|
||||
this.readyState = 1
|
||||
this.onopen?.(new Event('open'))
|
||||
}, 0)
|
||||
}
|
||||
|
||||
send(raw: string) {
|
||||
const message = JSON.parse(raw) as {
|
||||
type?: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'get_room_info':
|
||||
this.emit({
|
||||
type: 'room_info',
|
||||
roomId: state.roomId,
|
||||
payload: buildRoomPayload(),
|
||||
})
|
||||
break
|
||||
case 'set_ready': {
|
||||
const nextReady = Boolean(message.payload?.ready)
|
||||
state.players = state.players.map((player) =>
|
||||
player.player_id === state.selfId ? { ...player, ready: nextReady } : player,
|
||||
)
|
||||
this.emit({
|
||||
type: 'set_ready',
|
||||
roomId: state.roomId,
|
||||
payload: {
|
||||
room_id: state.roomId,
|
||||
user_id: state.selfId,
|
||||
ready: nextReady,
|
||||
},
|
||||
})
|
||||
this.emit({
|
||||
type: 'room_player_update',
|
||||
roomId: state.roomId,
|
||||
payload: {
|
||||
room_id: state.roomId,
|
||||
status: 'waiting',
|
||||
player_ids: state.players.map((player) => player.player_id),
|
||||
players: clone(state.players),
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'start_game':
|
||||
state.status = 'playing'
|
||||
this.emit({
|
||||
type: 'room_state',
|
||||
roomId: state.roomId,
|
||||
payload: buildRoomPayload().game_state,
|
||||
})
|
||||
this.emit({
|
||||
type: 'player_hand',
|
||||
roomId: state.roomId,
|
||||
payload: {
|
||||
room_id: state.roomId,
|
||||
hand: clone(state.hand),
|
||||
},
|
||||
})
|
||||
break
|
||||
case 'ding_que': {
|
||||
const suit = typeof message.payload?.suit === 'string' ? message.payload.suit : ''
|
||||
state.missingSuit = suit || null
|
||||
state.players = state.players.map((player) =>
|
||||
player.player_id === state.selfId ? { ...player, missing_suit: state.missingSuit } : player,
|
||||
)
|
||||
this.emit({
|
||||
type: 'player_ding_que',
|
||||
roomId: state.roomId,
|
||||
payload: {
|
||||
room_id: state.roomId,
|
||||
player_id: state.selfId,
|
||||
suit,
|
||||
},
|
||||
})
|
||||
this.emit({
|
||||
type: 'room_state',
|
||||
roomId: state.roomId,
|
||||
payload: buildRoomPayload().game_state,
|
||||
})
|
||||
this.emit({
|
||||
type: 'player_hand',
|
||||
roomId: state.roomId,
|
||||
payload: {
|
||||
room_id: state.roomId,
|
||||
ding_que: state.missingSuit ?? '',
|
||||
hand: clone(state.hand),
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'discard': {
|
||||
const tile = message.payload?.tile as Tile | undefined
|
||||
if (!tile) {
|
||||
break
|
||||
}
|
||||
state.hand = state.hand.filter((item) => item.id !== tile.id)
|
||||
state.currentTurn = 1
|
||||
this.emit({
|
||||
type: 'room_state',
|
||||
roomId: state.roomId,
|
||||
payload: buildRoomPayload().game_state,
|
||||
})
|
||||
this.emit({
|
||||
type: 'player_hand',
|
||||
roomId: state.roomId,
|
||||
payload: {
|
||||
room_id: state.roomId,
|
||||
hand: clone(state.hand),
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3
|
||||
this.onclose?.(new CloseEvent('close'))
|
||||
}
|
||||
|
||||
private emit(payload: unknown) {
|
||||
window.setTimeout(() => {
|
||||
this.onmessage?.(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify(payload),
|
||||
}) as MessageEvent<string>,
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'WebSocket', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: MockWebSocket,
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/v1/user/info', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
userID: 'u-e2e-1',
|
||||
username: '测试玩家',
|
||||
nickname: '测试玩家',
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/v1/game/mahjong/room/list', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
items: createdRoom ? [createdRoom] : [],
|
||||
page: 1,
|
||||
size: 20,
|
||||
total: createdRoom ? 1 : 0,
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/v1/game/mahjong/room/create', async (route) => {
|
||||
const body = (route.request().postDataJSON() as Record<string, unknown> | null) ?? {}
|
||||
createdRoom = {
|
||||
room_id: 'room-e2e-001',
|
||||
name: typeof body.name === 'string' ? body.name : 'E2E 测试房间',
|
||||
game_type: typeof body.game_type === 'string' ? body.game_type : 'chengdu',
|
||||
owner_id: 'u-e2e-1',
|
||||
max_players: typeof body.max_players === 'number' ? body.max_players : 4,
|
||||
player_count: 1,
|
||||
players: [
|
||||
{
|
||||
index: 0,
|
||||
player_id: 'u-e2e-1',
|
||||
player_name: '测试玩家',
|
||||
ready: false,
|
||||
},
|
||||
],
|
||||
status: 'waiting',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: createdRoom,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/v1/game/mahjong/room/join', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: createdRoom ?? {
|
||||
room_id: 'room-e2e-001',
|
||||
name: 'E2E 测试房间',
|
||||
game_type: 'chengdu',
|
||||
owner_id: 'u-e2e-1',
|
||||
max_players: 4,
|
||||
player_count: 1,
|
||||
status: 'waiting',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/hall')
|
||||
|
||||
await expect(page.getByTestId('open-create-room')).toBeVisible()
|
||||
await page.getByTestId('open-create-room').click()
|
||||
await page.getByTestId('create-room-name').fill('E2E 测试房间')
|
||||
await page.getByTestId('submit-create-room').click()
|
||||
await expect(page.getByTestId('enter-created-room')).toBeVisible()
|
||||
await page.getByTestId('enter-created-room').click()
|
||||
|
||||
await expect(page).toHaveURL(/\/game\/chengdu\/room-e2e-001/)
|
||||
await expect(page.getByTestId('ready-toggle')).toBeVisible()
|
||||
await page.getByTestId('ready-toggle').click()
|
||||
|
||||
await expect(page.getByTestId('start-game')).toBeVisible()
|
||||
await page.getByTestId('start-game').click()
|
||||
|
||||
await expect(page.getByTestId('ding-que-w')).toBeVisible()
|
||||
await page.getByTestId('ding-que-w').click()
|
||||
|
||||
const handBar = page.getByTestId('hand-action-bar')
|
||||
await expect(handBar).toBeVisible()
|
||||
const tiles = handBar.locator('[data-testid^="hand-tile-"]')
|
||||
await expect(tiles).toHaveCount(13)
|
||||
await tiles.first().click()
|
||||
await expect(tiles).toHaveCount(12)
|
||||
})
|
||||
@@ -1,26 +1,36 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import {defineConfig, loadEnv} from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:19000').replace(/\/$/, '')
|
||||
const wsProxyTarget = (env.VITE_WS_PROXY_TARGET || apiProxyTarget).replace(/\/$/, '')
|
||||
export default defineConfig(({mode}) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:19000').replace(/\/$/, '')
|
||||
const wsProxyTarget = (env.VITE_WS_PROXY_TARGET || apiProxyTarget).replace(/\/$/, '')
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/ws': {
|
||||
target: wsProxyTarget,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewriteWsOrigin: true,
|
||||
return {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@src': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
'/api/v1': {
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 8080,
|
||||
proxy: {
|
||||
'/ws': {
|
||||
target: wsProxyTarget,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewriteWsOrigin: true,
|
||||
},
|
||||
'/api/v1': {
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||