Compare commits
23 Commits
6fde4bbc0d
...
dev-claude
| Author | SHA1 | Date | |
|---|---|---|---|
| 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_API_BASE_URL=/api/v1
|
||||||
VITE_GAME_WS_URL=/ws
|
VITE_GAME_WS_URL=/ws
|
||||||
VITE_API_PROXY_TARGET=http://192.168.1.5:19000
|
VITE_API_PROXY_TARGET=http://127.0.0.1:19000
|
||||||
VITE_WS_PROXY_TARGET=http://192.168.1.5:19000
|
VITE_WS_PROXY_TARGET=http://127.0.0.1:19000
|
||||||
|
|||||||
@@ -27,3 +27,6 @@ Preview the production build:
|
|||||||
```bash
|
```bash
|
||||||
pnpm preview
|
pnpm preview
|
||||||
```
|
```
|
||||||
|
|
||||||
|
测试账号:A,B,C,D
|
||||||
|
测试密码:123456
|
||||||
@@ -31,6 +31,7 @@ HTTP 接口:
|
|||||||
{
|
{
|
||||||
"name": "房间名",
|
"name": "房间名",
|
||||||
"game_type": "chengdu",
|
"game_type": "chengdu",
|
||||||
|
"total_rounds": 8,
|
||||||
"max_players": 4
|
"max_players": 4
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"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": {
|
"dependencies": {
|
||||||
"pinia": "^3.0.4",
|
"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 {
|
export interface AuthUser {
|
||||||
id?: string | number
|
id?: string | number
|
||||||
username?: string
|
username?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthSessionInput {
|
export interface AuthSessionInput {
|
||||||
token: string
|
token: string
|
||||||
tokenType?: string
|
tokenType?: string
|
||||||
refreshToken?: string
|
refreshToken?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResult {
|
export interface AuthResult {
|
||||||
token: string
|
token: string
|
||||||
tokenType?: string
|
tokenType?: string
|
||||||
refreshToken?: string
|
refreshToken?: string
|
||||||
expiresIn?: number
|
expiresIn?: number
|
||||||
user?: AuthUser
|
user?: AuthUser
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiErrorPayload {
|
interface ApiErrorPayload {
|
||||||
message?: string
|
message?: string
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').trim().replace(/\/$/, '')
|
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()
|
const LOGIN_BEARER_TOKEN = (import.meta.env.VITE_LOGIN_BEARER_TOKEN ?? '').trim()
|
||||||
|
|
||||||
function buildUrl(path: string): string {
|
function buildUrl(path: string): string {
|
||||||
if (/^https?:\/\//.test(path)) {
|
if (/^https?:\/\//.test(path)) {
|
||||||
return 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${basePath}${normalizedPath}`
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||||
}
|
if (!API_BASE_URL) {
|
||||||
|
return 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}`
|
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>(
|
async function request<T>(
|
||||||
url: string,
|
url: string,
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
extraHeaders?: Record<string, string>,
|
extraHeaders?: Record<string, string>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...extraHeaders,
|
...extraHeaders,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload
|
const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(payload.message ?? payload.error ?? '请求失败,请稍后再试')
|
throw new Error(payload.message ?? payload.error ?? '请求失败,请稍后再试')
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAuthHeader(token: string, tokenType = 'Bearer'): string {
|
function createAuthHeader(token: string, tokenType = 'Bearer'): string {
|
||||||
const normalizedToken = token.trim()
|
const normalizedToken = token.trim()
|
||||||
if (/^\S+\s+\S+/.test(normalizedToken)) {
|
if (/^\S+\s+\S+/.test(normalizedToken)) {
|
||||||
return normalizedToken
|
return normalizedToken
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${tokenType || 'Bearer'} ${normalizedToken}`
|
return `${tokenType || 'Bearer'} ${normalizedToken}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractToken(payload: Record<string, unknown>): string {
|
function extractToken(payload: Record<string, unknown>): string {
|
||||||
const candidate =
|
const candidate =
|
||||||
payload.token ??
|
payload.token ??
|
||||||
payload.accessToken ??
|
payload.accessToken ??
|
||||||
payload.access_token ??
|
payload.access_token ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.token ??
|
(payload.data as Record<string, unknown> | undefined)?.token ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.accessToken ??
|
(payload.data as Record<string, unknown> | undefined)?.accessToken ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.access_token
|
(payload.data as Record<string, unknown> | undefined)?.access_token
|
||||||
|
|
||||||
if (typeof candidate !== 'string' || candidate.length === 0) {
|
if (typeof candidate !== 'string' || candidate.length === 0) {
|
||||||
throw new Error('登录成功,但后端未返回 token 字段')
|
throw new Error('登录成功,但后端未返回 token 字段')
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTokenType(payload: Record<string, unknown>): string | undefined {
|
function extractTokenType(payload: Record<string, unknown>): string | undefined {
|
||||||
const candidate =
|
const candidate =
|
||||||
payload.token_type ??
|
payload.token_type ??
|
||||||
payload.tokenType ??
|
payload.tokenType ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.token_type ??
|
(payload.data as Record<string, unknown> | undefined)?.token_type ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.tokenType
|
(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 {
|
function extractRefreshToken(payload: Record<string, unknown>): string | undefined {
|
||||||
const candidate =
|
const candidate =
|
||||||
payload.refresh_token ??
|
payload.refresh_token ??
|
||||||
payload.refreshToken ??
|
payload.refreshToken ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.refresh_token ??
|
(payload.data as Record<string, unknown> | undefined)?.refresh_token ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.refreshToken
|
(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 {
|
function extractExpiresIn(payload: Record<string, unknown>): number | undefined {
|
||||||
const candidate =
|
const candidate =
|
||||||
payload.expires_in ??
|
payload.expires_in ??
|
||||||
payload.expiresIn ??
|
payload.expiresIn ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.expires_in ??
|
(payload.data as Record<string, unknown> | undefined)?.expires_in ??
|
||||||
(payload.data as Record<string, unknown> | undefined)?.expiresIn
|
(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 {
|
function extractUser(payload: Record<string, unknown>): AuthUser | undefined {
|
||||||
const user = payload.user ?? (payload.data as Record<string, unknown> | undefined)?.user
|
const user = payload.user ?? (payload.data as Record<string, unknown> | undefined)?.user
|
||||||
return typeof user === 'object' && user !== null ? (user as AuthUser) : undefined
|
return typeof user === 'object' && user !== null ? (user as AuthUser) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAuthResult(payload: Record<string, unknown>): AuthResult {
|
function parseAuthResult(payload: Record<string, unknown>): AuthResult {
|
||||||
return {
|
return {
|
||||||
token: extractToken(payload),
|
token: extractToken(payload),
|
||||||
tokenType: extractTokenType(payload),
|
tokenType: extractTokenType(payload),
|
||||||
refreshToken: extractRefreshToken(payload),
|
refreshToken: extractRefreshToken(payload),
|
||||||
expiresIn: extractExpiresIn(payload),
|
expiresIn: extractExpiresIn(payload),
|
||||||
user: extractUser(payload),
|
user: extractUser(payload),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function register(input: {
|
export async function register(input: {
|
||||||
username: string
|
username: string
|
||||||
phone: string
|
phone: string
|
||||||
email: string
|
email: string
|
||||||
password: string
|
password: string
|
||||||
}): Promise<void> {
|
}): 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> {
|
export async function login(input: { loginId: string; password: string }): Promise<AuthResult> {
|
||||||
const payload = await request<Record<string, unknown>>(
|
const payload = await request<Record<string, unknown>>(
|
||||||
buildUrl(LOGIN_PATH),
|
buildUrl(LOGIN_PATH),
|
||||||
{
|
{
|
||||||
login_id: input.loginId,
|
login_id: input.loginId,
|
||||||
password: input.password,
|
password: input.password,
|
||||||
},
|
},
|
||||||
LOGIN_BEARER_TOKEN ? { Authorization: `Bearer ${LOGIN_BEARER_TOKEN}` } : undefined,
|
LOGIN_BEARER_TOKEN ? {Authorization: `Bearer ${LOGIN_BEARER_TOKEN}`} : undefined,
|
||||||
)
|
)
|
||||||
return parseAuthResult(payload)
|
return parseAuthResult(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshAccessToken(input: AuthSessionInput): Promise<AuthResult> {
|
export async function refreshAccessToken(input: AuthSessionInput): Promise<AuthResult> {
|
||||||
if (!input.refreshToken) {
|
if (!input.refreshToken) {
|
||||||
throw new Error('缺少 refresh_token,无法刷新登录状态')
|
throw new Error('缺少 refresh_token,无法刷新登录状态')
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await request<Record<string, unknown>>(
|
const refreshBody = {
|
||||||
buildUrl(REFRESH_PATH),
|
refreshToken: input.refreshToken
|
||||||
{
|
}
|
||||||
refreshToken: input.refreshToken,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Authorization: createAuthHeader(input.token, input.tokenType),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const ROOM_JOIN_PATH = import.meta.env.VITE_ROOM_JOIN_PATH ?? '/api/v1/game/mahj
|
|||||||
|
|
||||||
export async function createRoom(
|
export async function createRoom(
|
||||||
auth: AuthSession,
|
auth: AuthSession,
|
||||||
input: { name: string; gameType: string; maxPlayers: number },
|
input: { name: string; gameType: string; totalRounds: number; maxPlayers: number },
|
||||||
onAuthUpdated?: (next: AuthSession) => void,
|
onAuthUpdated?: (next: AuthSession) => void,
|
||||||
): Promise<RoomItem> {
|
): Promise<RoomItem> {
|
||||||
return authedRequest<RoomItem>({
|
return authedRequest<RoomItem>({
|
||||||
@@ -44,6 +44,7 @@ export async function createRoom(
|
|||||||
body: {
|
body: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
game_type: input.gameType,
|
game_type: input.gameType,
|
||||||
|
total_rounds: input.totalRounds,
|
||||||
max_players: input.maxPlayers,
|
max_players: input.maxPlayers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
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 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'
|
import type { SeatPlayerCardModel } from './seat-player-card'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -11,17 +10,26 @@ const props = defineProps<{
|
|||||||
player: SeatPlayerCardModel
|
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(() => {
|
const missingSuitIcon = computed(() => {
|
||||||
if (props.player.missingSuitLabel === '万') {
|
const suit = normalizeMissingSuit(props.player.missingSuitLabel)
|
||||||
return wanIcon
|
return suit ? getLackSuitImage(suit) : ''
|
||||||
}
|
|
||||||
if (props.player.missingSuitLabel === '筒') {
|
|
||||||
return tongIcon
|
|
||||||
}
|
|
||||||
if (props.player.missingSuitLabel === '条') {
|
|
||||||
return tiaoIcon
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolvedAvatarUrl = computed(() => {
|
const resolvedAvatarUrl = computed(() => {
|
||||||
@@ -43,6 +51,8 @@ const resolvedAvatarUrl = computed(() => {
|
|||||||
|
|
||||||
<div class="player-meta">
|
<div class="player-meta">
|
||||||
<p>{{ player.name }}</p>
|
<p>{{ player.name }}</p>
|
||||||
|
<small v-if="player.isTrustee" class="trustee-chip">托管中</small>
|
||||||
|
<small v-if="player.isReady" class="ready-chip">已准备</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="missing-mark">
|
<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 // 显示名称
|
name: string // 显示名称
|
||||||
dealer: boolean // 是否庄家
|
dealer: boolean // 是否庄家
|
||||||
isTurn: boolean // 是否当前轮到该玩家
|
isTurn: boolean // 是否当前轮到该玩家
|
||||||
|
isReady: boolean // 是否已准备
|
||||||
|
isTrustee: boolean // 是否托管
|
||||||
missingSuitLabel: string // 定缺花色(万/筒/条)
|
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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,27 @@ export interface RoomPlayerUpdatePayload {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoomTrusteePayload {
|
||||||
|
player_id?: string
|
||||||
|
playerId?: string
|
||||||
|
trustee?: boolean
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 游戏动作定义(只描述“发生了什么”)
|
* 游戏动作定义(只描述“发生了什么”)
|
||||||
@@ -80,3 +101,13 @@ export type GameAction =
|
|||||||
type: 'ROOM_PLAYER_UPDATE'
|
type: 'ROOM_PLAYER_UPDATE'
|
||||||
payload: RoomPlayerUpdatePayload
|
payload: RoomPlayerUpdatePayload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
| {
|
||||||
|
type: 'ROOM_TRUSTEE'
|
||||||
|
payload: RoomTrusteePayload
|
||||||
|
}
|
||||||
|
|
||||||
|
| {
|
||||||
|
type: 'PLAYER_TURN'
|
||||||
|
payload: PlayerTurnPayload
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export function dispatchGameAction(action: GameAction) {
|
|||||||
store.onRoomPlayerUpdate(action.payload)
|
store.onRoomPlayerUpdate(action.payload)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'ROOM_TRUSTEE':
|
||||||
|
store.onRoomTrustee(action.payload)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'PLAYER_TURN':
|
||||||
|
store.onPlayerTurn(action.payload)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error('Invalid game action')
|
throw new Error('Invalid game action')
|
||||||
|
|||||||
0
src/game/events.ts
Normal file
@@ -4,10 +4,35 @@ import {
|
|||||||
type GameState,
|
type GameState,
|
||||||
type PendingClaimState,
|
type PendingClaimState,
|
||||||
} from '../types/state'
|
} 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'
|
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', {
|
export const useGameStore = defineStore('game', {
|
||||||
state: (): GameState => ({
|
state: (): GameState => ({
|
||||||
roomId: '',
|
roomId: '',
|
||||||
@@ -16,6 +41,8 @@ export const useGameStore = defineStore('game', {
|
|||||||
|
|
||||||
dealerIndex: 0,
|
dealerIndex: 0,
|
||||||
currentTurn: 0,
|
currentTurn: 0,
|
||||||
|
currentPlayerId: '',
|
||||||
|
needDraw: false,
|
||||||
|
|
||||||
players: {},
|
players: {},
|
||||||
|
|
||||||
@@ -26,10 +53,18 @@ export const useGameStore = defineStore('game', {
|
|||||||
winners: [],
|
winners: [],
|
||||||
|
|
||||||
scores: {},
|
scores: {},
|
||||||
|
|
||||||
|
currentRound: 0,
|
||||||
|
|
||||||
|
totalRounds: 0,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
// 初始化
|
resetGame() {
|
||||||
|
this.$reset()
|
||||||
|
},
|
||||||
|
|
||||||
|
// 初始<E5889D>?
|
||||||
initGame(data: GameState) {
|
initGame(data: GameState) {
|
||||||
Object.assign(this, data)
|
Object.assign(this, data)
|
||||||
},
|
},
|
||||||
@@ -43,15 +78,18 @@ export const useGameStore = defineStore('game', {
|
|||||||
if (player.playerId === this.getMyPlayerId()) {
|
if (player.playerId === this.getMyPlayerId()) {
|
||||||
player.handTiles.push(data.tile)
|
player.handTiles.push(data.tile)
|
||||||
}
|
}
|
||||||
|
player.handCount += 1
|
||||||
|
|
||||||
// 剩余牌数减少
|
// 剩余牌数减少
|
||||||
this.remainingTiles = Math.max(0, this.remainingTiles - 1)
|
this.remainingTiles = Math.max(0, this.remainingTiles - 1)
|
||||||
|
|
||||||
// 更新回合(seatIndex)
|
// 更新回合(seatIndex<EFBFBD>?
|
||||||
this.currentTurn = player.seatIndex
|
this.currentTurn = player.seatIndex
|
||||||
|
this.currentPlayerId = player.playerId
|
||||||
|
|
||||||
// 清除操作窗口
|
// 清除操作窗口
|
||||||
this.pendingClaim = undefined
|
this.pendingClaim = undefined
|
||||||
|
this.needDraw = false
|
||||||
|
|
||||||
// 进入出牌阶段
|
// 进入出牌阶段
|
||||||
this.phase = GAME_PHASE.PLAYING
|
this.phase = GAME_PHASE.PLAYING
|
||||||
@@ -75,20 +113,23 @@ export const useGameStore = defineStore('game', {
|
|||||||
player.handTiles.splice(index, 1)
|
player.handTiles.splice(index, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
player.handCount = Math.max(0, player.handCount - 1)
|
||||||
|
|
||||||
// 加入出牌区
|
// 加入出牌<EFBFBD>?
|
||||||
player.discardTiles.push(data.tile)
|
player.discardTiles.push(data.tile)
|
||||||
|
|
||||||
// 更新回合
|
// 更新回合
|
||||||
this.currentTurn = data.nextSeat
|
this.currentTurn = data.nextSeat
|
||||||
|
this.needDraw = true
|
||||||
|
|
||||||
// 等待其他玩家响应
|
// 等待其他玩家响应
|
||||||
this.phase = GAME_PHASE.ACTION
|
this.phase = GAME_PHASE.ACTION
|
||||||
},
|
},
|
||||||
|
|
||||||
// 触发操作窗口(碰/杠/胡)
|
// 触发操作窗口(碰/<EFBFBD>?胡)
|
||||||
onPendingClaim(data: PendingClaimState) {
|
onPendingClaim(data: PendingClaimState) {
|
||||||
this.pendingClaim = data
|
this.pendingClaim = data
|
||||||
|
this.needDraw = false
|
||||||
this.phase = GAME_PHASE.ACTION
|
this.phase = GAME_PHASE.ACTION
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -132,6 +173,7 @@ export const useGameStore = defineStore('game', {
|
|||||||
const seatIndex =
|
const seatIndex =
|
||||||
typeof seatRaw === 'number' && Number.isFinite(seatRaw) ? seatRaw : index
|
typeof seatRaw === 'number' && Number.isFinite(seatRaw) ? seatRaw : index
|
||||||
const readyRaw = raw.Ready ?? raw.ready
|
const readyRaw = raw.Ready ?? raw.ready
|
||||||
|
const ready = parseBooleanish(readyRaw)
|
||||||
const displayNameRaw = raw.PlayerName ?? raw.player_name
|
const displayNameRaw = raw.PlayerName ?? raw.player_name
|
||||||
const avatarUrlRaw = raw.AvatarUrl ?? raw.avatar_url
|
const avatarUrlRaw = raw.AvatarUrl ?? raw.avatar_url
|
||||||
const missingSuitRaw = raw.MissingSuit ?? raw.missing_suit
|
const missingSuitRaw = raw.MissingSuit ?? raw.missing_suit
|
||||||
@@ -147,17 +189,20 @@ export const useGameStore = defineStore('game', {
|
|||||||
typeof avatarUrlRaw === 'string'
|
typeof avatarUrlRaw === 'string'
|
||||||
? avatarUrlRaw
|
? avatarUrlRaw
|
||||||
: previous?.avatarURL,
|
: previous?.avatarURL,
|
||||||
|
isTrustee: previous?.isTrustee ?? false,
|
||||||
missingSuit:
|
missingSuit:
|
||||||
typeof missingSuitRaw === 'string' || missingSuitRaw === null
|
typeof missingSuitRaw === 'string' || missingSuitRaw === null
|
||||||
? missingSuitRaw
|
? missingSuitRaw
|
||||||
: previous?.missingSuit,
|
: previous?.missingSuit,
|
||||||
handTiles: previous?.handTiles ?? [],
|
handTiles: previous?.handTiles ?? [],
|
||||||
|
handCount: previous?.handCount ?? 0,
|
||||||
melds: previous?.melds ?? [],
|
melds: previous?.melds ?? [],
|
||||||
discardTiles: previous?.discardTiles ?? [],
|
discardTiles: previous?.discardTiles ?? [],
|
||||||
|
hasHu: previous?.hasHu ?? false,
|
||||||
score: previous?.score ?? 0,
|
score: previous?.score ?? 0,
|
||||||
isReady:
|
isReady:
|
||||||
typeof readyRaw === 'boolean'
|
ready !== null
|
||||||
? readyRaw
|
? ready
|
||||||
: (previous?.isReady ?? false),
|
: (previous?.isReady ?? false),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -173,10 +218,13 @@ export const useGameStore = defineStore('game', {
|
|||||||
seatIndex: previous?.seatIndex ?? index,
|
seatIndex: previous?.seatIndex ?? index,
|
||||||
displayName: previous?.displayName ?? playerId,
|
displayName: previous?.displayName ?? playerId,
|
||||||
avatarURL: previous?.avatarURL,
|
avatarURL: previous?.avatarURL,
|
||||||
|
isTrustee: previous?.isTrustee ?? false,
|
||||||
missingSuit: previous?.missingSuit,
|
missingSuit: previous?.missingSuit,
|
||||||
handTiles: previous?.handTiles ?? [],
|
handTiles: previous?.handTiles ?? [],
|
||||||
|
handCount: previous?.handCount ?? 0,
|
||||||
melds: previous?.melds ?? [],
|
melds: previous?.melds ?? [],
|
||||||
discardTiles: previous?.discardTiles ?? [],
|
discardTiles: previous?.discardTiles ?? [],
|
||||||
|
hasHu: previous?.hasHu ?? false,
|
||||||
score: previous?.score ?? 0,
|
score: previous?.score ?? 0,
|
||||||
isReady: previous?.isReady ?? false,
|
isReady: previous?.isReady ?? false,
|
||||||
}
|
}
|
||||||
@@ -186,15 +234,67 @@ export const useGameStore = defineStore('game', {
|
|||||||
this.players = nextPlayers
|
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() {
|
clearPendingClaim() {
|
||||||
this.pendingClaim = undefined
|
this.pendingClaim = undefined
|
||||||
this.phase = GAME_PHASE.PLAYING
|
this.phase = GAME_PHASE.PLAYING
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取当前玩家ID(后续建议放到 userStore)
|
// 获取当前玩家ID(后续建议放<EFBFBD>?userStore<EFBFBD>?
|
||||||
getMyPlayerId(): string {
|
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 ''
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
ActiveRoomState,
|
ActiveRoomState,
|
||||||
ActiveRoomSelectionInput,
|
ActiveRoomSelectionInput,
|
||||||
} from './state'
|
} from './state'
|
||||||
import { readActiveRoomSnapshot, saveActiveRoom } from './storage'
|
import { clearActiveRoomSnapshot, readActiveRoomSnapshot, saveActiveRoom } from './storage'
|
||||||
|
|
||||||
const activeRoom = ref<ActiveRoomState | null>(readActiveRoomSnapshot())
|
const activeRoom = ref<ActiveRoomState | null>(readActiveRoomSnapshot())
|
||||||
|
|
||||||
@@ -19,8 +19,8 @@ function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState {
|
|||||||
createdAt: input.createdAt ?? '',
|
createdAt: input.createdAt ?? '',
|
||||||
updatedAt: input.updatedAt ?? '',
|
updatedAt: input.updatedAt ?? '',
|
||||||
players: input.players ?? [],
|
players: input.players ?? [],
|
||||||
myHand: [],
|
myHand: input.myHand ?? [],
|
||||||
game: {
|
game: input.game ?? {
|
||||||
state: {
|
state: {
|
||||||
wall: [],
|
wall: [],
|
||||||
scores: {},
|
scores: {},
|
||||||
@@ -39,7 +39,12 @@ export function setActiveRoom(input: ActiveRoomSelectionInput) {
|
|||||||
saveActiveRoom(next)
|
saveActiveRoom(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearActiveRoom() {
|
||||||
|
activeRoom.value = null
|
||||||
|
clearActiveRoomSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
// 使用房间状态
|
// 使用房间状态
|
||||||
export function useActiveRoomState() {
|
export function useActiveRoomState() {
|
||||||
return activeRoom
|
return activeRoom
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface RoomPlayerState {
|
|||||||
displayName?: string
|
displayName?: string
|
||||||
missingSuit?: string | null
|
missingSuit?: string | null
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
trustee?: boolean
|
||||||
hand: string[]
|
hand: string[]
|
||||||
melds: string[]
|
melds: string[]
|
||||||
outTiles: string[]
|
outTiles: string[]
|
||||||
@@ -46,4 +47,6 @@ export interface ActiveRoomSelectionInput {
|
|||||||
createdAt?: string
|
createdAt?: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
players?: RoomPlayerState[]
|
players?: RoomPlayerState[]
|
||||||
}
|
myHand?: string[]
|
||||||
|
game?: ActiveRoomState['game']
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,4 +17,9 @@ export function readActiveRoomSnapshot(): ActiveRoomState | null {
|
|||||||
// 写入缓存
|
// 写入缓存
|
||||||
export function saveActiveRoom(state: ActiveRoomState) {
|
export function saveActiveRoom(state: ActiveRoomState) {
|
||||||
localStorage.setItem(KEY, JSON.stringify(state))
|
localStorage.setItem(KEY, JSON.stringify(state))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清除缓存
|
||||||
|
export function clearActiveRoomSnapshot() {
|
||||||
|
localStorage.removeItem(KEY)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export interface GameState {
|
|||||||
|
|
||||||
// 当前操作玩家(座位)
|
// 当前操作玩家(座位)
|
||||||
currentTurn: number
|
currentTurn: number
|
||||||
|
// 当前操作玩家ID
|
||||||
|
currentPlayerId: string
|
||||||
|
|
||||||
|
// 当前回合是否需要先摸牌
|
||||||
|
needDraw: boolean
|
||||||
|
|
||||||
// 玩家列表
|
// 玩家列表
|
||||||
players: Record<string, PlayerState>
|
players: Record<string, PlayerState>
|
||||||
@@ -28,4 +33,10 @@ export interface GameState {
|
|||||||
|
|
||||||
// 分数(playerId -> score)
|
// 分数(playerId -> score)
|
||||||
scores: Record<string, number>
|
scores: Record<string, number>
|
||||||
}
|
|
||||||
|
// 当前第几局
|
||||||
|
currentRound: number
|
||||||
|
|
||||||
|
// 总局数
|
||||||
|
totalRounds: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import type {Tile} from "../tile.ts";
|
|||||||
|
|
||||||
export interface PendingClaimState {
|
export interface PendingClaimState {
|
||||||
// 当前被响应的牌
|
// 当前被响应的牌
|
||||||
tile: Tile
|
tile?: Tile
|
||||||
|
|
||||||
// 出牌人
|
// 出牌人
|
||||||
fromPlayerId: string
|
fromPlayerId?: string
|
||||||
|
|
||||||
// 当前玩家可执行操作
|
// 当前玩家可执行操作
|
||||||
options: ClaimOptionState[]
|
options: ClaimOptionState[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ export interface PlayerState {
|
|||||||
seatIndex: number
|
seatIndex: number
|
||||||
displayName?: string
|
displayName?: string
|
||||||
missingSuit?: string | null
|
missingSuit?: string | null
|
||||||
|
isTrustee: boolean
|
||||||
|
|
||||||
// 手牌(只有自己有完整数据,后端可控制)
|
// 手牌(只有自己有完整数据,后端可控制)
|
||||||
handTiles: Tile[]
|
handTiles: Tile[]
|
||||||
|
handCount: number
|
||||||
|
|
||||||
// 副露(碰/杠)
|
// 副露(碰/杠)
|
||||||
melds: MeldState[]
|
melds: MeldState[]
|
||||||
|
|
||||||
// 出牌区
|
// 出牌区
|
||||||
discardTiles: Tile[]
|
discardTiles: Tile[]
|
||||||
|
hasHu: boolean
|
||||||
|
|
||||||
// 分数
|
// 分数
|
||||||
score: number
|
score: number
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const createRoomForm = ref({
|
|||||||
name: '',
|
name: '',
|
||||||
gameType: 'chengdu',
|
gameType: 'chengdu',
|
||||||
maxPlayers: 4,
|
maxPlayers: 4,
|
||||||
|
totalRounds: 8,
|
||||||
})
|
})
|
||||||
|
|
||||||
const quickJoinRoomId = ref('')
|
const quickJoinRoomId = ref('')
|
||||||
@@ -136,6 +137,7 @@ function mapRoomPlayers(room: RoomItem): RoomPlayerState[] {
|
|||||||
(typeof item.PlayerName === 'string' && item.PlayerName) ||
|
(typeof item.PlayerName === 'string' && item.PlayerName) ||
|
||||||
(item.player_id === currentUserId.value ? displayName.value : undefined),
|
(item.player_id === currentUserId.value ? displayName.value : undefined),
|
||||||
ready: Boolean(item.ready),
|
ready: Boolean(item.ready),
|
||||||
|
trustee: false,
|
||||||
hand: [],
|
hand: [],
|
||||||
melds: [],
|
melds: [],
|
||||||
outTiles: [],
|
outTiles: [],
|
||||||
@@ -188,7 +190,7 @@ function connectGameWs(): void {
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
wsClient.connect(buildWsUrl(token), token)
|
wsClient.connect(buildWsUrl(), token)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRooms(): Promise<void> {
|
async function refreshRooms(): Promise<void> {
|
||||||
@@ -246,6 +248,7 @@ async function submitCreateRoom(): Promise<void> {
|
|||||||
name: createRoomForm.value.name.trim(),
|
name: createRoomForm.value.name.trim(),
|
||||||
gameType: createRoomForm.value.gameType,
|
gameType: createRoomForm.value.gameType,
|
||||||
maxPlayers: Number(createRoomForm.value.maxPlayers),
|
maxPlayers: Number(createRoomForm.value.maxPlayers),
|
||||||
|
totalRounds: Number(createRoomForm.value.totalRounds),
|
||||||
},
|
},
|
||||||
syncAuth,
|
syncAuth,
|
||||||
)
|
)
|
||||||
@@ -265,6 +268,7 @@ async function submitCreateRoom(): Promise<void> {
|
|||||||
})
|
})
|
||||||
quickJoinRoomId.value = room.room_id
|
quickJoinRoomId.value = room.room_id
|
||||||
createRoomForm.value.name = ''
|
createRoomForm.value.name = ''
|
||||||
|
createRoomForm.value.totalRounds = 8
|
||||||
showCreateModal.value = false
|
showCreateModal.value = false
|
||||||
showCreatedModal.value = true
|
showCreatedModal.value = true
|
||||||
await refreshRooms()
|
await refreshRooms()
|
||||||
@@ -449,6 +453,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="primary-btn"
|
class="primary-btn"
|
||||||
|
:data-testid="`room-enter-${room.room_id}`"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="roomSubmitting"
|
:disabled="roomSubmitting"
|
||||||
@click="handleJoinRoom({ roomId: room.room_id, roomName: room.name })"
|
@click="handleJoinRoom({ roomId: room.room_id, roomName: room.name })"
|
||||||
@@ -459,7 +464,7 @@ onMounted(async () => {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="room-actions-footer">
|
<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>
|
<button class="ghost-btn wide-btn" type="button" @click="logoutToLogin">退出大厅</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -471,8 +476,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<h3>快速加入</h3>
|
<h3>快速加入</h3>
|
||||||
<form class="join-line" @submit.prevent="handleJoinRoom()">
|
<form class="join-line" @submit.prevent="handleJoinRoom()">
|
||||||
<input v-model.trim="quickJoinRoomId" type="text" placeholder="输入 room_id" />
|
<input v-model.trim="quickJoinRoomId" data-testid="quick-join-room-id" type="text" placeholder="输入 room_id" />
|
||||||
<button class="primary-btn" type="submit" :disabled="roomSubmitting">加入</button>
|
<button class="primary-btn" data-testid="quick-join-submit" type="submit" :disabled="roomSubmitting">加入</button>
|
||||||
</form>
|
</form>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
@@ -486,7 +491,7 @@ onMounted(async () => {
|
|||||||
<form class="form" @submit.prevent="submitCreateRoom">
|
<form class="form" @submit.prevent="submitCreateRoom">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>房间名</span>
|
<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>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>玩法</span>
|
<span>玩法</span>
|
||||||
@@ -498,14 +503,19 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<fieldset class="radio-group">
|
<fieldset class="radio-group">
|
||||||
<legend>人数</legend>
|
<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>
|
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="4" /> 4人</label>
|
||||||
</fieldset>
|
</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">
|
<div class="modal-actions">
|
||||||
<button class="ghost-btn" type="button" @click="closeCreateModal">取消</button>
|
<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 ? '创建中...' : '创建' }}
|
{{ roomSubmitting ? '创建中...' : '创建' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -528,7 +538,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ async function handleSubmit(): Promise<void> {
|
|||||||
<form class="form" @submit.prevent="handleSubmit">
|
<form class="form" @submit.prevent="handleSubmit">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>登录ID</span>
|
<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>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>密码</span>
|
<span>密码</span>
|
||||||
<input v-model="form.password" type="password" placeholder="请输入密码" />
|
<input v-model="form.password" data-testid="login-password" type="password" placeholder="请输入密码" />
|
||||||
</label>
|
</label>
|
||||||
<button class="primary-btn" type="submit" :disabled="submitting">
|
<button class="primary-btn" data-testid="login-submit" type="submit" :disabled="submitting">
|
||||||
{{ submitting ? '登录中...' : '登录' }}
|
{{ submitting ? '登录中...' : '登录' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -38,10 +38,15 @@ class WsClient {
|
|||||||
private buildUrl(): string {
|
private buildUrl(): string {
|
||||||
if (!this.token) return this.url
|
if (!this.token) return this.url
|
||||||
|
|
||||||
const hasQuery = this.url.includes('?')
|
try {
|
||||||
const connector = hasQuery ? '&' : '?'
|
const parsed = new URL(this.url)
|
||||||
|
parsed.searchParams.set('token', this.token)
|
||||||
return `${this.url}${connector}token=${encodeURIComponent(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) => {
|
this.ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
|
console.log('[WS:RECV]', data)
|
||||||
this.messageHandlers.forEach(fn => fn(data))
|
this.messageHandlers.forEach(fn => fn(data))
|
||||||
} catch {
|
} catch {
|
||||||
|
console.log('[WS:RECV]', event.data)
|
||||||
this.messageHandlers.forEach(fn => fn(event.data))
|
this.messageHandlers.forEach(fn => fn(event.data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function registerHandler(type: string, handler: Handler) {
|
|||||||
// 初始化监听
|
// 初始化监听
|
||||||
export function initWsHandler() {
|
export function initWsHandler() {
|
||||||
wsClient.onMessage((msg) => {
|
wsClient.onMessage((msg) => {
|
||||||
|
console.log('[WS] 收到消息:', msg)
|
||||||
const handlers = handlerMap[msg.type]
|
const handlers = handlerMap[msg.type]
|
||||||
|
|
||||||
if (handlers && handlers.length > 0) {
|
if (handlers && handlers.length > 0) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws'
|
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)
|
const baseUrl = /^wss?:\/\//.test(WS_BASE_URL)
|
||||||
? new URL(WS_BASE_URL)
|
? new URL(WS_BASE_URL)
|
||||||
: new URL(
|
: new URL(
|
||||||
@@ -8,6 +8,5 @@ export function buildWsUrl(token: string): string {
|
|||||||
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
|
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
baseUrl.searchParams.set('token', token)
|
|
||||||
return baseUrl.toString()
|
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 vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({mode}) => {
|
||||||
const env = loadEnv(mode, process.cwd(), '')
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
const apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:19000').replace(/\/$/, '')
|
const apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:19000').replace(/\/$/, '')
|
||||||
const wsProxyTarget = (env.VITE_WS_PROXY_TARGET || apiProxyTarget).replace(/\/$/, '')
|
const wsProxyTarget = (env.VITE_WS_PROXY_TARGET || apiProxyTarget).replace(/\/$/, '')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [vue()],
|
resolve: {
|
||||||
server: {
|
alias: {
|
||||||
proxy: {
|
'@': path.resolve(__dirname, 'src'),
|
||||||
'/ws': {
|
'@src': path.resolve(__dirname, 'src'),
|
||||||
target: wsProxyTarget,
|
},
|
||||||
changeOrigin: true,
|
|
||||||
ws: true,
|
|
||||||
rewriteWsOrigin: true,
|
|
||||||
},
|
},
|
||||||
'/api/v1': {
|
|
||||||
target: apiProxyTarget,
|
plugins: [vue()],
|
||||||
changeOrigin: true,
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 8080,
|
||||||
|
proxy: {
|
||||||
|
'/ws': {
|
||||||
|
target: wsProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
rewriteWsOrigin: true,
|
||||||
|
},
|
||||||
|
'/api/v1': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||