48 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
f3137493af Merge remote-tracking branch 'origin/dev' into dev 2026-03-26 13:28:41 +08:00
0f1684b8d7 feat(game): 更新游戏页面功能和认证刷新机制
- 将开发环境代理目标从 192.168.1.5 改为 127.0.0.1
- 重构 auth.ts 文件中的代码缩进格式
- 实现自动令牌刷新机制,支持 JWT 过期时间检测
- 添加 WebSocket 连接的令牌强制刷新逻辑
- 新增 WindSquare 组件显示方位风向图标
- 实现动态座位风向计算和显示功能
- 优化 WebSocket URL 构建方式,移除查询参数中的令牌传递
- 添加登录失效时自动跳转到登录页面的功能
- 限制玩家名称显示长度为4个字符
- 改进 WebSocket 错误处理和重连机制
2026-03-26 13:28:35 +08:00
6fde4bbc0d refactor(styles): 移除全局样式中的玩家相关组件类
- 删除了.table-watermark、.player-badge、.avatar-panel、.avatar-card等类定义
- 移除了.dealer-mark、.missing-mark、.wall等与游戏桌相关的样式
- 保留了.center-deck样式并移至合适位置
- 在room.css中添加了.picture-scene下的玩家离线状态透明度样式
- 为右侧和左侧玩家添加了.meta区域旋转和布局调整样式
- 修复了missing-mark文字颜色样式缺失问题
2026-03-25 22:11:54 +08:00
43744c2203 style(room): 调整房间场景墙壁定位样式
- 修改 wall-top 的 top 值从 -100px 到 120px
- 修改 wall-bottom 的 bottom 值从 108px 到 160px
2026-03-25 22:01:44 +08:00
774dbbdc25 style(game): 优化游戏界面样式和布局
- 调整玩家头像位置和旋转效果
- 将玩家卡片样式从全局CSS分离到房间页面独立控制
- 优化桌面背景墙布局参数
- 修复左右侧玩家头像图片旋转显示问题
- 统一游戏场景中元素定位和间距配置
2026-03-25 21:56:37 +08:00
ae5d8d48c4 feat(game): 支持玩家显示名称的多种数据源
- 在麻将游戏页面中添加本地缓存头像URL的优先级处理
- 为玩家座位信息添加自定义显示名称功能
- 支持从player_name或PlayerName字段获取玩家名称
- 实现当前用户显示名称的回退逻辑
- 更新API接口定义以支持可选的玩家名称字段
2026-03-25 21:15:40 +08:00
6168117eb2 feat(game): 添加玩家头像显示功能
- 在环境配置中更新代理目标地址
- 扩展游戏动作类型定义以支持头像URL字段
- 添加头像URL缓存计算逻辑以从多种来源获取头像
- 修改座位玩家卡片数据模型将avatar替换为avatarUrl
- 实现头像图片加载并添加默认头像回退机制
- 更新CSS样式以正确显示头像图片
- 重构游戏状态管理中的玩家头像数据处理
- 优化游戏页面中的头像分配逻辑
2026-03-25 21:07:49 +08:00
66834d8a7a feat(game): 添加房间玩家状态同步功能
- 定义 RoomPlayerUpdatePayload 接口用于处理房间状态更新
- 在游戏动作中新增 ROOM_PLAYER_UPDATE 类型支持
- 实现游戏状态管理器中的房间玩家更新逻辑
- 重构成都麻将页面以使用新的状态管理机制
- 添加从 WebSocket 消息转换为游戏动作的功能
- 更新房间离开时的 WebSocket 消息发送逻辑
- 优化玩家手牌显示和选择逻辑
- 调整房间状态显示逻辑以匹配新状态模型
- 修复座位索引计算和庄家标识逻辑
- 更新全局样式中的图标按钮样式
- 替换大厅页面的刷新图标为 SVG 图像
- 升级 pnpm 包管理器版本
- 扩展玩家状态类型定义以支持显示名称和缺门信息
2026-03-25 17:26:18 +08:00
2737971608 refactor(game): 重构游戏状态管理和WebSocket通信
- 定义统一的游戏动作类型GameAction替代原有发送函数
- 创建游戏状态管理store使用Pinia进行状态管理
- 实现游戏状态分发器处理各种游戏事件
- 重构WebSocket处理器支持多处理器注册
- 重命名状态类型文件统一使用State后缀
- 添加ACTION游戏阶段处理操作窗口逻辑
- 集成Pinia依赖管理应用状态
2026-03-25 15:19:28 +08:00
4a9b2f2db2 feat(game): 实现游戏房间状态管理和WebSocket连接功能
- 添加路由参数解析和房间状态初始化逻辑
- 实现房间玩家座位视图计算和状态映射
- 集成WebSocket客户端连接管理和重连机制
- 添加房间数据持久化存储功能
- 实现游戏界面状态显示和用户交互控制
- 更新WS代理目标地址配置
- 重构房间状态管理模块分离到独立store
2026-03-25 14:07:52 +08:00
148e21f3b0 refactor(game): 重构游戏动作处理和WebSocket连接管理
- 重构sendGameAction函数参数结构,添加上下文支持
- 新增sendStartGame和sendLeaveRoom函数统一处理游戏开始和离开房间逻辑
- 移除路由相关依赖,简化ChengduGamePage组件
- 更新WebSocket客户端实现,添加状态变化订阅功能
- 移除requestId生成函数和相关参数,精简消息结构
- 优化座位玩家卡片数据模型,移除在线状态和金钱字段
- 整理游戏阶段常量定义,添加标签映射
- 移除过期的游戏状态字段如needDraw、lastDiscardTile等
- 添加座位类型定义和改进游戏类型文件组织结构
2026-03-25 13:34:47 +08:00
4f6ef1d0ec refactor(game): 重构游戏模块并添加WebSocket客户端
- 修改开发环境配置中的代理目标地址
- 移除旧的活动房间状态管理模块
- 移除成都麻将游戏相关的测试用例
- 添加新的游戏动作发送功能
- 实现WebSocket客户端类并支持自动重连
- 添加WebSocket消息处理器注册机制
- 创建游戏相关状态类型定义
- 添加ID生成工具函数
- 移除废弃的游戏相关模块和常量定义
- 添加WebSocket消息结构定义
- 重构游戏状态相关类型定义
2026-03-24 23:42:03 +08:00
7316588d9e refactor(game): 移除废弃的房间状态管理文件并优化游戏页面
- 删除 src/state/active-room.ts 文件及其相关导入引用
- 更新 ChengduGamePage.vue 中的导入路径从 features/chengdu-game/useChengduGameRoom 到 game/chengdu
- 移除 ChengduGamePage.vue 中不再需要的状态变量如 roomId、startGamePending 等
- 简化 roomStatusText 计算属性逻辑,移除 "等待中" 默认值
- 调整 phaseLabelMap 映射,移除 "摸牌" 阶段显示
- 删除多个废弃的计算属性如 centerTimer、selectedTileText、pendingClaimText 等
- 移除 actionTheme 函数及相关的按钮样式绑定
- 清理游戏场景中的装饰元素如 diamond outline、scene watermark、center desk 等
- 更新 HallPage.vue 中的导入路径到 store/active-room-store
- 添加缺失的玩家数据字段如 hand、melds、outTiles、hasHu
- 调整 CSS 样式包括工具栏位置、动画角度和时钟位置等视觉优化
2026-03-24 22:09:03 +08:00
3219639b04 ```
fix(ChengduGamePage): update game title from '指尖四川麻将' to '四川麻将'

- Changed the game title in the scene watermark from '指尖四川麻将' to '四川麻将'
- This updates the display text to be more concise while maintaining the core game identity
```
2026-03-24 19:09:03 +08:00
679116e455 feat(game): 添加游戏房间菜单和托管功能
- 引入机器人和退出图标资源
- 实现游戏房间顶部菜单触发器和弹出菜单
- 添加托管模式切换功能
- 实现退出房间功能
- 添加全局点击和ESC键关闭菜单事件监听
- 优化菜单动画效果和交互反馈
- 移除侧边按钮区域的聊天、赞赏和开局按钮
- 调整时钟位置以适应新菜单布局
2026-03-24 17:25:37 +08:00
716bc2b106 style(game): 优化成都麻将游戏页面样式和代码结构
- 将CSS样式提取到独立的room.css文件中
- 移除组件中的内联样式定义
- 调整了顶部工具栏位置参数
- 更新了计数器指示灯的样式
- 精简了导入语句的空格格式
- 移除了调试用的编辑按钮
- 更新游戏标题为"指尖四川麻将"
- 移除了冗余的import类型声明顺序
- 优化了数组创建语法格式
- 统一了图片标签的alt属性格式
- 调整了循环渲染元素的缩进格式
2026-03-24 17:01:02 +08:00
ceba41fb08 ```
style(global): update background gradients and visual styling

- Replace radial gradient with combined radial and linear gradients
- Update color schemes with warmer tones and improved transparency
- Adjust border colors and add subtle glow effects
- Increase blur intensity for better glassmorphism effect

style(game): enhance seat player card design

- Add avatar panel container for better layout structure
- Implement dealer mark positioning with absolute placement
- Add missing suit icons with computed property mapping
- Replace text-based missing marks with image icons when available
- Improve visual hierarchy and spacing between elements

refactor(game): add computed property for dynamic suit icon selection

- Import suit icon assets (wan, tong, tiao)
- Create computed property to map suit labels to corresponding icons
- Handle fallback to text display when no icon is available
```
2026-03-24 16:26:13 +08:00
72253b1391 Update open-four-players.mjs 2026-03-24 15:29:32 +08:00
58fe43607a update 2026-03-24 15:25:40 +08:00
292a4181ce Create chengdu-mahjong-features.md 2026-03-24 14:40:28 +08:00
84ce67b9be ```
feat(game): update websocket URL configuration and improve game room logic

- Change VITE_GAME_WS_URL from /api/v1/ws to /ws in .env.development
- Update proxy configuration in vite.config.ts to match new websocket path
- Refactor leave room functionality to properly disconnect websocket
and destroy room state
- Add e2e testing script to package.json
```
2026-03-24 14:38:47 +08:00
1b15748d0d ```
refactor(ChengduGamePage): replace manual WebSocket logic with composable hook

- Replace manual WebSocket connection and state management with
  useChengduGameRoom composable
- Remove unused imports and authentication related code
- Simplify component by extracting room state logic into separate hook
- Clean up redundant functions and variables that are now handled
 by the composable
- Update component lifecycle to use the new composable's methods
  for connecting WebSocket and managing room state
```
2026-03-24 14:12:04 +08:00
f97f1ffdbc ```
feat(game): add player cards and topbar styling for Chengdu Mahjong game

- Add new CSS classes for topbar layout including .topbar-left,
  .topbar-back-btn, .topbar-room-meta, .eyebrow, and .topbar-room-name
- Create dedicated player card components for each seat position
  (top, right, bottom, left)
- Refactor seatDecor computed property to use SeatPlayerCardModel
  interface with proper typing
- Replace inline player badge rendering with reusable player card
  components
- Update game header layout to use new topbar structure with
  back button and room metadata
- Adjust spacing and font sizes in game header elements
```
2026-03-24 14:02:21 +08:00
d4e217b11b Merge branch 'main' into dev
# Conflicts:
#	src/views/ChengduGamePage.vue
#	vite.config.ts
2026-03-24 13:45:30 +08:00
a5c833c769 feat(game): 完善成都麻将游戏页面功能
- 添加WebSocket URL构建逻辑和认证令牌刷新功能
- 实现游戏状态显示包括阶段、网络状态、时钟等信息
- 添加游戏桌面背景图片和玩家座位装饰组件
- 重构CSS样式为网格布局提升响应式体验
- 配置环境变量支持API和WebSocket代理目标设置
- 优化WebSocket连接管理增加错误处理机制
- 添加游戏桌墙体和中心计数器等UI元素
- 修复多处字符串国际化和路径处理问题
2026-03-24 13:44:53 +08:00
fcb9a02c68 ```
fix(backend): resolve merge conflicts and update API proxy configuration

- Remove leftover merge conflict markers from ChengduGamePage.vue
- Fix broken HTML structure by properly closing header and section tags
- Update proxy configuration to point to correct backend port (19000)
- Clean up import statements and remove conflicting code blocks
```
2026-03-24 09:36:58 +08:00
bb3b55f69b Update ChengduGamePage.vue 2026-03-24 09:30:50 +08:00
82 changed files with 9450 additions and 2489 deletions

View File

@@ -1 +0,0 @@
VITE_API_BASE_URL=http://localhost:8080/api/v1

4
.env.development Normal file
View File

@@ -0,0 +1,4 @@
VITE_API_BASE_URL=/api/v1
VITE_GAME_WS_URL=/ws
VITE_API_PROXY_TARGET=http://127.0.0.1:19000
VITE_WS_PROXY_TARGET=http://127.0.0.1:19000

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.tmp/

View File

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

607
chengdu-mahjong-features.md Normal file
View File

@@ -0,0 +1,607 @@
# 成都麻将功能整理
本文基于当前项目 `mahjong-server` 的实际代码实现整理,目的是给前端或测试同学做逐项对照测试。这里写的是“当前代码已经实现/暴露出来的功能”,不是传统成都麻将的完整规则说明。
## 1. 当前玩法范围
### 1.1 当前后端只支持成都麻将
- 创建房间时如果不传 `game_type`,默认就是 `chengdu`
- 如果传入其他玩法,服务端直接返回错误:`only chengdu mahjong is supported currently`
- 虽然引擎里预留了 `xueliu``hongzhong` 的规则工厂,但房间服务层目前只允许创建 `chengdu`
### 1.2 当前牌集范围
- 只使用三门牌:万(`W`)、条(`T`)、筒(`B`)
- 每门 `1-9` 各 4 张,共 `108`
- 当前不带字牌、风牌、箭牌、花牌
- 成都玩法配置里明确 `HasHongZhong() == false`
## 2. 房间功能
### 2.1 创建房间
HTTP 接口:
- `POST /api/v1/game/mahjong/room/create`
请求体:
```json
{
"name": "房间名",
"game_type": "chengdu",
"total_rounds": 8,
"max_players": 4
}
```
实现规则:
- 必须登录鉴权
- 创建者自动成为房主
- 创建者自动进入房间,座位索引固定为 `0`
- `max_players` 为空时,使用配置默认值
- 当前只允许 `4` 人房
- 房间名为空时,自动生成类似 `成都麻将-xxxxxxxx`
- 初始房间状态为 `waiting`
返回重点字段:
- `room_id`
- `name`
- `game_type`
- `owner_id`
- `max_players`
- `player_count`
- `players`
- `status`
### 2.2 房间列表
HTTP 接口:
- `GET /api/v1/game/mahjong/room/list`
查询参数:
- `status`
- `game_type`
- `page`
- `size`
实现规则:
- 需要登录
- 支持按房间状态筛选
- 支持按玩法类型筛选
- 支持分页
- `size` 超过配置上限会被裁剪
### 2.3 加入房间
HTTP 接口:
- `POST /api/v1/game/mahjong/room/join`
请求体:
```json
{
"room_id": "xxx"
}
```
实现规则:
- 需要登录
- 只能加入 `waiting` 状态房间
- 人数满 4 人后不可继续加入
- 已在房间内再次加入,不会重复加人
- 如果玩家名之前为空,再次进入时会补写玩家名
加入房间成功后,除了 HTTP 返回房间摘要,还会额外推送 WS 事件:
- `room_joined`
- `room_member_joined`(仅首次加入触发)
- `room_player_update`
### 2.4 查询房间详情
HTTP 接口:
- `GET /api/v1/game/mahjong/room/:room_id`
实现规则:
- 需要登录
- 必须是房间内玩家才能看该房间详情
- 返回 `summary + public`
其中 `public` 可用于前端牌桌展示,包含:
- 当前阶段 `phase`
- 当前轮到谁 `current_turn_player`
- 是否必须先摸牌 `need_draw`
- 最近弃牌人 `last_discard_by`
- 最近弃牌 `last_discard_tile`
- 牌墙剩余数 `wall_count`
- 本局胡牌玩家 `winners`
- 本局分数 `scores`
- 当前响应窗口 `pending_claim`
- 每个玩家的公开状态
### 2.5 离开房间
离开房间是走 WS 动作,不是 HTTP 接口。
实现规则:
- 只有 `waiting` 状态下允许离开
- `playing` 状态下离开会失败
- 普通玩家离开后,剩余玩家座位索引会重新整理
- 房主离开后,房主身份自动转移给当前列表中的第一个玩家
- 如果最后一个玩家离开,房间会直接删除
对应事件:
- `room_left`
- `room_member_left`
- `room_owner_changed`(房主变更时)
- `room_player_update`
- `room_closed`(最后一人离开时)
## 3. 开局和基础对局流程
### 3.1 开始游戏
开始游戏走 WS 动作:
- `type = "start_game"`
实现规则:
- 只有房主能开始
- 只有满 4 人时才能开始
- 房间状态从 `waiting` 变成 `playing`
- 内部固定按当前房间玩家顺序创建 4 个玩家
- 庄家固定为座位 `0`
- 发牌后庄家 14 张,其余玩家 13 张
开始后会推送:
- 广播 `room_state`
- 给每个玩家单独推送 `my_hand`
### 3.2 基础轮转规则
- 庄家开局先打牌,不需要先摸
- 非当前操作者不能操作
- 非庄家进入自己回合后,必须先 `draw` 才能 `discard`
- 每次打牌后,系统检查其他三家是否可 `hu / peng / gang`
- 如果没人可响应,则轮到下家摸牌
- 如果所有可响应玩家都 `pass`,也轮到出牌者下家摸牌
### 3.3 牌墙耗尽
- 当需要摸牌但牌墙为空时,直接流局结束
- 打完牌后如果后续无人响应,且牌墙为空,也直接结束
- 摸到最后一张牌后,如果牌墙已空且无人可胡,也会结束
- 结束后阶段 `phase = over`
- 房间状态同步变为 `finished`
## 4. 对局动作功能
当前对局动作都通过 WS -> ws-gateway -> grpc -> mahjong-game 转发执行。
支持的动作类型:
- `draw`
- `discard`
- `peng`
- `gang`
- `hu`
- `pass`
### 4.1 摸牌 `draw`
触发条件:
- 必须轮到当前玩家
- 当前状态必须 `need_draw = true`
效果:
- 从牌墙头部摸 1 张
- 手牌加 1
- `need_draw = false`
- 记录最近摸牌玩家
- 如果是杠后补牌,会记录 `LastDrawFromGang = true`
- 如果摸到的是最后一张,会记录 `LastDrawIsLastTile = true`
### 4.2 出牌 `discard`
触发条件:
- 必须轮到当前玩家
- 当前不能处于“必须先摸牌”的状态
- 必须指定要打出的牌,并且该牌确实在玩家手里
效果:
- 从手牌移除该张牌
- 追加到玩家弃牌区 `out_tiles`
- 记录 `last_discard_by`
- 记录 `last_discard_tile`
- 然后生成一个 `pending_claim` 响应窗口,给其他玩家判断能否 `hu / peng / gang`
### 4.3 碰牌 `peng`
触发条件:
- 当前必须存在 `pending_claim`
- 该玩家必须在响应窗口中,并且 `CanPeng = true`
- 玩家手里要有与目标弃牌同牌面的 2 张牌
效果:
- 手牌移除 2 张同牌
- 明牌区新增一组 3 张碰牌
- 当前回合转给碰牌玩家
- 碰牌后不需要先摸,直接由碰牌玩家出牌
### 4.4 杠牌 `gang`
当前只实现两种杠:
- 明杠:别人打出的牌,你手里正好有 3 张相同牌
- 暗杠:当前轮到自己时,手里本来就有 4 张相同牌
实现效果:
- 组成 4 张杠牌放入明牌区
- 杠后立即补 1 张牌
- 杠后补牌来自牌墙头部
- 补牌后当前回合仍归杠牌玩家
当前未见实现:
- 碰后补杠
- 抢杠流程本身
说明:
- 代码里有 `QiangGangHu` 计分位,但当前动作流程没有真正把“抢杠胡窗口”建出来
### 4.5 胡牌 `hu`
支持两类:
- 自摸胡:当前轮到自己时胡自己的手牌
- 点炮胡:在 `pending_claim` 窗口里胡别人刚打出的牌
共同前提:
- 必须满足成都规则的胡牌校验
- 必须满足“缺一门”
胡牌后效果:
- 玩家 `HasHu = true`
- `winners` 增加该玩家
- 按规则计算本次番数并累计到 `scores`
- 成都玩法当前不是血流玩法,所以有人胡后本局直接结束
- `phase = over`
- 房间状态会被服务层更新为 `finished`
### 4.6 过牌 `pass`
触发条件:
- 当前必须有 `pending_claim`
- 该玩家必须在当前响应窗口里
效果:
- 从当前 `pending_claim.options` 删除该玩家
- 如果还有其他玩家待响应,则继续等待
- 如果所有玩家都过,则轮到弃牌者下家摸牌
## 5. 成都麻将胡牌判定规则
### 5.1 缺一门
当前代码里,“缺一门”的实现方式是:
- 手牌 + 副露中最多只能出现两种花色
- 如果出现万、条、筒三门同时存在,则不能胡
注意:
- 这里只做了“结果校验”
- 没有单独实现“开局定缺”流程
- 也没有前端/协议层的“选缺门”动作
这意味着当前代码更接近“胡牌时校验是否缺一门”,而不是完整的成都定缺流程。
### 5.2 支持的胡型
当前规则代码支持以下胡型:
- 平胡:`1`
- 对对胡:`3`
- 七对:`8`
- 龙七对:`16`
- 清一色:`8`
- 清对:`16`
- 清七对:`32`
- 清龙七对:`64`
### 5.3 支持的附加番
- 杠上开花:`+2`
- 海底捞月:`+2`
- 抢杠胡:`+2`
### 5.4 胡牌组合方式
代码可识别:
- 标准胡:`4` 组面子 + `1` 对将
- 七对
- 龙七对
说明:
- 对对胡通过“全部由刻子/杠 + 1 对将”识别
- 清一色通过“所有牌同一花色”识别
## 6. 分数处理方式
当前代码的分数逻辑比较简单,适合先做基础功能联调,不等同于完整成都麻将结算。
已实现:
- 每次胡牌时,按番型计算一个整数分数
- 这个整数直接累计到 `state.scores[winnerID]`
- 例如平胡就加 `1`,清龙七对就加 `64`
当前未实现或未体现:
- 输家扣分分摊
- 自摸三家付、点炮单家付
- 杠分结算
- 查花猪
- 查大叫
- 退税
- 荒庄查叫
- 局数/圈风/连庄
- 最终总结算
因此,当前 `scores` 更准确说是“本局胡牌番数累计”,不是正式货币化结算结果。
## 7. 前端可收到的核心事件
### 7.1 房间相关事件
- `room_joined`
- `room_member_joined`
- `room_player_update`
- `room_left`
- `room_member_left`
- `room_owner_changed`
- `room_closed`
### 7.2 对局相关事件
- `room_state`
- `my_hand`
### 7.3 `room_state` 关键字段
建议重点校验:
- `room_id`
- `phase`
- `status`
- `current_turn_player`
- `need_draw`
- `last_discard_by`
- `last_discard_tile`
- `wall_count`
- `winners`
- `scores`
- `pending_claim`
- `players`
其中每个公开玩家对象包含:
- `player_id`
- `hand_count`
- `melds`
- `out_tiles`
- `has_hu`
### 7.4 `my_hand` 关键字段
- `room_id`
- `hand`
说明:
- `my_hand` 是定向事件,只发给对应玩家
- 前端应该只用它渲染自己的真实手牌
- 其他玩家只看 `hand_count`
## 8. 建议的 WebSocket 动作格式
从网关协议看,客户端 WS 消息结构大致如下:
```json
{
"type": "start_game",
"roomId": "room-xxx",
"requestId": "req-001",
"trace_id": "trace-001",
"payload": {}
}
```
通用字段:
- `type`
- `roomId`
- `requestId`
- `trace_id`
- `payload`
房间相关动作:
- `join_room`
- `leave_room`
- `start_game`
对局动作:
- `draw`
- `discard`
- `peng`
- `gang`
- `hu`
- `pass`
## 9. 当前代码中的已知限制/缺口
这一段非常重要,测试时请单独对照。
### 9.1 没有“定缺”流程
- 成都麻将通常有“定缺”
- 当前代码没有开局选缺门动作
- 只有胡牌时做“最多两门花色”的校验
### 9.2 没有“换三张”
- 项目内未看到换三张流程
- 没有对应状态、动作、事件
### 9.3 不是血战到底,也不是血流成河
- 成都规则实现里 `IsBloodFlow() == false`
- 一旦有人胡牌,本局直接结束
- 不支持“一炮多响后继续”或“胡后继续打”
### 9.4 结算远未完整
- 当前只给赢家加一个番数值
- 没有完整输赢结算模型
- 没有杠分、查叫、花猪、退税等成都特色结算
### 9.5 `discard` 动作当前存在接线缺口
这一点从代码看是高风险问题:
- 引擎层 `discard` 必须拿到具体 `tile`
- 但服务层 `toEngineAction(msg)` 当前只设置了 `Type``PlayerID`
- 没有把 `msg.Payload` 里的牌对象解析到 `types.Action.Tile`
这意味着:
- 如果前端通过现有 WS 通道发送 `discard`,很可能会因为缺少牌对象而失败
- 报错大概率是 `discard tile is required`
建议测试时优先验证这个点。
### 9.6 抢杠胡只有计分标志,没有完整流程
- 规则层支持 `QiangGangHu` 加番
- 但当前核心流程没有看到“补杠被抢”的动作分支和响应窗口
- 所以这个番目前更像预留能力,不一定能在真实流程里触发
### 9.7 优先级仲裁较简化
项目内 `engine/README.md` 还明确写了后续建议:
- 需要补充更明确的优先级处理 `hu > gang > peng`
当前代码行为是:
- 打牌后把所有可操作选项放进 `pending_claim`
- 谁先发起动作、且在 options 中合法,谁就能执行
因此如果多个玩家同时都可响应,前端和测试要特别注意是否存在“先到先得”而不是严格优先级裁决。
## 10. 建议测试清单
### 10.1 房间层
- 创建房间成功,默认玩法为 `chengdu`
-`chengdu` 玩法创建失败
- 非 4 人房创建失败
- 房主自动入房
- 房间列表分页正常
- 房间未满时可加入
- 房间满员后加入失败
- 对局开始后不能再加入
- 房间内玩家可查看详情
- 非房间内玩家查询详情失败
- 普通玩家可离开等待中的房间
- 房主离开后房主转移
- 最后一人离开后房间关闭
- 游戏进行中离开房间失败
### 10.2 开局与轮转
- 房主才能开始游戏
- 必须满 4 人才能开始
- 开局庄家 14 张,其余 13 张
- 开始后收到 `room_state`
- 每位玩家都能收到自己的 `my_hand`
- 庄家首回合直接出牌
- 非庄家必须先摸再打
- 轮转顺序按座位顺延
### 10.3 响应动作
- 弃牌后正确生成 `pending_claim`
- 可碰玩家能执行 `peng`
- 碰后当前回合归碰牌玩家
- 手牌与副露数量变化正确
- 明杠成功后会补牌
- 暗杠成功后会补牌
- 所有可响应玩家都 `pass` 后轮到下家摸牌
### 10.4 胡牌与流局
- 平胡可胡且加 `1`
- 对对胡可胡且加 `3`
- 七对可胡且加 `8`
- 龙七对可胡且加 `16`
- 清一色可胡且加 `8`
- 清对可胡且加 `16`
- 清七对可胡且加 `32`
- 清龙七对可胡且加 `64`
- 杠上开花附加 `2`
- 海底捞月附加 `2`
- 三门齐全时不能胡
- 点炮胡后本局直接结束
- 自摸后本局直接结束
- 牌墙为空时本局流局结束
### 10.5 高风险专项
- WS `discard` 是否因未传/未解析 `tile` 失败
- 多家同时可响应时,是否符合预期优先级
- `QiangGangHu` 是否实际上无法走通
## 11. 结论
当前项目里的“成都麻将”更准确地说,是一个:
- 已具备房间管理
- 已具备 4 人基础发牌和回合流转
- 已具备碰/杠/胡/过等核心动作
- 已具备部分成都胡型与番数计算
- 但尚未实现完整成都特色流程与结算
如果你是拿它和“标准成都麻将产品需求”对照,当前更像“成都麻将基础可玩内核 + 房间联机骨架”,还不是完整商用品规。

View File

@@ -3,13 +3,16 @@
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"packageManager": "pnpm@9.0.0", "packageManager": "pnpm@10.28.2",
"scripts": { "scripts": {
"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:live": "PLAYWRIGHT_LIVE=1 playwright test tests/e2e/room-flow.live.spec.ts"
}, },
"dependencies": { "dependencies": {
"pinia": "^3.0.4",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-router": "4" "vue-router": "4"
}, },
@@ -17,6 +20,7 @@
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"playwright": "^1.58.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vue-tsc": "^3.1.5" "vue-tsc": "^3.1.5"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

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',
},
})

128
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3))
vue: vue:
specifier: ^3.5.25 specifier: ^3.5.25
version: 3.5.28(typescript@5.9.3) version: 3.5.28(typescript@5.9.3)
@@ -24,6 +27,9 @@ importers:
'@vue/tsconfig': '@vue/tsconfig':
specifier: ^0.8.1 specifier: ^0.8.1
version: 0.8.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)) version: 0.8.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3))
playwright:
specifier: ^1.58.2
version: 1.58.2
typescript: typescript:
specifier: ~5.9.3 specifier: ~5.9.3
version: 5.9.3 version: 5.9.3
@@ -390,6 +396,15 @@ packages:
'@vue/devtools-api@6.6.4': '@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/devtools-api@7.7.9':
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
'@vue/devtools-kit@7.7.9':
resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==}
'@vue/devtools-shared@7.7.9':
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
'@vue/language-core@3.2.4': '@vue/language-core@3.2.4':
resolution: {integrity: sha512-bqBGuSG4KZM45KKTXzGtoCl9cWju5jsaBKaJJe3h5hRAAWpZUuj5G+L+eI01sPIkm4H6setKRlw7E85wLdDNew==} resolution: {integrity: sha512-bqBGuSG4KZM45KKTXzGtoCl9cWju5jsaBKaJJe3h5hRAAWpZUuj5G+L+eI01sPIkm4H6setKRlw7E85wLdDNew==}
@@ -424,6 +439,13 @@ packages:
alien-signals@3.1.2: alien-signals@3.1.2:
resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==}
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -448,14 +470,29 @@ packages:
picomatch: picomatch:
optional: true optional: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] os: [darwin]
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
is-what@5.5.0:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
muggle-string@0.4.1: muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
@@ -467,6 +504,9 @@ packages:
path-browserify@1.0.1: path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -474,10 +514,32 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'} engines: {node: '>=12'}
pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
typescript: '>=4.5.0'
vue: ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.58.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
postcss@8.5.6: postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rollup@4.57.1: rollup@4.57.1:
resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -487,6 +549,14 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
tinyglobby@0.2.15: tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -789,6 +859,24 @@ snapshots:
'@vue/devtools-api@6.6.4': {} '@vue/devtools-api@6.6.4': {}
'@vue/devtools-api@7.7.9':
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-kit@7.7.9':
dependencies:
'@vue/devtools-shared': 7.7.9
birpc: 2.9.0
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.6
'@vue/devtools-shared@7.7.9':
dependencies:
rfdc: 1.4.1
'@vue/language-core@3.2.4': '@vue/language-core@3.2.4':
dependencies: dependencies:
'@volar/language-core': 2.4.27 '@volar/language-core': 2.4.27
@@ -830,6 +918,12 @@ snapshots:
alien-signals@3.1.2: {} alien-signals@3.1.2: {}
birpc@2.9.0: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
csstype@3.2.3: {} csstype@3.2.3: {}
entities@7.0.1: {} entities@7.0.1: {}
@@ -869,29 +963,57 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
hookable@5.5.3: {}
is-what@5.5.0: {}
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
mitt@3.0.1: {}
muggle-string@0.4.1: {} muggle-string@0.4.1: {}
nanoid@3.3.11: {} nanoid@3.3.11: {}
path-browserify@1.0.1: {} path-browserify@1.0.1: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.3: {} picomatch@4.0.3: {}
pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.9
vue: 3.5.28(typescript@5.9.3)
optionalDependencies:
typescript: 5.9.3
playwright-core@1.58.2: {}
playwright@1.58.2:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
postcss@8.5.6: postcss@8.5.6:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
rfdc@1.4.1: {}
rollup@4.57.1: rollup@4.57.1:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -925,6 +1047,12 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
superjson@2.2.6:
dependencies:
copy-anything: 4.0.5
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)

View File

@@ -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,156 +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
}
// 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}` 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}`
}
// 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)
} }

View File

@@ -47,6 +47,15 @@ function buildUrl(path: string): string {
return normalizedPath 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}`
}
try { try {
const baseUrl = new URL(API_BASE_URL) const baseUrl = new URL(API_BASE_URL)
const basePath = baseUrl.pathname.replace(/\/$/, '') const basePath = baseUrl.pathname.replace(/\/$/, '')

View File

@@ -10,6 +10,8 @@ export interface RoomItem {
players?: Array<{ players?: Array<{
index: number index: number
player_id: string player_id: string
player_name?: string
PlayerName?: string
ready: boolean ready: boolean
}> }>
status: string status: string
@@ -31,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>({
@@ -42,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,
}, },
}) })

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 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="1774428253072" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3685" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M819.2 729.088V757.76c0 33.792-27.648 61.44-61.44 61.44H266.24c-33.792 0-61.44-27.648-61.44-61.44v-28.672c0-74.752 87.04-119.808 168.96-155.648 3.072-1.024 5.12-2.048 8.192-4.096 6.144-3.072 13.312-3.072 19.456 1.024C434.176 591.872 472.064 604.16 512 604.16c39.936 0 77.824-12.288 110.592-32.768 6.144-4.096 13.312-4.096 19.456-1.024 3.072 1.024 5.12 2.048 8.192 4.096 81.92 34.816 168.96 79.872 168.96 154.624z" fill="#FFFFFF" p-id="3686"></path><path d="M359.424 373.76a168.96 152.576 90 1 0 305.152 0 168.96 152.576 90 1 0-305.152 0Z" fill="#FFFFFF" p-id="3687"></path></svg>

After

Width:  |  Height:  |  Size: 912 B

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="1774343595232" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5478" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M951.737186 488.212224 802.424532 301.56936c-7.222495-9.027607-18.034748-14.011108-29.157064-14.011108-4.131087 0-8.300037 0.688685-12.349259 2.106987-14.957667 5.246491-24.970718 19.371186-24.970718 35.223223l0 111.98756-298.631448 0c-41.232077 0-74.656327 33.42425-74.656327 74.656327 0 41.2331 33.42425 74.656327 74.656327 74.656327l298.631448 0 0 111.98756c0 15.852036 10.013051 29.977755 24.970718 35.223223 4.049223 1.424442 8.218172 2.108011 12.349259 2.108011 11.123338 0 21.934568-4.978385 29.157064-14.013155l149.311631-186.643887C962.64563 521.221012 962.64563 501.848803 951.737186 488.212224L951.737186 488.212224zM586.628698 810.162774 362.66074 810.162774l-74.656327 0 0-0.011256c-0.199545 0-0.393973 0.011256-0.587378 0.011256-40.906665 0-74.076112-33.42425-74.076112-74.656327l0-74.656327 0-298.631448 0-74.656327 0.011256 0c0-0.199545-0.011256-0.393973-0.011256-0.587378 0-40.906665 33.429367-74.076112 74.66349-74.076112l74.656327 0 223.967958 0c41.2331 0 74.66349-33.422204 74.66349-74.656327 0-41.232077-33.429367-74.656327-74.66349-74.656327L213.340923 63.586201c-82.459037 0-149.311631 66.853617-149.311631 149.311631l0 597.262896c0 82.4662 66.853617 149.311631 149.311631 149.311631l373.286752 0c41.2331 0 74.66349-33.422204 74.66349-74.656327C661.291165 843.586001 627.861798 810.162774 586.628698 810.162774L586.628698 810.162774zM586.628698 810.162774" fill="#272636" p-id="5479"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 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="1774424368718" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1633" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M864 509.12a72 72 0 0 0-72 72A269.76 269.76 0 1 1 512 311.68v141.44c0 17.6 11.84 24 26.56 14.4l298.88-199.04a19.84 19.84 0 0 0 0-35.52l-298.88-199.04C523.84 24 512 32 512 48v119.68a413.44 413.44 0 1 0 424 413.44A72 72 0 0 0 864 509.12z" fill="#ffffff" p-id="1634"></path></svg>

After

Width:  |  Height:  |  Size: 610 B

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="1774343512314" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3627" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 85.333333a85.333333 85.333333 0 0 1 85.333333 85.333334c0 31.573333-17.066667 59.306667-42.666666 73.813333V298.666667h42.666666a298.666667 298.666667 0 0 1 298.666667 298.666666h42.666667a42.666667 42.666667 0 0 1 42.666666 42.666667v128a42.666667 42.666667 0 0 1-42.666666 42.666667h-42.666667v42.666666a85.333333 85.333333 0 0 1-85.333333 85.333334H213.333333a85.333333 85.333333 0 0 1-85.333333-85.333334v-42.666666H85.333333a42.666667 42.666667 0 0 1-42.666666-42.666667v-128a42.666667 42.666667 0 0 1 42.666666-42.666667h42.666667a298.666667 298.666667 0 0 1 298.666667-298.666666h42.666666V244.48c-25.6-14.506667-42.666667-42.24-42.666666-73.813333a85.333333 85.333333 0 0 1 85.333333-85.333334M320 554.666667A106.666667 106.666667 0 0 0 213.333333 661.333333 106.666667 106.666667 0 0 0 320 768a106.666667 106.666667 0 0 0 106.666667-106.666667A106.666667 106.666667 0 0 0 320 554.666667m384 0a106.666667 106.666667 0 0 0-106.666667 106.666666 106.666667 106.666667 0 0 0 106.666667 106.666667 106.666667 106.666667 0 0 0 106.666667-106.666667 106.666667 106.666667 0 0 0-106.666667-106.666666z" fill="" p-id="3628"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 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

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="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

View File

@@ -1,3 +1,10 @@
html,
body,
#app {
height: 100%;
overflow: hidden;
}
:root { :root {
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
line-height: 1.5; line-height: 1.5;
@@ -16,11 +23,14 @@
body { body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
background: radial-gradient(circle at 12% 12%, #254935 0%, #11251c 45%, #0a1411 100%); background:
radial-gradient(circle at top, rgba(219, 171, 91, 0.16), transparent 22%),
linear-gradient(180deg, #442621 0%, #24110e 100%);
} }
#app { #app {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
} }
h1, h1,
@@ -31,6 +41,8 @@ p {
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
overflow: hidden;
} }
.auth-page { .auth-page {
@@ -44,10 +56,12 @@ p {
width: min(440px, 100%); width: min(440px, 100%);
padding: 28px 24px; padding: 28px 24px;
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.16); border: 1px solid rgba(246, 212, 139, 0.18);
background: rgba(8, 27, 20, 0.82); background:
backdrop-filter: blur(8px); linear-gradient(180deg, rgba(62, 33, 26, 0.96), rgba(26, 14, 11, 0.96)),
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); radial-gradient(circle at top, rgba(255, 214, 134, 0.08), transparent 40%);
backdrop-filter: blur(10px);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.42);
} }
.auth-card h1 { .auth-card h1 {
@@ -194,8 +208,10 @@ button:disabled {
gap: 12px; gap: 12px;
padding: 18px; padding: 18px;
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(244, 210, 140, 0.14);
background: linear-gradient(130deg, rgba(22, 57, 43, 0.9), rgba(10, 30, 22, 0.92)); background:
linear-gradient(180deg, rgba(62, 33, 26, 0.94), rgba(30, 15, 12, 0.92)),
radial-gradient(circle at top, rgba(255, 214, 134, 0.08), transparent 42%);
} }
.hall-topbar { .hall-topbar {
@@ -279,8 +295,8 @@ button:disabled {
.panel { .panel {
padding: 18px; padding: 18px;
border-radius: 14px; border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(244, 210, 140, 0.12);
background: rgba(10, 30, 22, 0.85); background: rgba(40, 21, 17, 0.8);
} }
.panel h2 { .panel h2 {
@@ -319,6 +335,9 @@ button:disabled {
.icon-btn { .icon-btn {
width: 34px; width: 34px;
height: 34px; height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(176, 216, 194, 0.35); border: 1px solid rgba(176, 216, 194, 0.35);
border-radius: 8px; border-radius: 8px;
color: #d3efdf; color: #d3efdf;
@@ -326,6 +345,16 @@ button:disabled {
cursor: pointer; cursor: pointer;
} }
.icon-btn-image {
width: 18px;
height: 18px;
display: block;
}
.icon-btn:disabled .icon-btn-image {
opacity: 0.65;
}
.room-list { .room-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -423,36 +452,113 @@ button:disabled {
} }
.game-page { .game-page {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
width: 100%; width: 100%;
max-width: none; max-width: none;
height: 100vh;
height: 100dvh;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
margin: 0; margin: 0;
padding-top: max(12px, env(safe-area-inset-top)); padding-top: max(12px, env(safe-area-inset-top));
padding-right: max(12px, env(safe-area-inset-right)); padding-right: max(12px, env(safe-area-inset-right));
padding-bottom: max(12px, env(safe-area-inset-bottom)); padding-bottom: max(12px, env(safe-area-inset-bottom));
padding-left: max(12px, env(safe-area-inset-left)); padding-left: max(12px, env(safe-area-inset-left));
overflow: hidden;
} }
.game-header { .game-header {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(320px, auto) minmax(280px, 1fr);
align-items: center;
min-height: 96px;
padding: 14px 18px;
border-radius: 22px;
border: 1px solid rgba(233, 199, 108, 0.16);
background:
linear-gradient(180deg, rgba(20, 47, 35, 0.86), rgba(8, 24, 18, 0.82)),
radial-gradient(circle at top, rgba(255, 219, 123, 0.08), transparent 38%);
backdrop-filter: blur(10px);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 16px 36px rgba(0, 0, 0, 0.28);
}
.game-header > div:first-child {
min-width: 0;
}
.topbar-left {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.topbar-back-btn {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 108px;
}
.topbar-room-meta {
min-width: 0;
}
.eyebrow {
color: #f7e4b0;
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
}
.topbar-room-name {
margin-top: 4px;
color: #f6edd5;
font-size: 20px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.game-header h1 {
font-size: 28px;
font-weight: 800;
letter-spacing: 1px;
color: #f7e4b0;
}
.game-header .sub-title {
margin-top: 4px;
color: #d7eadf;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.game-table-panel { .game-table-panel {
flex: 1 1 auto;
display: flex; display: flex;
flex-direction: column; align-items: center;
min-height: 0; min-height: 60px;
padding: 10px 14px;
overflow: hidden;
} }
.room-brief { .room-brief {
width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 10px; gap: 10px;
margin-bottom: 10px; margin-bottom: 0;
padding: 8px 10px; padding: 4px 2px;
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(194, 226, 208, 0.2); border: 0;
background: rgba(7, 28, 20, 0.55); background: transparent;
overflow: hidden;
} }
.room-brief-title { .room-brief-title {
@@ -495,6 +601,185 @@ button:disabled {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.topbar-center {
display: flex;
justify-content: center;
min-width: 0;
}
.title-stack {
padding: 10px 18px;
border-radius: 18px;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(7, 24, 17, 0.36);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.game-title {
font-size: 30px;
font-weight: 700;
line-height: 1.1;
letter-spacing: 2px;
color: #f6edd5;
}
.game-subtitle {
margin-top: 6px;
color: #c4ddd0;
font-size: 13px;
}
.topbar-right {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid rgba(198, 223, 209, 0.18);
background: rgba(5, 24, 17, 0.42);
font-size: 13px;
}
.wifi-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #9a6b6b;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.06);
}
.wifi-dot.is-connected {
background: #62d78f;
}
.wifi-dot.is-connecting {
background: #f0c46b;
}
.wifi-dot.is-disconnected {
background: #d86f6f;
}
.header-btn {
height: 36px;
}
.table-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 12px;
min-height: 0;
height: 100%;
overflow: hidden;
align-items: center;
}
.table-desk {
display: block;
grid-column: 1;
grid-row: 1;
width: min(100%, calc((100dvh - 220px) * 16 / 9));
max-width: 100%;
height: auto;
aspect-ratio: 16 / 9;
justify-self: center;
align-self: center;
border-radius: 28px;
object-fit: contain;
object-position: center;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.04),
0 20px 42px rgba(0, 0, 0, 0.32);
}
.table-felt {
grid-column: 1;
grid-row: 1;
position: relative;
min-height: 0;
width: min(100%, calc((100dvh - 220px) * 16 / 9));
height: auto;
aspect-ratio: 16 / 9;
max-width: 100%;
max-height: 100%;
justify-self: center;
align-self: center;
border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.06);
background:
radial-gradient(circle at center, rgba(30, 126, 70, 0.12), transparent 42%),
linear-gradient(180deg, rgba(0, 0, 0, 0.03), rgba(0, 0, 0, 0.12));
overflow: hidden;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.table-felt::before {
content: '';
position: absolute;
inset: 22px;
border-radius: 24px;
background: radial-gradient(circle at center, rgba(35, 121, 68, 0.14), transparent 55%);
pointer-events: none;
}
.felt-frame {
position: absolute;
inset: 20px;
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.felt-frame.inner {
inset: 38px;
border-color: rgba(255, 255, 255, 0.06);
border-style: solid;
}
.center-deck {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: grid;
grid-template-columns: repeat(2, 42px);
gap: 6px;
align-items: center;
justify-items: center;
padding: 12px 16px;
border-radius: 18px;
background: rgba(8, 27, 20, 0.82);
border: 1px solid rgba(244, 222, 163, 0.28);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
}
.center-deck strong {
grid-column: 1 / -1;
font-size: 16px;
color: #f7e4b0;
}
.wind {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.08);
font-weight: 700;
}
.table-tip { .table-tip {
margin-top: 4px; margin-top: 4px;
color: #c1dfcf; color: #c1dfcf;
@@ -614,11 +899,22 @@ button:disabled {
} }
.ws-panel { .ws-panel {
margin-top: 10px; grid-column: 2;
grid-row: 1;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
margin-top: 0;
padding: 10px; padding: 10px;
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(176, 216, 194, 0.22); border: 1px solid rgba(176, 216, 194, 0.22);
background: rgba(5, 24, 17, 0.58); background: rgba(5, 24, 17, 0.58);
overflow: hidden;
}
.ws-log {
min-height: 0;
} }
.ws-panel-head { .ws-panel-head {
@@ -662,7 +958,8 @@ button:disabled {
.ws-log { .ws-log {
margin-top: 8px; margin-top: 8px;
max-height: 140px; flex: 1 1 auto;
min-height: 0;
overflow: auto; overflow: auto;
padding: 8px; padding: 8px;
border-radius: 8px; border-radius: 8px;
@@ -802,6 +1099,35 @@ button:disabled {
align-items: flex-start; align-items: flex-start;
} }
.game-header {
grid-template-columns: 1fr;
justify-items: stretch;
}
.topbar-left {
flex-wrap: wrap;
}
.topbar-center,
.topbar-right {
justify-content: flex-start;
}
.table-shell {
grid-template-columns: 1fr;
}
.table-desk,
.table-felt,
.ws-panel {
grid-column: auto;
grid-row: auto;
}
.ws-panel {
min-height: 180px;
}
.header-actions { .header-actions {
width: 100%; width: 100%;
display: grid; display: grid;
@@ -824,6 +1150,9 @@ button:disabled {
padding-right: max(8px, env(safe-area-inset-right)); padding-right: max(8px, env(safe-area-inset-right));
padding-bottom: max(8px, env(safe-area-inset-bottom)); padding-bottom: max(8px, env(safe-area-inset-bottom));
padding-left: max(8px, env(safe-area-inset-left)); padding-left: max(8px, env(safe-area-inset-left));
height: auto;
min-height: 100vh;
overflow: visible;
} }
.game-mahjong-table { .game-mahjong-table {

1772
src/assets/styles/room.css Normal file

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

@@ -0,0 +1,12 @@
<script setup lang="ts">
import SeatPlayerCard from './SeatPlayerCard.vue'
import type { SeatPlayerCardModel } from './seat-player-card'
defineProps<{
player: SeatPlayerCardModel
}>()
</script>
<template>
<SeatPlayerCard seat-class="seat-bottom" :player="player" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import SeatPlayerCard from './SeatPlayerCard.vue'
import type { SeatPlayerCardModel } from './seat-player-card'
defineProps<{
player: SeatPlayerCardModel
}>()
</script>
<template>
<SeatPlayerCard seat-class="seat-left" :player="player" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import SeatPlayerCard from './SeatPlayerCard.vue'
import type { SeatPlayerCardModel } from './seat-player-card'
defineProps<{
player: SeatPlayerCardModel
}>()
</script>
<template>
<SeatPlayerCard seat-class="seat-right" :player="player" />
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue'
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<{
seatClass: string
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 suit = normalizeMissingSuit(props.player.missingSuitLabel)
return suit ? getLackSuitImage(suit) : ''
})
const resolvedAvatarUrl = computed(() => {
return props.player.avatarUrl || defaultAvatarIcon
})
</script>
<template>
<article
class="player-badge"
:class="[seatClass, { 'is-turn': player.isTurn }]"
>
<div class="avatar-panel">
<div class="avatar-card">
<img :src="resolvedAvatarUrl" :alt="`${player.name}头像`" />
</div>
<span v-if="player.dealer" class="dealer-mark"></span>
</div>
<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">
<img v-if="missingSuitIcon" :src="missingSuitIcon" alt="" />
<span v-else>{{ player.missingSuitLabel }}</span>
</div>
</article>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import SeatPlayerCard from './SeatPlayerCard.vue'
import type { SeatPlayerCardModel } from './seat-player-card'
defineProps<{
player: SeatPlayerCardModel
}>()
</script>
<template>
<SeatPlayerCard seat-class="seat-top" :player="player" />
</template>

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

View File

@@ -0,0 +1,10 @@
// 玩家卡片展示模型用于座位UI渲染
export interface SeatPlayerCardModel {
avatarUrl: string // 头像 URL
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 ''
}
}

43
src/domain/tile/tile.ts Normal file
View File

@@ -0,0 +1,43 @@
import type {Suit, Tile} from "../../types/tile.ts";
export class TileModel {
id: number
suit: Suit
value: number
constructor(tile: Tile) {
this.id = tile.id
this.suit = tile.suit
this.value = tile.value
}
/** 花色中文 */
get suitName(): string {
const map: Record<Suit, string> = {
W: '万',
T: '筒',
B: '条',
}
return map[this.suit]
}
/** 显示文本 */
toString(): string {
return `${this.suitName}${this.value}[#${this.id}]`
}
/** 是否同一张牌和后端一致按ID */
equals(other: TileModel): boolean {
return this.id === other.id
}
/** 排序权重(用于手牌排序) */
get sortValue(): number {
const suitOrder: Record<Suit, number> = {
W: 0,
T: 1,
B: 2,
}
return suitOrder[this.suit] * 10 + this.value
}
}

113
src/game/actions.ts Normal file
View File

@@ -0,0 +1,113 @@
import type {GameState, PendingClaimState} from "../types/state";
import type {Tile} from "../types/tile.ts";
export interface RoomPlayerUpdatePayload {
room_id?: string
status?: string
player_count?: number
player_ids?: string[]
players?: Array<{
Index?: number
index?: number
PlayerID?: string
player_id?: string
PlayerName?: string
player_name?: string
AvatarUrl?: string
avatar_url?: string
Ready?: boolean
ready?: boolean
MissingSuit?: string | null
missing_suit?: string | null
}>
}
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[]
}
/**
* 游戏动作定义(只描述“发生了什么”)
*/
export type GameAction =
// 初始化整局(进入房间 / 断线重连)
| {
type: 'GAME_INIT'
payload: GameState
}
// 开始游戏(发牌完成)
| {
type: 'GAME_START'
payload: {
dealerIndex: number
}
}
// 摸牌
| {
type: 'DRAW_TILE'
payload: {
playerId: string
tile: Tile
}
}
// 出牌
| {
type: 'PLAY_TILE'
payload: {
playerId: string
tile: Tile
nextSeat: number
}
}
// 进入操作窗口(碰/杠/胡)
| {
type: 'PENDING_CLAIM'
payload: PendingClaimState
}
// 操作结束(碰/杠/胡/过)
| {
type: 'CLAIM_RESOLVED'
payload: {
playerId: string
action: 'peng' | 'gang' | 'hu' | 'pass'
}
}
// 房间玩家更新(等待房间人数变化)
| {
type: 'ROOM_PLAYER_UPDATE'
payload: RoomPlayerUpdatePayload
}
| {
type: 'ROOM_TRUSTEE'
payload: RoomTrusteePayload
}
| {
type: 'PLAYER_TURN'
payload: PlayerTurnPayload
}

49
src/game/dispatcher.ts Normal file
View File

@@ -0,0 +1,49 @@
import type {GameAction} from './actions'
import {useGameStore} from "../store/gameStore.ts";
export function dispatchGameAction(action: GameAction) {
const store = useGameStore()
switch (action.type) {
case 'GAME_INIT':
store.initGame(action.payload)
break
case 'GAME_START':
store.phase = 'playing'
store.dealerIndex = action.payload.dealerIndex
break
case 'DRAW_TILE':
store.onDrawTile(action.payload)
break
case 'PLAY_TILE':
store.onPlayTile(action.payload)
break
case 'PENDING_CLAIM':
store.onPendingClaim(action.payload)
break
case 'CLAIM_RESOLVED':
store.clearPendingClaim()
break
case 'ROOM_PLAYER_UPDATE':
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

2
src/game/seat.ts Normal file
View File

@@ -0,0 +1,2 @@
export type SeatKey = 'top' | 'right' | 'bottom' | 'left'

5
src/game/types.ts Normal file
View File

@@ -0,0 +1,5 @@
export type ActionButtonState = {
type: 'discard' | 'peng' | 'gang' | 'hu' | 'pass'
label: string
disabled: boolean
}

View File

@@ -1,6 +1,16 @@
import { createApp } from 'vue' import {createApp} from 'vue'
import './assets/styles/style.css' import {createPinia} from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
createApp(App).use(router).mount('#app') import './assets/styles/style.css'
const app = createApp(App)
const pinia = createPinia()
app.use(router)
app.use(pinia)
app.mount('#app')

View File

@@ -1,139 +0,0 @@
import { ref } from 'vue'
export const DEFAULT_MAX_PLAYERS = 4
export type RoomStatus = 'waiting' | 'playing' | 'finished'
export interface RoomPlayerState {
index: number
playerId: string
ready: boolean
}
export interface RuleState {
name: string
isBloodFlow: boolean
hasHongZhong: boolean
}
export interface GamePlayerState {
playerId: string
index: number
ready: boolean
}
export interface EngineState {
phase: string
dealerIndex: number
currentTurn: number
needDraw: boolean
players: GamePlayerState[]
wall: string[]
lastDiscardTile: string | null
lastDiscardBy: string
pendingClaim: Record<string, unknown> | null
winners: string[]
scores: Record<string, number>
lastDrawPlayerId: string
lastDrawFromGang: boolean
lastDrawIsLastTile: boolean
huWay: string
}
export interface GameState {
rule: RuleState | null
state: EngineState | null
}
export interface RoomState {
id: string
name: string
gameType: string
ownerId: string
maxPlayers: number
playerCount: number
status: RoomStatus | string
createdAt: string
updatedAt: string
game: GameState | null
players: RoomPlayerState[]
currentTurnIndex: number | null
}
function createInitialRoomState(): RoomState {
return {
id: '',
name: '',
gameType: 'chengdu',
ownerId: '',
maxPlayers: DEFAULT_MAX_PLAYERS,
playerCount: 0,
status: 'waiting',
createdAt: '',
updatedAt: '',
game: null,
players: [],
currentTurnIndex: null,
}
}
export const activeRoomState = ref<RoomState>(createInitialRoomState())
export function destroyActiveRoomState(): void {
activeRoomState.value = createInitialRoomState()
}
export function resetActiveRoomState(seed?: Partial<RoomState>): void {
destroyActiveRoomState()
if (!seed) {
return
}
activeRoomState.value = {
...activeRoomState.value,
...seed,
players: seed.players ?? [],
}
}
export function mergeActiveRoomState(next: RoomState): void {
if (activeRoomState.value.id && next.id && next.id !== activeRoomState.value.id) {
return
}
activeRoomState.value = {
...activeRoomState.value,
...next,
game: next.game ?? activeRoomState.value.game,
players: next.players.length > 0 ? next.players : activeRoomState.value.players,
currentTurnIndex:
next.currentTurnIndex !== null ? next.currentTurnIndex : activeRoomState.value.currentTurnIndex,
}
}
export function hydrateActiveRoomFromSelection(input: {
roomId: string
roomName?: string
gameType?: string
ownerId?: string
maxPlayers?: number
playerCount?: number
status?: string
createdAt?: string
updatedAt?: string
players?: RoomPlayerState[]
currentTurnIndex?: number | null
}): void {
resetActiveRoomState({
id: input.roomId,
name: input.roomName ?? '',
gameType: input.gameType ?? 'chengdu',
ownerId: input.ownerId ?? '',
maxPlayers: input.maxPlayers ?? DEFAULT_MAX_PLAYERS,
playerCount: input.playerCount ?? 0,
status: input.status ?? 'waiting',
createdAt: input.createdAt ?? '',
updatedAt: input.updatedAt ?? '',
players: input.players ?? [],
currentTurnIndex: input.currentTurnIndex ?? null,
})
}

300
src/store/gameStore.ts Normal file
View File

@@ -0,0 +1,300 @@
import { defineStore } from 'pinia'
import {
GAME_PHASE,
type GameState,
type PendingClaimState,
} from '../types/state'
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: '',
phase: GAME_PHASE.WAITING,
dealerIndex: 0,
currentTurn: 0,
currentPlayerId: '',
needDraw: false,
players: {},
remainingTiles: 0,
pendingClaim: undefined,
winners: [],
scores: {},
currentRound: 0,
totalRounds: 0,
}),
actions: {
resetGame() {
this.$reset()
},
// 初始<E5889D>?
initGame(data: GameState) {
Object.assign(this, data)
},
// 摸牌
onDrawTile(data: { playerId: string; tile: Tile }) {
const player = this.players[data.playerId]
if (!player) return
// 只更新自己的手牌
if (player.playerId === this.getMyPlayerId()) {
player.handTiles.push(data.tile)
}
player.handCount += 1
// 剩余牌数减少
this.remainingTiles = Math.max(0, this.remainingTiles - 1)
// 更新回合seatIndex<65>?
this.currentTurn = player.seatIndex
this.currentPlayerId = player.playerId
// 清除操作窗口
this.pendingClaim = undefined
this.needDraw = false
// 进入出牌阶段
this.phase = GAME_PHASE.PLAYING
},
// 出牌
onPlayTile(data: {
playerId: string
tile: Tile
nextSeat: number
}) {
const player = this.players[data.playerId]
if (!player) return
// 如果是自己,移除手牌
if (player.playerId === this.getMyPlayerId()) {
const index = player.handTiles.findIndex(
(t) => t.id === data.tile.id
)
if (index !== -1) {
player.handTiles.splice(index, 1)
}
}
player.handCount = Math.max(0, player.handCount - 1)
// 加入出牌<E587BA>?
player.discardTiles.push(data.tile)
// 更新回合
this.currentTurn = data.nextSeat
this.needDraw = true
// 等待其他玩家响应
this.phase = GAME_PHASE.ACTION
},
// 触发操作窗口(碰/<2F>?胡)
onPendingClaim(data: PendingClaimState) {
this.pendingClaim = data
this.needDraw = false
this.phase = GAME_PHASE.ACTION
},
onRoomPlayerUpdate(payload: RoomPlayerUpdatePayload) {
if (typeof payload.room_id === 'string' && payload.room_id) {
this.roomId = payload.room_id
}
if (typeof payload.status === 'string' && payload.status) {
const phaseMap: Record<string, GameState['phase']> = {
waiting: GAME_PHASE.WAITING,
dealing: GAME_PHASE.DEALING,
playing: GAME_PHASE.PLAYING,
action: GAME_PHASE.ACTION,
settlement: GAME_PHASE.SETTLEMENT,
}
this.phase = phaseMap[payload.status] ?? this.phase
}
const hasPlayerList =
Array.isArray(payload.players) || Array.isArray(payload.player_ids)
if (!hasPlayerList) {
return
}
const nextPlayers: GameState['players'] = {}
const players = Array.isArray(payload.players) ? payload.players : []
const playerIds = Array.isArray(payload.player_ids) ? payload.player_ids : []
players.forEach((raw, index) => {
const playerId =
(typeof raw.PlayerID === 'string' && raw.PlayerID) ||
(typeof raw.player_id === 'string' && raw.player_id) ||
playerIds[index]
if (!playerId) {
return
}
const previous = this.players[playerId]
const seatRaw = raw.Index ?? raw.index ?? index
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
nextPlayers[playerId] = {
playerId,
seatIndex,
displayName:
typeof displayNameRaw === 'string' && displayNameRaw
? displayNameRaw
: previous?.displayName,
avatarURL:
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:
ready !== null
? ready
: (previous?.isReady ?? false),
}
})
if (players.length === 0) {
playerIds.forEach((playerId, index) => {
if (typeof playerId !== 'string' || !playerId) {
return
}
const previous = this.players[playerId]
nextPlayers[playerId] = {
playerId,
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,
}
})
}
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后续建议放<E8AEAE>?userStore<72>?
getMyPlayerId(): string {
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 ''
},
},
})

50
src/store/index.ts Normal file
View File

@@ -0,0 +1,50 @@
import { ref } from 'vue'
import type {
ActiveRoomState,
ActiveRoomSelectionInput,
} from './state'
import { clearActiveRoomSnapshot, readActiveRoomSnapshot, saveActiveRoom } from './storage'
const activeRoom = ref<ActiveRoomState | null>(readActiveRoomSnapshot())
function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState {
return {
roomId: input.roomId,
roomName: input.roomName ?? '',
gameType: input.gameType ?? 'chengdu',
ownerId: input.ownerId ?? '',
maxPlayers: input.maxPlayers ?? 4,
playerCount: input.playerCount ?? input.players?.length ?? 0,
status: input.status ?? 'waiting',
createdAt: input.createdAt ?? '',
updatedAt: input.updatedAt ?? '',
players: input.players ?? [],
myHand: input.myHand ?? [],
game: input.game ?? {
state: {
wall: [],
scores: {},
dealerIndex: -1,
currentTurn: -1,
phase: 'waiting',
},
},
}
}
// 设置当前房间
export function setActiveRoom(input: ActiveRoomSelectionInput) {
const next = normalizeRoom(input)
activeRoom.value = next
saveActiveRoom(next)
}
export function clearActiveRoom() {
activeRoom.value = null
clearActiveRoomSnapshot()
}
// 使用房间状态
export function useActiveRoomState() {
return activeRoom
}

52
src/store/state.ts Normal file
View File

@@ -0,0 +1,52 @@
// 房间玩家状态
export interface RoomPlayerState {
index: number
playerId: string
displayName?: string
missingSuit?: string | null
ready: boolean
trustee?: boolean
hand: string[]
melds: string[]
outTiles: string[]
hasHu: boolean
}
// 房间整体状态
export interface ActiveRoomState {
roomId: string
roomName: string
gameType: string
ownerId: string
maxPlayers: number
playerCount: number
status: string
createdAt: string
updatedAt: string
players: RoomPlayerState[]
myHand: string[]
game?: {
state?: {
wall?: string[]
scores?: Record<string, number>
dealerIndex?: number
currentTurn?: number
phase?: string
}
}
}
export interface ActiveRoomSelectionInput {
roomId: string
roomName?: string
gameType?: string
ownerId?: string
maxPlayers?: number
playerCount?: number
status?: string
createdAt?: string
updatedAt?: string
players?: RoomPlayerState[]
myHand?: string[]
game?: ActiveRoomState['game']
}

25
src/store/storage.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { ActiveRoomState } from './state'
const KEY = 'mahjong_active_room'
// 读取缓存
export function readActiveRoomSnapshot(): ActiveRoomState | null {
const raw = localStorage.getItem(KEY)
if (!raw) return null
try {
return JSON.parse(raw)
} catch {
return null
}
}
// 写入缓存
export function saveActiveRoom(state: ActiveRoomState) {
localStorage.setItem(KEY, JSON.stringify(state))
}
// 清除缓存
export function clearActiveRoomSnapshot() {
localStorage.removeItem(KEY)
}

View File

@@ -0,0 +1,9 @@
export const CLAIM_OPTIONS = {
PENG: 'peng',
GANG: 'gang',
HU: 'hu',
PASS: 'pass',
} as const
export type ClaimOptionState =
typeof CLAIM_OPTIONS[keyof typeof CLAIM_OPTIONS]

View File

@@ -0,0 +1,21 @@
// 游戏阶段常量定义(用于标识当前对局所处阶段)
export const GAME_PHASE = {
WAITING: 'waiting', // 等待玩家准备 / 开始
DEALING: 'dealing', // 发牌阶段
PLAYING: 'playing', // 对局进行中
ACTION: 'action',
SETTLEMENT: 'settlement', // 结算阶段
} as const
// 游戏阶段类型(取自 GAME_PHASE 的值)
export type GamePhaseState =
typeof GAME_PHASE[keyof typeof GAME_PHASE]
export const GAME_PHASE_LABEL: Record<GamePhaseState, string> = {
waiting: '等待中',
dealing: '发牌',
playing: '对局中',
action: '操作中',
settlement: '结算',
}

View File

@@ -0,0 +1,42 @@
import type {PlayerState} from "./playerState.ts";
import type {PendingClaimState} from "./pendingClaimState.ts";
import type {GamePhaseState} from "./gamePhaseState.ts";
export interface GameState {
// 房间ID
roomId: string
// 当前阶段
phase: GamePhaseState
// 庄家位置
dealerIndex: number
// 当前操作玩家(座位)
currentTurn: number
// 当前操作玩家ID
currentPlayerId: string
// 当前回合是否需要先摸牌
needDraw: boolean
// 玩家列表
players: Record<string, PlayerState>
// 剩余数量
remainingTiles: number
// 操作响应窗口(碰/杠/胡)
pendingClaim?: PendingClaimState
// 胡牌玩家
winners: string[]
// 分数playerId -> score
scores: Record<string, number>
// 当前第几局
currentRound: number
// 总局数
totalRounds: number
}

View File

@@ -0,0 +1,12 @@
// 胡牌类型常量(用于避免直接写字符串)
export const HU_WAY = {
PING_HU: 'pinghu', // 平胡
QI_DUI: 'qidui', // 七对
PENG_PENG_HU: 'pengpenghu', // 碰碰胡
QING_YI_SE: 'qingyise', // 清一色
UNKNOWN: 'unknown', // 未知 / 未判定
} as const
// 胡牌类型(从常量中推导)
export type HuWayState =
typeof HU_WAY[keyof typeof HU_WAY]

7
src/types/state/index.ts Normal file
View File

@@ -0,0 +1,7 @@
export * from './gameState.ts'
export * from './playerState.ts'
export * from './meldState.ts'
export * from './pendingClaimState.ts'
export * from './claimOptionState.ts'
export * from './gamePhaseState.ts'
export * from './huWayState.ts'

View File

@@ -0,0 +1,17 @@
import type { Tile } from '../tile'
export type MeldState =
| {
type: 'peng'
tiles: Tile[]
fromPlayerId: string
}
| {
type: 'ming_gang'
tiles: Tile[]
fromPlayerId: string
}
| {
type: 'an_gang'
tiles: Tile[]
}

View File

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

View File

@@ -0,0 +1,28 @@
import type {Tile} from '../tile'
import type {MeldState} from './meldState.ts'
export interface PlayerState {
playerId: string
avatarURL?: string
seatIndex: number
displayName?: string
missingSuit?: string | null
isTrustee: boolean
// 手牌(只有自己有完整数据,后端可控制)
handTiles: Tile[]
handCount: number
// 副露(碰/杠)
melds: MeldState[]
// 出牌区
discardTiles: Tile[]
hasHu: boolean
// 分数
score: number
// 是否准备
isReady: boolean
}

9
src/types/tile.ts Normal file
View File

@@ -0,0 +1,9 @@
export type Suit = 'W' | 'T' | 'B'
export interface Tile {
id: number
suit: Suit
value: number
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,13 @@ import { useRouter } from 'vue-router'
import { AuthExpiredError, type AuthSession } from '../api/authed-request' import { AuthExpiredError, type AuthSession } from '../api/authed-request'
import { createRoom, joinRoom, listRooms, type RoomItem } from '../api/mahjong' import { createRoom, joinRoom, listRooms, type RoomItem } from '../api/mahjong'
import { getUserInfo, type UserInfo } from '../api/user' import { getUserInfo, type UserInfo } from '../api/user'
import { hydrateActiveRoomFromSelection } from '../state/active-room' import refreshIcon from '../assets/images/icons/refresh.svg'
import type { RoomPlayerState } from '../state/active-room' import { setActiveRoom } from '../store'
import type { RoomPlayerState } from '../store/state'
import type { StoredAuth } from '../types/session' import type { StoredAuth } from '../types/session'
import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage' import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
import { wsClient } from '../ws/client'
import { buildWsUrl } from '../ws/url'
const router = useRouter() const router = useRouter()
@@ -29,6 +32,7 @@ const createRoomForm = ref({
name: '', name: '',
gameType: 'chengdu', gameType: 'chengdu',
maxPlayers: 4, maxPlayers: 4,
totalRounds: 8,
}) })
const quickJoinRoomId = ref('') const quickJoinRoomId = ref('')
@@ -128,7 +132,16 @@ function mapRoomPlayers(room: RoomItem): RoomPlayerState[] {
.map((item, fallbackIndex) => ({ .map((item, fallbackIndex) => ({
index: Number.isFinite(item.index) ? item.index : fallbackIndex, index: Number.isFinite(item.index) ? item.index : fallbackIndex,
playerId: item.player_id, playerId: item.player_id,
displayName:
(typeof item.player_name === 'string' && item.player_name) ||
(typeof item.PlayerName === 'string' && item.PlayerName) ||
(item.player_id === currentUserId.value ? displayName.value : undefined),
ready: Boolean(item.ready), ready: Boolean(item.ready),
trustee: false,
hand: [],
melds: [],
outTiles: [],
hasHu: false,
})) }))
.filter((item) => Boolean(item.playerId)) .filter((item) => Boolean(item.playerId))
} }
@@ -172,6 +185,14 @@ function currentSession(): AuthSession | null {
return toSession(auth.value) return toSession(auth.value)
} }
function connectGameWs(): void {
const token = auth.value?.token
if (!token) {
return
}
wsClient.connect(buildWsUrl(), token)
}
async function refreshRooms(): Promise<void> { async function refreshRooms(): Promise<void> {
const session = currentSession() const session = currentSession()
if (!session) { if (!session) {
@@ -227,12 +248,13 @@ 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,
) )
createdRoom.value = room createdRoom.value = room
hydrateActiveRoomFromSelection({ setActiveRoom({
roomId: room.room_id, roomId: room.room_id,
roomName: room.name, roomName: room.name,
gameType: room.game_type, gameType: room.game_type,
@@ -246,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()
@@ -278,7 +301,7 @@ async function handleJoinRoom(room?: { roomId?: string; roomName?: string }): Pr
roomSubmitting.value = true roomSubmitting.value = true
try { try {
const joinedRoom = await joinRoom(session, { roomId: targetRoomId }, syncAuth) const joinedRoom = await joinRoom(session, { roomId: targetRoomId }, syncAuth)
hydrateActiveRoomFromSelection({ setActiveRoom({
roomId: joinedRoom.room_id, roomId: joinedRoom.room_id,
roomName: joinedRoom.name, roomName: joinedRoom.name,
gameType: joinedRoom.game_type, gameType: joinedRoom.game_type,
@@ -293,6 +316,7 @@ async function handleJoinRoom(room?: { roomId?: string; roomName?: string }): Pr
quickJoinRoomId.value = joinedRoom.room_id quickJoinRoomId.value = joinedRoom.room_id
successMessage.value = `已加入房间:${joinedRoom.room_id}` successMessage.value = `已加入房间:${joinedRoom.room_id}`
await refreshRooms() await refreshRooms()
connectGameWs()
await router.push({ await router.push({
path: `/game/chengdu/${joinedRoom.room_id}`, path: `/game/chengdu/${joinedRoom.room_id}`,
query: joinedRoom.name ? { roomName: joinedRoom.name } : undefined, query: joinedRoom.name ? { roomName: joinedRoom.name } : undefined,
@@ -331,7 +355,7 @@ async function enterCreatedRoom(): Promise<void> {
} }
showCreatedModal.value = false showCreatedModal.value = false
hydrateActiveRoomFromSelection({ setActiveRoom({
roomId: createdRoom.value.room_id, roomId: createdRoom.value.room_id,
roomName: createdRoom.value.name, roomName: createdRoom.value.name,
gameType: createdRoom.value.game_type, gameType: createdRoom.value.game_type,
@@ -343,6 +367,7 @@ async function enterCreatedRoom(): Promise<void> {
updatedAt: createdRoom.value.updated_at, updatedAt: createdRoom.value.updated_at,
players: mapRoomPlayers(createdRoom.value), players: mapRoomPlayers(createdRoom.value),
}) })
connectGameWs()
await router.push({ await router.push({
path: `/game/chengdu/${createdRoom.value.room_id}`, path: `/game/chengdu/${createdRoom.value.room_id}`,
query: { roomName: createdRoom.value.name }, query: { roomName: createdRoom.value.name },
@@ -408,7 +433,9 @@ onMounted(async () => {
<article class="panel room-list-panel"> <article class="panel room-list-panel">
<div class="room-panel-header"> <div class="room-panel-header">
<h2>房间列表</h2> <h2>房间列表</h2>
<button class="icon-btn" type="button" :disabled="roomLoading" @click="refreshRooms">🔄</button> <button class="icon-btn" type="button" :disabled="roomLoading" @click="refreshRooms">
<img class="icon-btn-image" :src="refreshIcon" alt="刷新房间列表" />
</button>
</div> </div>
<div v-if="roomLoading" class="empty-state">正在加载房间...</div> <div v-if="roomLoading" class="empty-state">正在加载房间...</div>
@@ -426,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 })"
@@ -436,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>
@@ -448,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>
@@ -463,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>
@@ -475,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>
@@ -505,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>

View File

@@ -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>

180
src/ws/client.ts Normal file
View File

@@ -0,0 +1,180 @@
// WebSocket 连接状态
export type WsStatus =
// 空闲未连接
| 'idle'
// 正在连接
| 'connecting'
// 连接成功
| 'connected'
// 连接已关闭
| 'closed'
// 连接异常
| 'error'
// 消息回调
type MessageHandler = (data: any) => void
// 状态变化回调
type StatusHandler = (status: WsStatus) => void
// 错误回调
type ErrorHandler = (err: string) => void
class WsClient {
private ws: WebSocket | null = null
private url: string = ''
private token: string = '' // 保存 token用于重连
private status: WsStatus = 'idle'
private messageHandlers: MessageHandler[] = []
private statusHandlers: StatusHandler[] = []
private errorHandlers: ErrorHandler[] = []
private reconnectTimer: number | null = null
private reconnectDelay = 2000 // 重连间隔(毫秒)
private manualClosing = false
// 构造带 token 的 URL
private buildUrl(): string {
if (!this.token) return this.url
try {
const parsed = new URL(this.url)
parsed.searchParams.set('token', this.token)
return parsed.toString()
} catch {
const hasQuery = this.url.includes('?')
const connector = hasQuery ? '&' : '?'
return `${this.url}${connector}token=${encodeURIComponent(this.token)}`
}
}
// 建立连接
connect(url: string, token?: string) {
// 已连接或连接中则不重复连接
if (this.ws && (this.status === 'connected' || this.status === 'connecting')) {
return
}
this.url = url
if (token !== undefined) {
this.token = token // 保存 token用于重连
}
this.setStatus('connecting')
this.manualClosing = false
const finalUrl = this.buildUrl()
this.ws = new WebSocket(finalUrl)
// 连接成功
this.ws.onopen = () => {
this.setStatus('connected')
}
// 收到消息
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))
}
}
// 出错
this.ws.onerror = () => {
this.setStatus('error')
this.emitError('WebSocket error')
}
// 连接关闭
this.ws.onclose = () => {
this.setStatus('closed')
if (!this.manualClosing) {
this.tryReconnect()
}
this.manualClosing = false
}
}
// 发送消息
send(data: any) {
if (this.ws && this.status === 'connected') {
this.ws.send(JSON.stringify(data))
}
}
// 手动关闭
close() {
this.manualClosing = true
if (this.ws) {
this.ws.close()
this.ws = null
}
this.clearReconnect()
}
reconnect(url: string, token?: string) {
this.close()
this.connect(url, token)
}
// 订阅消息
onMessage(handler: MessageHandler) {
this.messageHandlers.push(handler)
}
// 订阅状态变化
onStatusChange(handler: StatusHandler) {
this.statusHandlers.push(handler)
return () => {
this.statusHandlers = this.statusHandlers.filter(fn => fn !== handler)
}
}
// 订阅错误
onError(handler: ErrorHandler) {
this.errorHandlers.push(handler)
}
// 设置状态并通知所有订阅者
private setStatus(status: WsStatus) {
this.status = status
this.statusHandlers.forEach(fn => fn(status))
}
// 触发错误回调
private emitError(msg: string) {
this.errorHandlers.forEach(fn => fn(msg))
}
// 尝试重连
private tryReconnect() {
this.clearReconnect()
this.reconnectTimer = window.setTimeout(() => {
if (this.url) {
this.connect(this.url) // 自动带 token
}
}, this.reconnectDelay)
}
// 清除重连定时器
private clearReconnect() {
if (this.reconnectTimer !== null) {
window.clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
}
// 单例
export const wsClient = new WsClient()

28
src/ws/handler.ts Normal file
View File

@@ -0,0 +1,28 @@
import {wsClient} from './client'
type Handler = (msg: any) => void
const handlerMap: Record<string, Handler[]> = {}
// 注册 handler
export function registerHandler(type: string, handler: Handler) {
if (!handlerMap[type]) {
handlerMap[type] = []
}
handlerMap[type].push(handler)
}
// 初始化监听
export function initWsHandler() {
wsClient.onMessage((msg) => {
console.log('[WS] 收到消息:', msg)
const handlers = handlerMap[msg.type]
if (handlers && handlers.length > 0) {
handlers.forEach(h => h(msg))
} else {
console.warn('[WS] 未处理消息:', msg.type, msg)
}
})
}

30
src/ws/message.ts Normal file
View File

@@ -0,0 +1,30 @@
// 通用消息结构
/**
* 客户端 → 服务端(请求 / 操作)
*/
export interface ActionMessage<T = any> {
type: string
sender?: string
target?: string
roomId?: string
seq?: number
status?: string
trace_id?: string
payload?: T
}
/**
* 服务端 → 客户端(事件 / 推送)
*/
export interface ActionEvent<T = any> {
type: string
target?: string
roomId?: string
seq?: number
status?: string
requestId?: string
trace_id?: string
payload?: T
}

6
src/ws/sender.ts Normal file
View File

@@ -0,0 +1,6 @@
import { wsClient } from './client'
export function sendWsMessage(message: any) {
console.log('[WS SEND]', message)
wsClient.send(message)
}

12
src/ws/url.ts Normal file
View File

@@ -0,0 +1,12 @@
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws'
export function buildWsUrl(): string {
const baseUrl = /^wss?:\/\//.test(WS_BASE_URL)
? new URL(WS_BASE_URL)
: new URL(
WS_BASE_URL.startsWith('/') ? WS_BASE_URL : `/${WS_BASE_URL}`,
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
)
return baseUrl.toString()
}

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,20 +1,36 @@
import { defineConfig } 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({ export default defineConfig(({mode}) => {
plugins: [vue()], const env = loadEnv(mode, process.cwd(), '')
server: { const apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:19000').replace(/\/$/, '')
host: '127.0.0.1', const wsProxyTarget = (env.VITE_WS_PROXY_TARGET || apiProxyTarget).replace(/\/$/, '')
port: 3000,
proxy: { return {
'/api/v1': { resolve: {
target: 'http://127.0.0.1:19000', alias: {
changeOrigin: true, '@': path.resolve(__dirname, 'src'),
}, '@src': path.resolve(__dirname, 'src'),
'/api/v1/ws': { },
target: 'ws://127.0.0.1:19000', },
ws: 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,
},
},
},
} }
}
}) })