Compare commits

..

21 Commits

Author SHA1 Message Date
9b3e3fdb90 feat(game): 结算倒计时展示+自动下一局
- 解析后端settlement_deadline_ms,展示5秒结算倒计时
- 下一局按钮显示倒计时秒数(如"下一局 (3s)")
- 倒计时归零自动发送next_round(可提前点击)
- 新局开始时清除结算状态

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 14:02:42 +08:00
e435fa9d96 feat(game): 添加局间结算界面,对接后端settlement状态
- GameState新增currentRound/totalRounds字段
- 解析后端返回的current_round和total_rounds
- 新增结算弹窗:展示每位玩家得分、胡牌标记和排名
- 状态面板显示当前局数信息
- 新增next_round动作,结算后点击"下一局"继续游戏
- 最后一局结算后显示"返回大厅"按钮

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 11:34:40 +08:00
941d878931 feat(game): 添加自摸胡牌和暗杠功能支持
- 添加了 turnActionPending 状态管理当前回合动作状态
- 新增 canSelfHu 计算属性用于判断是否可以自摸胡牌
- 实现 concealedGangCandidates 计算属性计算可暗杠的牌面选项
- 添加 tileFaceKey 工具函数用于生成牌面键值
- 实现 clearTurnActionPending 和 markTurnActionPending 动作状态管理函数
- 新增牌面解析和胡牌判断相关辅助函数
- 修改 meld 解析逻辑支持数组格式的碰杠数据
- 在游戏状态更新时清理回合动作状态
- 添加 ACTION_ERROR 消息处理器处理操作错误
- 扩展 PENG/GANG/HU/PASS 消息解析支持
- 实现 submitConcealedGang 提交暗杠功能
- 实现 submitSelfHu 提交自摸胡牌功能
- 在 UI 界面添加暗杠和自摸胡牌按钮组件
- 集成 WebSocket 错误处理和状态清理逻辑
2026-04-01 10:26:53 +08:00
100d950eb8 feat(game): 添加对多种数据类型的布尔值解析支持
- 实现了数字类型(1/0)到布尔值的转换逻辑
- 添加了字符串类型('true'/'false', '1'/'0')到布尔值的转换
- 在ChengduGamePage.vue中使用readBoolean函数处理就绪状态
- 更新了游戏存储中的就绪状态解析逻辑
- 在发送就绪状态时同时包含ready和isReady字段
- 统一了布尔值判断逻辑,提高代码健壮性
2026-03-31 17:26:54 +08:00
06b25bde62 feat(game): 添加当前回合座位高亮功能
- 计算当前回合座位标识符
- 将活动位置传递给风向指示器组件
- 实现回合座位视觉反馈机制
2026-03-30 17:26:54 +08:00
2625baf266 feat(game): 实现出牌选择与计时功能
- 添加 PlayerTurnPayload 接口定义和 PLAYER_TURN 动作类型
- 实现选牌、出牌确认逻辑和相关状态管理
- 添加客户端出牌限制检查和错误提示
- 集成 PLAYER_TURN WebSocket 消息处理
- 添加房间状态面板显示游戏信息
- 优化桌面背景图片和样式布局
- 添加马蹄形动画样式文件
- 配置 Vite 别名和端口设置
2026-03-30 17:23:43 +08:00
43439cb09d Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	src/api/mahjong.ts
2026-03-29 23:56:55 +08:00
be9bd8c76d feat(game): 添加成都麻将房间配置和桌面牌面显示功能
- 在房间创建接口中添加总回合数配置选项
- 实现桌面弃牌区域的可视化展示,区分各玩家的弃牌和组合
- 添加缺门标识显示,帮助玩家识别缺门牌组起始位置
- 优化牌面操作状态管理,增加弃牌等待状态和超时处理机制
- 更新样式布局适配新的桌面牌面区域,调整墙体和桌面对齐方式
- 修复多处牌面状态同步问题,确保游戏流程中的界面一致性
2026-03-29 23:56:32 +08:00
623ee94b04 feat(game): 添加游戏托管功能和倒计时显示
- 添加 RoomTrusteePayload 接口定义和 ROOM_TRUSTEE 动作类型
- 在玩家状态中增加 trustee 字段用于标识托管状态
- 实现托管模式切换和状态同步功能
- 添加房间倒计时功能支持玩家操作限时
- 实现倒计时 UI 组件显示操作剩余时间
- 修改游戏开始逻辑避免回合开始后重复准备
- 更新 WebSocket 消息处理支持新的托管消息类型
- 添加托管玩家的视觉标识显示托管状态
- 移除房间创建时不必要的总回合数参数
2026-03-29 17:50:34 +08:00
7751d3b8e3 feat(game): 添加摸牌和碰杠胡操作功能
- 在游戏状态中添加 needDraw 字段用于标识当前回合是否需要摸牌
- 实现 canDrawTile 计算属性控制摸牌按钮的显示和启用状态
- 添加 claimActionPending 状态防止重复提交操作
- 实现 myClaimState、visibleClaimOptions 和 showClaimActions 计算属性
- 添加 submitClaim 方法处理碰/杠/胡/过操作
- 实现 normalizePendingClaim 函数解析服务端推送的声明状态
- 在底部手牌区域将牌图片改为按钮以便点击弃牌
- 添加摸牌按钮和声明操作栏界面元素
- 更新房间创建表单添加局数选择选项
- 添加 E2E 测试文件验证多人房间流程
- 为登录页面输入框和按钮添加 testid 属性便于测试
- 修复 test-results 文件中的失败测试记录
2026-03-29 17:46:34 +08:00
5c9c2a180d feat(game): 添加成都麻将打牌功能和E2E测试
- 添加discardPending状态控制丢弃牌操作
- 实现canDiscardTiles计算属性判断是否可以丢弃牌
- 新增handleRoomStateResponse函数处理房间状态响应
- 实现discardTile函数发送丢弃牌消息
- 在游戏页面添加手牌操作栏显示可丢弃的牌
- 为定缺按钮和准备按钮添加data-testid标识
- 在大厅页面为房间操作元素添加data-testid标识
- 添加手牌操作相关的CSS样式
- 配置Playwright E2E测试框架
- 创建房间流程到打牌的完整E2E测试用例
2026-03-29 16:47:56 +08:00
4f7a54cf08 refactor(game): 重构缺门花色处理逻辑并优化组件结构
- 移除硬编码的花色图标导入,改用动态加载方式
- 添加新的 flowerColorMap 配置文件统一管理缺门图标
- 引入 clearActiveRoom 函数用于清理活动房间状态
- 在游戏数据解析中添加缺失花色的读取函数
- 当房间数据为空时自动清理房间状态并跳转回大厅
- 统一玩家缺门花色数据处理逻辑
- 注释掉浮动状态显示区域以优化界面布局
- 调整CSS样式中缺门标记尺寸和旋转效果
- 在游戏存储模块中添加清除快照功能
- 重构座位玩家卡片组件中的花色图标计算逻辑
- 优化花色标签映射和归一化处理函数
2026-03-28 09:59:44 +08:00
d60a505226 fix(game): 修复房间信息请求和定缺响应处理逻辑
- 将 pendingRoomInfoRequest 重命名为 needsInitialRoomInfo 以更准确表达含义
- 移除 waiting 阶段的限制条件,仅在结算阶段返回 false
- 当手牌为空时才返回空状态,避免误判
- 新增 handlePlayerDingQueResponse 函数处理玩家定缺响应
- 优化 onMounted 中的房间信息请求逻辑,确保连接状态下才发送请求
- 在消息处理器中添加对定缺响应的处理支持
2026-03-27 17:26:33 +08:00
d1220cc45d feat(game): 添加定缺功能支持
- 在游戏页面添加 dingQuePending 状态管理
- 实现 showDingQueChooser 计算属性控制定缺选择器显示
- 添加 chooseDingQue 函数处理定缺选择逻辑
- 集成 WebSocket 消息发送定缺选择结果
- 更新底部控制面板添加定缺选择按钮界面
- 添加相应的 CSS 样式支持定缺选择器布局和交互
- 修复房间状态更新时重置定缺待处理状态
2026-03-27 17:12:18 +08:00
7289635340 feat(game): 更新成都麻将游戏页面功能实现
- 移除静态背景图片导入,改为动态获取牌面图片
- 添加 MeldState 类型定义,支持副露状态管理
- 重构牌面图片获取逻辑,为不同座位创建独立配置文件
- 定义 TableTileImageType、WallTileItem 和 WallSeatState 接口
- 移除 selectedTile 响应式变量,优化手牌显示逻辑
- 创建 sortedVisibleHandTiles 计算属性替代原 visibleHandTiles
- 添加 normalizeMeldType 和 normalizeMelds 函数处理副露数据标准化
- 在 PlayerState 中新增 handCount 和 hasHu 属性
- 更新房间玩家数据结构,同步处理手牌计数和胡牌状态
- 重构牌墙显示逻辑,实现动态渲染各座位手牌和副露
- 添加胡牌标识显示功能,改进牌面分组展示效果
- 优化 CSS 样式,调整牌墙布局和间距设置
2026-03-27 16:37:10 +08:00
dc09c7e487 feat(game): 实现麻将手牌按花色分组显示功能
- 添加 HandSuitLabel 类型定义区分万筒条三种花色
- 创建手牌花色排序映射和标签映射配置
- 实现 visibleHandTileGroups 计算属性按花色对手牌进行分组
- 新增 player-hand-group 样式类支持分组布局
- 调整 tile-chip 尺寸适配新的分组显示方式
- 修改底部控制面板位置避免遮挡手牌区域
2026-03-27 16:14:22 +08:00
fd8f6d47fa feat(tiles): 实现麻将牌图像系统并优化游戏界面显示
- 重命名 tileMap.ts 为 bottomTileMap.ts 并扩展支持字牌(东南西北、中发白)
- 新增 leftTileMap.ts、rightTileMap.ts 和 topTileMap.ts 支持多位置牌面渲染
- 实现牌面图像类型区分(手牌、明牌、盖牌)和动态图像键构建
- 添加牌面验证函数支持不同花色的数值范围检查
- 更新 ChengduGamePage.vue 使用新的底部牌面配置文件
- 实现玩家手牌可见性控制仅在非等待阶段显示
- 重构服务器响应解析逻辑适配新的数据结构
- 添加玩家手牌响应处理器实时更新手牌状态
- 将玩家手牌显示从文本改为图像展示提升用户体验
- 重构CSS样式实现牌面图像的响应式布局和阴影效果
2026-03-27 15:34:59 +08:00
b1e394d675 feat(game): 添加麻将牌图片映射配置并优化成都麻将页面
- 新增 tileMap.ts 配置文件,定义麻将牌图片映射逻辑
- 实现根据花色和点数获取对应图片路径的功能
- 添加麻将牌验证和基础牌生成工具函数
- 在 ChengduGamePage.vue 中导入并使用 getTileImage 函数
- 添加服务器响应日志用于调试
- 优化玩家手牌显示区域的布局结构
2026-03-27 14:16:04 +08:00
921f47d916 feat(game): 添加房间管理和游戏启动功能
- 添加 setActiveRoom 导入和房间状态管理功能
- 实现房间所有者判断逻辑和玩家准备状态检查
- 添加游戏启动按钮和相关权限控制
- 实现房间信息请求和响应处理机制
- 添加 WebSocket 消息规范化处理函数
- 集成 tile 数据标准化和验证逻辑
- 更新 CSS 样式以支持新的界面元素
- 修复 Vite 配置以支持外部访问
- 优化 UI 组件布局和交互反馈机制
2026-03-26 23:37:00 +08:00
0fa3c4f1df feat(game): 添加游戏准备状态功能
- 在 SeatPlayerCard 组件中添加 isReady 属性用于显示准备状态
- 添加准备/取消准备按钮,支持玩家切换准备状态
- 实现 WebSocket 消息处理以同步玩家准备状态
- 添加 CSS 样式显示准备状态标签和准备按钮
- 优化用户 ID 解析逻辑,支持多种字段格式
- 修复座位索引计算逻辑,确保相对位置正确显示
- 添加认证会话管理功能,确保用户信息同步加载
- 实现房间玩家状态更新的消息处理机制
2026-03-26 17:18:29 +08:00
603f910e8b style(game): 更新风向方块组件样式
- 替换三角形图标为正方形基础图标
- 移除四个三角形元素,改用对角线装饰设计
- 调整组件尺寸从128px改为96px,并增加圆角效果
- 更新阴影和渐变背景样式以提升视觉效果
- 优化风向标签的位置和大小布局
- 修改风向图标样式为全白色调显示
- 调整各个风向位置的坐标定位参数
2026-03-26 14:47:18 +08:00
40 changed files with 5764 additions and 507 deletions

View File

@@ -27,3 +27,6 @@ Preview the production build:
```bash
pnpm preview
```
测试账号ABCD
测试密码123456

View File

@@ -31,6 +31,7 @@ HTTP 接口:
{
"name": "房间名",
"game_type": "chengdu",
"total_rounds": 8,
"max_players": 4
}
```

View File

@@ -8,7 +8,8 @@
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test:e2e": "playwright test"
"test:e2e": "playwright test",
"test:e2e:live": "PLAYWRIGHT_LIVE=1 playwright test tests/e2e/room-flow.live.spec.ts"
},
"dependencies": {
"pinia": "^3.0.4",

14
playwright.config.ts Normal file
View 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',
},
})

View File

@@ -33,7 +33,7 @@ const ROOM_JOIN_PATH = import.meta.env.VITE_ROOM_JOIN_PATH ?? '/api/v1/game/mahj
export async function createRoom(
auth: AuthSession,
input: { name: string; gameType: string; maxPlayers: number },
input: { name: string; gameType: string; totalRounds: number; maxPlayers: number },
onAuthUpdated?: (next: AuthSession) => void,
): Promise<RoomItem> {
return authedRequest<RoomItem>({
@@ -44,6 +44,7 @@ export async function createRoom(
body: {
name: input.name,
game_type: input.gameType,
total_rounds: input.totalRounds,
max_players: input.maxPlayers,
},
})

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
src/assets/images/icons/read.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View 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

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import wanIcon from '../../assets/images/flowerClolor/wan.png'
import tongIcon from '../../assets/images/flowerClolor/tong.png'
import tiaoIcon from '../../assets/images/flowerClolor/tiao.png'
import defaultAvatarIcon from '../../assets/images/icons/avatar.svg'
import { getLackSuitImage } from '../../config/flowerColorMap'
import type { Suit } from '../../types/tile'
import type { SeatPlayerCardModel } from './seat-player-card'
const props = defineProps<{
@@ -11,17 +10,26 @@ const props = defineProps<{
player: SeatPlayerCardModel
}>()
function normalizeMissingSuit(value: string): Suit | null {
const normalized = value.trim().toLowerCase()
const missingSuitMap: Record<string, Suit> = {
: 'W',
: 'T',
: 'B',
w: 'W',
t: 'T',
b: 'B',
wan: 'W',
tong: 'T',
tiao: 'B',
}
return missingSuitMap[normalized] ?? null
}
const missingSuitIcon = computed(() => {
if (props.player.missingSuitLabel === '万') {
return wanIcon
}
if (props.player.missingSuitLabel === '筒') {
return tongIcon
}
if (props.player.missingSuitLabel === '条') {
return tiaoIcon
}
return ''
const suit = normalizeMissingSuit(props.player.missingSuitLabel)
return suit ? getLackSuitImage(suit) : ''
})
const resolvedAvatarUrl = computed(() => {
@@ -43,6 +51,8 @@ const resolvedAvatarUrl = computed(() => {
<div class="player-meta">
<p>{{ player.name }}</p>
<small v-if="player.isTrustee" class="trustee-chip">托管中</small>
<small v-if="player.isReady" class="ready-chip">已准备</small>
</div>
<div class="missing-mark">

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import triangleIcon from '../../assets/images/icons/triangle.svg'
import squareIcon from '../../assets/images/icons/square.svg'
import '@src/assets/styles/windowSquare.css'
defineProps<{
seatWinds: {
@@ -8,144 +9,34 @@ defineProps<{
bottom: string
left: string
}
activePosition?: 'top' | 'right' | 'bottom' | 'left' | ''
}>()
</script>
<template>
<div class="wind-square">
<img class="triangle top" :src="triangleIcon" alt="" />
<img class="triangle right" :src="triangleIcon" alt="" />
<img class="triangle bottom" :src="triangleIcon" alt="" />
<img class="triangle left" :src="triangleIcon" alt="" />
<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="位风" />
<img class="wind-icon" :src="seatWinds.top" alt="上方位风" />
</span>
<span class="wind-slot wind-right">
<img class="wind-icon" :src="seatWinds.right" alt="右位风" />
<img class="wind-icon" :src="seatWinds.right" alt="右位风" />
</span>
<span class="wind-slot wind-bottom">
<img class="wind-icon" :src="seatWinds.bottom" alt="位风" />
<img class="wind-icon" :src="seatWinds.bottom" alt="下方位风" />
</span>
<span class="wind-slot wind-left">
<img class="wind-icon" :src="seatWinds.left" alt="左位风" />
<img class="wind-icon" :src="seatWinds.left" alt="左位风" />
</span>
</div>
</template>
<style scoped>
.wind-square {
position: relative;
width: 128px;
height: 128px;
border-radius: 18px;
}
.wind-square::before {
content: '';
position: absolute;
inset: 18px;
border-radius: 10px;
background:
radial-gradient(circle at 50% 45%, rgba(244, 222, 151, 0.2), rgba(12, 40, 30, 0.05) 65%),
linear-gradient(145deg, rgba(21, 82, 58, 0.42), rgba(8, 38, 27, 0.16));
box-shadow:
inset 0 0 0 1px rgba(255, 225, 165, 0.15),
0 6px 12px rgba(0, 0, 0, 0.24);
}
.triangle {
position: absolute;
width: 64px;
height: 64px;
object-fit: contain;
opacity: 0.96;
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
}
.triangle.top {
top: 4px;
left: 32px;
transform: rotate(0deg);
filter:
hue-rotate(-8deg)
saturate(1.35)
brightness(1.1)
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
}
.triangle.right {
top: 32px;
right: 4px;
transform: rotate(90deg);
filter:
hue-rotate(16deg)
saturate(1.28)
brightness(1.08)
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
}
.triangle.bottom {
bottom: 4px;
left: 32px;
transform: rotate(180deg);
filter:
hue-rotate(34deg)
saturate(1.2)
brightness(1.02)
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
}
.triangle.left {
top: 32px;
left: 4px;
transform: rotate(270deg);
filter:
hue-rotate(-26deg)
saturate(1.24)
brightness(1.06)
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
}
.wind-slot {
position: absolute;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background:
linear-gradient(180deg, rgba(255, 237, 186, 0.92), rgba(232, 191, 105, 0.84));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.56),
0 3px 8px rgba(0, 0, 0, 0.26);
}
.wind-icon {
width: 24px;
height: 24px;
object-fit: contain;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
.wind-top {
top: 12px;
left: 48px;
}
.wind-right {
top: 48px;
right: 12px;
}
.wind-bottom {
bottom: 12px;
left: 48px;
}
.wind-left {
top: 48px;
left: 12px;
}
</style>
</template>

View File

@@ -4,5 +4,7 @@ export interface SeatPlayerCardModel {
name: string // 显示名称
dealer: boolean // 是否庄家
isTurn: boolean // 是否当前轮到该玩家
isReady: boolean // 是否已准备
isTrustee: boolean // 是否托管
missingSuitLabel: string // 定缺花色(万/筒/条)
}

299
src/config/bottomTileMap.ts Normal file
View 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 ''
}
}

View 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,
},
]

View 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
View 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
View 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
View 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 ''
}
}

View File

@@ -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'
payload: RoomPlayerUpdatePayload
}
| {
type: 'ROOM_TRUSTEE'
payload: RoomTrusteePayload
}
| {
type: 'PLAYER_TURN'
payload: PlayerTurnPayload
}

View File

@@ -34,6 +34,14 @@ export function dispatchGameAction(action: GameAction) {
store.onRoomPlayerUpdate(action.payload)
break
case 'ROOM_TRUSTEE':
store.onRoomTrustee(action.payload)
break
case 'PLAYER_TURN':
store.onPlayerTurn(action.payload)
break
default:
throw new Error('Invalid game action')

0
src/game/events.ts Normal file
View File

View File

@@ -4,10 +4,35 @@ import {
type GameState,
type PendingClaimState,
} from '../types/state'
import type { RoomPlayerUpdatePayload } from '../game/actions'
import type { PlayerTurnPayload, RoomPlayerUpdatePayload, RoomTrusteePayload } from '../game/actions'
import { readStoredAuth } from '../utils/auth-storage'
import type { Tile } from '../types/tile'
function parseBooleanish(value: unknown): boolean | null {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
if (value === 1) {
return true
}
if (value === 0) {
return false
}
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'true' || normalized === '1') {
return true
}
if (normalized === 'false' || normalized === '0') {
return false
}
}
return null
}
export const useGameStore = defineStore('game', {
state: (): GameState => ({
roomId: '',
@@ -16,6 +41,8 @@ export const useGameStore = defineStore('game', {
dealerIndex: 0,
currentTurn: 0,
currentPlayerId: '',
needDraw: false,
players: {},
@@ -26,10 +53,18 @@ export const useGameStore = defineStore('game', {
winners: [],
scores: {},
currentRound: 0,
totalRounds: 0,
}),
actions: {
// 初始化
resetGame() {
this.$reset()
},
// 初始<E5889D>?
initGame(data: GameState) {
Object.assign(this, data)
},
@@ -43,15 +78,18 @@ export const useGameStore = defineStore('game', {
if (player.playerId === this.getMyPlayerId()) {
player.handTiles.push(data.tile)
}
player.handCount += 1
// 剩余牌数减少
this.remainingTiles = Math.max(0, this.remainingTiles - 1)
// 更新回合seatIndex
// 更新回合seatIndex<EFBFBD>?
this.currentTurn = player.seatIndex
this.currentPlayerId = player.playerId
// 清除操作窗口
this.pendingClaim = undefined
this.needDraw = false
// 进入出牌阶段
this.phase = GAME_PHASE.PLAYING
@@ -75,20 +113,23 @@ export const useGameStore = defineStore('game', {
player.handTiles.splice(index, 1)
}
}
player.handCount = Math.max(0, player.handCount - 1)
// 加入出牌
// 加入出牌<EFBFBD>?
player.discardTiles.push(data.tile)
// 更新回合
this.currentTurn = data.nextSeat
this.needDraw = true
// 等待其他玩家响应
this.phase = GAME_PHASE.ACTION
},
// 触发操作窗口(碰/杠/胡)
// 触发操作窗口(碰/<EFBFBD>?胡)
onPendingClaim(data: PendingClaimState) {
this.pendingClaim = data
this.needDraw = false
this.phase = GAME_PHASE.ACTION
},
@@ -132,6 +173,7 @@ export const useGameStore = defineStore('game', {
const seatIndex =
typeof seatRaw === 'number' && Number.isFinite(seatRaw) ? seatRaw : index
const readyRaw = raw.Ready ?? raw.ready
const ready = parseBooleanish(readyRaw)
const displayNameRaw = raw.PlayerName ?? raw.player_name
const avatarUrlRaw = raw.AvatarUrl ?? raw.avatar_url
const missingSuitRaw = raw.MissingSuit ?? raw.missing_suit
@@ -147,17 +189,20 @@ export const useGameStore = defineStore('game', {
typeof avatarUrlRaw === 'string'
? avatarUrlRaw
: previous?.avatarURL,
isTrustee: previous?.isTrustee ?? false,
missingSuit:
typeof missingSuitRaw === 'string' || missingSuitRaw === null
? missingSuitRaw
: previous?.missingSuit,
handTiles: previous?.handTiles ?? [],
handCount: previous?.handCount ?? 0,
melds: previous?.melds ?? [],
discardTiles: previous?.discardTiles ?? [],
hasHu: previous?.hasHu ?? false,
score: previous?.score ?? 0,
isReady:
typeof readyRaw === 'boolean'
? readyRaw
ready !== null
? ready
: (previous?.isReady ?? false),
}
})
@@ -173,10 +218,13 @@ export const useGameStore = defineStore('game', {
seatIndex: previous?.seatIndex ?? index,
displayName: previous?.displayName ?? playerId,
avatarURL: previous?.avatarURL,
isTrustee: previous?.isTrustee ?? false,
missingSuit: previous?.missingSuit,
handTiles: previous?.handTiles ?? [],
handCount: previous?.handCount ?? 0,
melds: previous?.melds ?? [],
discardTiles: previous?.discardTiles ?? [],
hasHu: previous?.hasHu ?? false,
score: previous?.score ?? 0,
isReady: previous?.isReady ?? false,
}
@@ -186,15 +234,67 @@ export const useGameStore = defineStore('game', {
this.players = nextPlayers
},
onRoomTrustee(payload: RoomTrusteePayload) {
const playerId =
(typeof payload.player_id === 'string' && payload.player_id) ||
(typeof payload.playerId === 'string' && payload.playerId) ||
''
if (!playerId) {
return
}
const player = this.players[playerId]
if (!player) {
return
}
player.isTrustee = typeof payload.trustee === 'boolean' ? payload.trustee : true
},
// 清理操作窗口
onPlayerTurn(payload: PlayerTurnPayload) {
const playerId =
(typeof payload.player_id === 'string' && payload.player_id) ||
(typeof payload.playerId === 'string' && payload.playerId) ||
(typeof payload.PlayerID === 'string' && payload.PlayerID) ||
''
if (!playerId) {
return
}
const player = this.players[playerId]
if (player) {
this.currentTurn = player.seatIndex
}
this.currentPlayerId = playerId
this.needDraw = false
this.pendingClaim = undefined
this.phase = GAME_PHASE.PLAYING
},
clearPendingClaim() {
this.pendingClaim = undefined
this.phase = GAME_PHASE.PLAYING
},
// 获取当前玩家ID后续建议放userStore
// 获取当前玩家ID后续建议放<EFBFBD>?userStore<EFBFBD>?
getMyPlayerId(): string {
return Object.keys(this.players)[0] || ''
const auth = readStoredAuth()
const source = auth?.user as Record<string, unknown> | undefined
const rawId =
source?.id ??
source?.userID ??
source?.user_id
if (typeof rawId === 'string' && rawId.trim()) {
return rawId
}
if (typeof rawId === 'number') {
return String(rawId)
}
return ''
},
},
})

View File

@@ -3,7 +3,7 @@ import type {
ActiveRoomState,
ActiveRoomSelectionInput,
} from './state'
import { readActiveRoomSnapshot, saveActiveRoom } from './storage'
import { clearActiveRoomSnapshot, readActiveRoomSnapshot, saveActiveRoom } from './storage'
const activeRoom = ref<ActiveRoomState | null>(readActiveRoomSnapshot())
@@ -19,8 +19,8 @@ function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState {
createdAt: input.createdAt ?? '',
updatedAt: input.updatedAt ?? '',
players: input.players ?? [],
myHand: [],
game: {
myHand: input.myHand ?? [],
game: input.game ?? {
state: {
wall: [],
scores: {},
@@ -39,7 +39,12 @@ export function setActiveRoom(input: ActiveRoomSelectionInput) {
saveActiveRoom(next)
}
export function clearActiveRoom() {
activeRoom.value = null
clearActiveRoomSnapshot()
}
// 使用房间状态
export function useActiveRoomState() {
return activeRoom
}
}

View File

@@ -5,6 +5,7 @@ export interface RoomPlayerState {
displayName?: string
missingSuit?: string | null
ready: boolean
trustee?: boolean
hand: string[]
melds: string[]
outTiles: string[]
@@ -46,4 +47,6 @@ export interface ActiveRoomSelectionInput {
createdAt?: string
updatedAt?: string
players?: RoomPlayerState[]
}
myHand?: string[]
game?: ActiveRoomState['game']
}

View File

@@ -17,4 +17,9 @@ export function readActiveRoomSnapshot(): ActiveRoomState | null {
// 写入缓存
export function saveActiveRoom(state: ActiveRoomState) {
localStorage.setItem(KEY, JSON.stringify(state))
}
}
// 清除缓存
export function clearActiveRoomSnapshot() {
localStorage.removeItem(KEY)
}

View File

@@ -13,6 +13,11 @@ export interface GameState {
// 当前操作玩家(座位)
currentTurn: number
// 当前操作玩家ID
currentPlayerId: string
// 当前回合是否需要先摸牌
needDraw: boolean
// 玩家列表
players: Record<string, PlayerState>
@@ -28,4 +33,10 @@ export interface GameState {
// 分数playerId -> score
scores: Record<string, number>
}
// 当前第几局
currentRound: number
// 总局数
totalRounds: number
}

View File

@@ -4,11 +4,11 @@ import type {Tile} from "../tile.ts";
export interface PendingClaimState {
// 当前被响应的牌
tile: Tile
tile?: Tile
// 出牌人
fromPlayerId: string
fromPlayerId?: string
// 当前玩家可执行操作
options: ClaimOptionState[]
}
}

View File

@@ -7,15 +7,18 @@ export interface PlayerState {
seatIndex: number
displayName?: string
missingSuit?: string | null
isTrustee: boolean
// 手牌(只有自己有完整数据,后端可控制)
handTiles: Tile[]
handCount: number
// 副露(碰/杠)
melds: MeldState[]
// 出牌区
discardTiles: Tile[]
hasHu: boolean
// 分数
score: number

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,7 @@ const createRoomForm = ref({
name: '',
gameType: 'chengdu',
maxPlayers: 4,
totalRounds: 8,
})
const quickJoinRoomId = ref('')
@@ -136,6 +137,7 @@ function mapRoomPlayers(room: RoomItem): RoomPlayerState[] {
(typeof item.PlayerName === 'string' && item.PlayerName) ||
(item.player_id === currentUserId.value ? displayName.value : undefined),
ready: Boolean(item.ready),
trustee: false,
hand: [],
melds: [],
outTiles: [],
@@ -246,6 +248,7 @@ async function submitCreateRoom(): Promise<void> {
name: createRoomForm.value.name.trim(),
gameType: createRoomForm.value.gameType,
maxPlayers: Number(createRoomForm.value.maxPlayers),
totalRounds: Number(createRoomForm.value.totalRounds),
},
syncAuth,
)
@@ -265,6 +268,7 @@ async function submitCreateRoom(): Promise<void> {
})
quickJoinRoomId.value = room.room_id
createRoomForm.value.name = ''
createRoomForm.value.totalRounds = 8
showCreateModal.value = false
showCreatedModal.value = true
await refreshRooms()
@@ -449,6 +453,7 @@ onMounted(async () => {
</div>
<button
class="primary-btn"
:data-testid="`room-enter-${room.room_id}`"
type="button"
:disabled="roomSubmitting"
@click="handleJoinRoom({ roomId: room.room_id, roomName: room.name })"
@@ -459,7 +464,7 @@ onMounted(async () => {
</ul>
<div class="room-actions-footer">
<button class="primary-btn wide-btn" type="button" @click="openCreateModal">创建房间</button>
<button class="primary-btn wide-btn" data-testid="open-create-room" type="button" @click="openCreateModal">创建房间</button>
<button class="ghost-btn wide-btn" type="button" @click="logoutToLogin">退出大厅</button>
</div>
</article>
@@ -471,8 +476,8 @@ onMounted(async () => {
<h3>快速加入</h3>
<form class="join-line" @submit.prevent="handleJoinRoom()">
<input v-model.trim="quickJoinRoomId" type="text" placeholder="输入 room_id" />
<button class="primary-btn" type="submit" :disabled="roomSubmitting">加入</button>
<input v-model.trim="quickJoinRoomId" data-testid="quick-join-room-id" type="text" placeholder="输入 room_id" />
<button class="primary-btn" data-testid="quick-join-submit" type="submit" :disabled="roomSubmitting">加入</button>
</form>
</aside>
</section>
@@ -486,7 +491,7 @@ onMounted(async () => {
<form class="form" @submit.prevent="submitCreateRoom">
<label class="field">
<span>房间名</span>
<input v-model.trim="createRoomForm.name" type="text" maxlength="24" placeholder="例如test001" />
<input v-model.trim="createRoomForm.name" data-testid="create-room-name" type="text" maxlength="24" placeholder="例如test001" />
</label>
<label class="field">
<span>玩法</span>
@@ -498,14 +503,19 @@ onMounted(async () => {
<fieldset class="radio-group">
<legend>人数</legend>
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="2" /> 2</label>
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="3" /> 3</label>
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="4" /> 4</label>
</fieldset>
<fieldset class="radio-group">
<legend>局数</legend>
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="4" /> 4</label>
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="8" /> 8</label>
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="16" /> 16</label>
</fieldset>
<div class="modal-actions">
<button class="ghost-btn" type="button" @click="closeCreateModal">取消</button>
<button class="primary-btn" type="submit" :disabled="roomSubmitting">
<button class="primary-btn" data-testid="submit-create-room" type="submit" :disabled="roomSubmitting">
{{ roomSubmitting ? '创建中...' : '创建' }}
</button>
</div>
@@ -528,7 +538,7 @@ onMounted(async () => {
</div>
<div class="modal-actions">
<button class="primary-btn" type="button" @click="enterCreatedRoom">进入房间</button>
<button class="primary-btn" data-testid="enter-created-room" type="button" @click="enterCreatedRoom">进入房间</button>
</div>
</section>
</div>

View File

@@ -70,13 +70,13 @@ async function handleSubmit(): Promise<void> {
<form class="form" @submit.prevent="handleSubmit">
<label class="field">
<span>登录ID</span>
<input v-model.trim="form.loginId" type="text" placeholder="请输入手机号或账号" />
<input v-model.trim="form.loginId" data-testid="login-id" type="text" placeholder="请输入手机号或账号" />
</label>
<label class="field">
<span>密码</span>
<input v-model="form.password" type="password" placeholder="请输入密码" />
<input v-model="form.password" data-testid="login-password" type="password" placeholder="请输入密码" />
</label>
<button class="primary-btn" type="submit" :disabled="submitting">
<button class="primary-btn" data-testid="login-submit" type="submit" :disabled="submitting">
{{ submitting ? '登录中...' : '登录' }}
</button>
</form>

View File

@@ -77,8 +77,10 @@ class WsClient {
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
console.log('[WS:RECV]', data)
this.messageHandlers.forEach(fn => fn(data))
} catch {
console.log('[WS:RECV]', event.data)
this.messageHandlers.forEach(fn => fn(event.data))
}
}

View File

@@ -16,6 +16,7 @@ export function registerHandler(type: string, handler: Handler) {
// 初始化监听
export function initWsHandler() {
wsClient.onMessage((msg) => {
console.log('[WS] 收到消息:', msg)
const handlers = handlerMap[msg.type]
if (handlers && handlers.length > 0) {

View File

@@ -0,0 +1,6 @@
{
"status": "failed",
"failedTests": [
"782a5ad254513972d5b8-1549d022f94e0e4cc886"
]
}

View 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
View 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)
})

View File

@@ -1,26 +1,36 @@
import { defineConfig, loadEnv } from 'vite'
import {defineConfig, loadEnv} from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:19000').replace(/\/$/, '')
const wsProxyTarget = (env.VITE_WS_PROXY_TARGET || apiProxyTarget).replace(/\/$/, '')
export default defineConfig(({mode}) => {
const env = loadEnv(mode, process.cwd(), '')
const apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:19000').replace(/\/$/, '')
const wsProxyTarget = (env.VITE_WS_PROXY_TARGET || apiProxyTarget).replace(/\/$/, '')
return {
plugins: [vue()],
server: {
proxy: {
'/ws': {
target: wsProxyTarget,
changeOrigin: true,
ws: true,
rewriteWsOrigin: true,
return {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@src': path.resolve(__dirname, 'src'),
},
},
'/api/v1': {
target: apiProxyTarget,
changeOrigin: true,
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 8080,
proxy: {
'/ws': {
target: wsProxyTarget,
changeOrigin: true,
ws: true,
rewriteWsOrigin: true,
},
'/api/v1': {
target: apiProxyTarget,
changeOrigin: true,
},
},
},
},
},
}
}
})