Compare commits
12 Commits
main
...
716bc2b106
| Author | SHA1 | Date | |
|---|---|---|---|
| 716bc2b106 | |||
| ceba41fb08 | |||
| 72253b1391 | |||
| 58fe43607a | |||
| 292a4181ce | |||
| 84ce67b9be | |||
| 1b15748d0d | |||
| f97f1ffdbc | |||
| d4e217b11b | |||
| a5c833c769 | |||
| fcb9a02c68 | |||
| bb3b55f69b |
4
.env.development
Normal file
4
.env.development
Normal 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
3
.gitignore
vendored
@@ -22,3 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
|
||||
.tmp/
|
||||
|
||||
606
chengdu-mahjong-features.md
Normal file
606
chengdu-mahjong-features.md
Normal file
@@ -0,0 +1,606 @@
|
||||
# 成都麻将功能整理
|
||||
|
||||
本文基于当前项目 `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",
|
||||
"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 人基础发牌和回合流转
|
||||
- 已具备碰/杠/胡/过等核心动作
|
||||
- 已具备部分成都胡型与番数计算
|
||||
- 但尚未实现完整成都特色流程与结算
|
||||
|
||||
如果你是拿它和“标准成都麻将产品需求”对照,当前更像“成都麻将基础可玩内核 + 房间联机骨架”,还不是完整商用品规。
|
||||
@@ -7,7 +7,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.25",
|
||||
@@ -17,6 +18,7 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"playwright": "^1.58.2",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5"
|
||||
|
||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -24,6 +24,9 @@ importers:
|
||||
'@vue/tsconfig':
|
||||
specifier: ^0.8.1
|
||||
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:
|
||||
specifier: ~5.9.3
|
||||
version: 5.9.3
|
||||
@@ -448,6 +451,11 @@ packages:
|
||||
picomatch:
|
||||
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:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -474,6 +482,16 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
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:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -869,6 +887,9 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -886,6 +907,14 @@ snapshots:
|
||||
|
||||
picomatch@4.0.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:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
|
||||
168
scripts/open-four-players.mjs
Normal file
168
scripts/open-four-players.mjs
Normal file
@@ -0,0 +1,168 @@
|
||||
import { chromium } from 'playwright'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const baseUrl = 'http://127.0.0.1:5173'
|
||||
const chromePath = 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
|
||||
const rootDir = process.cwd()
|
||||
const runtimeDir = path.join(rootDir, '.tmp', 'manual-four-players', String(Date.now()))
|
||||
|
||||
const players = [
|
||||
{ username: 'pwtest04', loginId: '13812345681', password: 'play123', owner: true },
|
||||
{ username: 'pwtest01', loginId: '13812345678', password: 'play123', owner: false },
|
||||
{ username: 'pwtest02', loginId: '13812345679', password: 'play123', owner: false },
|
||||
{ username: 'pwtest03', loginId: '13812345680', password: 'play123', owner: false },
|
||||
]
|
||||
|
||||
function log(message) {
|
||||
console.log(`[setup] ${message}`)
|
||||
}
|
||||
|
||||
async function launchPlayer(player) {
|
||||
const profileDir = path.join(runtimeDir, player.username)
|
||||
await fs.mkdir(profileDir, { recursive: true })
|
||||
log(`launch browser for ${player.username}`)
|
||||
|
||||
const context = await chromium.launchPersistentContext(profileDir, {
|
||||
headless: false,
|
||||
executablePath: chromePath,
|
||||
args: ['--no-first-run', '--no-default-browser-check'],
|
||||
viewport: { width: 1400, height: 960 },
|
||||
})
|
||||
|
||||
let page = context.pages()[0]
|
||||
if (!page) {
|
||||
page = await context.newPage()
|
||||
}
|
||||
await page.goto(`${baseUrl}/login`, { waitUntil: 'domcontentloaded' })
|
||||
return { ...player, context, page, profileDir }
|
||||
}
|
||||
|
||||
async function login(page, player) {
|
||||
log(`login ${player.username}`)
|
||||
await page.goto(`${baseUrl}/login`, { waitUntil: 'domcontentloaded' })
|
||||
await page.getByRole('textbox', { name: '登录ID' }).fill(player.loginId)
|
||||
await page.getByRole('textbox', { name: '密码' }).fill(player.password)
|
||||
const submitButton = page.locator('form').getByRole('button', { name: '登录' })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/hall', { timeout: 15000 }),
|
||||
submitButton.click(),
|
||||
])
|
||||
await page.getByRole('heading', { name: '麻将游戏大厅' }).waitFor({ timeout: 15000 })
|
||||
log(`logged in ${player.username}`)
|
||||
}
|
||||
|
||||
async function createRoom(page) {
|
||||
const roomName = `manual-${Date.now()}`
|
||||
log(`create room ${roomName}`)
|
||||
await page.getByRole('button', { name: '创建房间' }).click()
|
||||
await page.getByRole('textbox', { name: '房间名' }).fill(roomName)
|
||||
await page.getByRole('button', { name: '创建', exact: true }).click()
|
||||
const roomText = await page.getByText(/房间ID:/).textContent()
|
||||
const roomId = roomText?.replace('房间ID:', '').trim()
|
||||
if (!roomId) {
|
||||
throw new Error('Failed to read room id')
|
||||
}
|
||||
await Promise.all([
|
||||
page.waitForURL(`**/game/chengdu/${roomId}*`, { timeout: 15000 }),
|
||||
page.getByRole('button', { name: '进入房间' }).click(),
|
||||
])
|
||||
log(`owner entered room ${roomId}`)
|
||||
return { roomId, roomName }
|
||||
}
|
||||
|
||||
async function joinRoom(page, roomId, username) {
|
||||
log(`join room ${roomId} as ${username}`)
|
||||
await page.goto(`${baseUrl}/hall`, { waitUntil: 'domcontentloaded' })
|
||||
await page.getByRole('textbox', { name: '输入 room_id' }).fill(roomId)
|
||||
await Promise.all([
|
||||
page.waitForURL(`**/game/chengdu/${roomId}*`, { timeout: 15000 }),
|
||||
page.getByRole('button', { name: '加入' }).click(),
|
||||
])
|
||||
log(`joined room ${roomId} as ${username}`)
|
||||
}
|
||||
|
||||
async function snapshotPage(session) {
|
||||
const bodyText = ((await session.page.locator('body').textContent()) ?? '').replace(/\s+/g, ' ').trim()
|
||||
return {
|
||||
username: session.username,
|
||||
loginId: session.loginId,
|
||||
url: session.page.url(),
|
||||
started: bodyText.includes('对局中') || bodyText.includes('牌局进行中'),
|
||||
bodyPreview: bodyText.slice(0, 240),
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(runtimeDir, { recursive: true })
|
||||
log(`runtimeDir ${runtimeDir}`)
|
||||
|
||||
const sessions = []
|
||||
for (const player of players) {
|
||||
sessions.push(await launchPlayer(player))
|
||||
}
|
||||
|
||||
try {
|
||||
for (const session of sessions) {
|
||||
await login(session.page, session)
|
||||
}
|
||||
|
||||
const owner = sessions.find((session) => session.owner)
|
||||
if (!owner) {
|
||||
throw new Error('Owner session missing')
|
||||
}
|
||||
|
||||
const { roomId } = await createRoom(owner.page)
|
||||
log(`room ready ${roomId}`)
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!session.owner) {
|
||||
await joinRoom(session.page, roomId, session.username)
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 8000))
|
||||
|
||||
const playersSnapshot = []
|
||||
for (const session of sessions) {
|
||||
await session.page.bringToFront()
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
let snapshot = await snapshotPage(session)
|
||||
if (!snapshot.started) {
|
||||
log(`focus retry for ${session.username}`)
|
||||
await session.page.bringToFront()
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
snapshot = await snapshotPage(session)
|
||||
}
|
||||
playersSnapshot.push(snapshot)
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
roomId,
|
||||
roomUrl: `${baseUrl}/game/chengdu/${roomId}`,
|
||||
players: playersSnapshot,
|
||||
}, null, 2))
|
||||
|
||||
if (process.env.KEEP_ALIVE === '1') {
|
||||
log('setup finished; keeping browsers alive for manual testing')
|
||||
await new Promise(() => {})
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
await session.context.close()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (process.env.KEEP_ALIVE !== '1') {
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
await session.context.close()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -39,6 +39,15 @@ function buildUrl(path: string): string {
|
||||
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)
|
||||
|
||||
@@ -47,6 +47,15 @@ function buildUrl(path: string): string {
|
||||
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 {
|
||||
const baseUrl = new URL(API_BASE_URL)
|
||||
const basePath = baseUrl.pathname.replace(/\/$/, '')
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.5;
|
||||
@@ -16,11 +23,14 @@
|
||||
body {
|
||||
margin: 0;
|
||||
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 {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -31,6 +41,8 @@ p {
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
@@ -44,10 +56,12 @@ p {
|
||||
width: min(440px, 100%);
|
||||
padding: 28px 24px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(8, 27, 20, 0.82);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(246, 212, 139, 0.18);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(62, 33, 26, 0.96), rgba(26, 14, 11, 0.96)),
|
||||
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 {
|
||||
@@ -194,8 +208,10 @@ button:disabled {
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: linear-gradient(130deg, rgba(22, 57, 43, 0.9), rgba(10, 30, 22, 0.92));
|
||||
border: 1px solid rgba(244, 210, 140, 0.14);
|
||||
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 {
|
||||
@@ -279,8 +295,8 @@ button:disabled {
|
||||
.panel {
|
||||
padding: 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(10, 30, 22, 0.85);
|
||||
border: 1px solid rgba(244, 210, 140, 0.12);
|
||||
background: rgba(40, 21, 17, 0.8);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
@@ -423,36 +439,113 @@ button:disabled {
|
||||
}
|
||||
|
||||
.game-page {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
padding-top: max(12px, env(safe-area-inset-top));
|
||||
padding-right: max(12px, env(safe-area-inset-right));
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
padding-left: max(12px, env(safe-area-inset-left));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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;
|
||||
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 {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
min-height: 60px;
|
||||
padding: 10px 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.room-brief {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 0;
|
||||
padding: 4px 2px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(194, 226, 208, 0.2);
|
||||
background: rgba(7, 28, 20, 0.55);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.room-brief-title {
|
||||
@@ -495,6 +588,389 @@ button:disabled {
|
||||
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;
|
||||
}
|
||||
|
||||
.table-watermark {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 24px;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: rgba(244, 240, 220, 0.82);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.table-watermark span {
|
||||
font-size: 12px;
|
||||
color: #f7e4b0;
|
||||
}
|
||||
|
||||
.table-watermark strong {
|
||||
font-size: 26px;
|
||||
letter-spacing: 2px;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.table-watermark small {
|
||||
font-size: 12px;
|
||||
color: #bdd8ca;
|
||||
}
|
||||
|
||||
.player-badge {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 154px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(248, 226, 173, 0.24);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(43, 52, 73, 0.84), rgba(17, 22, 34, 0.82)),
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 40%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 12px 28px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.avatar-panel {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.player-badge.seat-top {
|
||||
top: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.player-badge.seat-right {
|
||||
right: 24px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.player-badge.seat-bottom {
|
||||
bottom: 136px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.player-badge.seat-left {
|
||||
left: 24px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.player-badge.is-turn {
|
||||
border-color: rgba(244, 222, 163, 0.72);
|
||||
}
|
||||
|
||||
.player-badge.offline {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.avatar-card {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 248, 215, 0.32);
|
||||
background:
|
||||
linear-gradient(145deg, #b3e79c, #4eaf4a 46%, #2f7e28 100%);
|
||||
color: #f7fff7;
|
||||
font-weight: 800;
|
||||
box-shadow:
|
||||
inset 0 2px 4px rgba(255, 255, 255, 0.18),
|
||||
0 6px 14px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.player-meta p {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #eef5ff;
|
||||
}
|
||||
|
||||
.player-meta strong {
|
||||
font-size: 15px;
|
||||
color: #ffd85c;
|
||||
text-shadow: 0 0 10px rgba(255, 216, 92, 0.2);
|
||||
}
|
||||
|
||||
.dealer-mark,
|
||||
.missing-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dealer-mark {
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
bottom: -6px;
|
||||
background: linear-gradient(180deg, #ffe38a 0%, #f1b92e 100%);
|
||||
color: #5f3200;
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.missing-mark {
|
||||
margin-left: auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, rgba(114, 219, 149, 0.2) 0%, rgba(21, 148, 88, 0.34) 100%);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.missing-mark img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.missing-mark span {
|
||||
color: #effff5;
|
||||
}
|
||||
|
||||
.wall {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
filter: drop-shadow(0 6px 8px rgba(0, 0, 0, 0.22));
|
||||
}
|
||||
|
||||
.wall img {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.wall-top,
|
||||
.wall-bottom {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.wall-left,
|
||||
.wall-right {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wall-top {
|
||||
top: 154px;
|
||||
}
|
||||
|
||||
.wall-top img,
|
||||
.wall-bottom img {
|
||||
width: 24px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.wall-right {
|
||||
right: 132px;
|
||||
}
|
||||
|
||||
.wall-left {
|
||||
left: 132px;
|
||||
}
|
||||
|
||||
.wall-left img,
|
||||
.wall-right img {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.wall-bottom {
|
||||
bottom: 176px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-top: 4px;
|
||||
color: #c1dfcf;
|
||||
@@ -614,11 +1090,22 @@ button:disabled {
|
||||
}
|
||||
|
||||
.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;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(176, 216, 194, 0.22);
|
||||
background: rgba(5, 24, 17, 0.58);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ws-log {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ws-panel-head {
|
||||
@@ -662,7 +1149,8 @@ button:disabled {
|
||||
|
||||
.ws-log {
|
||||
margin-top: 8px;
|
||||
max-height: 140px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
@@ -802,6 +1290,35 @@ button:disabled {
|
||||
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 {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
@@ -824,6 +1341,9 @@ button:disabled {
|
||||
padding-right: max(8px, env(safe-area-inset-right));
|
||||
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
||||
padding-left: max(8px, env(safe-area-inset-left));
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.game-mahjong-table {
|
||||
|
||||
712
src/assets/styles/room.css
Normal file
712
src/assets/styles/room.css
Normal file
@@ -0,0 +1,712 @@
|
||||
.picture-scene {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: 18px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(116, 58, 41, 0.28), transparent 20%),
|
||||
linear-gradient(180deg, #3f2119 0%, #27140f 100%);
|
||||
}
|
||||
|
||||
.picture-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
min-height: calc(100vh - 36px);
|
||||
}
|
||||
|
||||
.table-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: start;
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 36px);
|
||||
}
|
||||
|
||||
.table-desk,
|
||||
.table-felt {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: calc(100dvh - 72px);
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.table-desk {
|
||||
grid-area: 1 / 1;
|
||||
display: block;
|
||||
margin-top: 18px;
|
||||
border-radius: 26px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 24px 44px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.table-felt {
|
||||
grid-area: 1 / 1;
|
||||
position: relative;
|
||||
margin-top: 18px;
|
||||
border-radius: 26px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-surface {
|
||||
position: absolute;
|
||||
inset: 18px;
|
||||
border-radius: 20px;
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(255, 255, 255, 0.04), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0.04) 52%, rgba(0, 0, 0, 0.08));
|
||||
}
|
||||
|
||||
.table-surface::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 20px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.03), transparent 40%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.01), rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
|
||||
.inner-outline {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inner-outline.outer {
|
||||
inset: 22px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(24, 63, 35, 0.82);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(177, 112, 69, 0.3),
|
||||
inset 0 0 0 6px rgba(8, 36, 18, 0.18);
|
||||
}
|
||||
|
||||
.inner-outline.mid {
|
||||
inset: 74px 92px 122px;
|
||||
border-radius: 20px;
|
||||
border: 2px solid rgba(180, 224, 187, 0.12);
|
||||
}
|
||||
|
||||
.inner-outline.diamond {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
border: 2px solid rgba(143, 199, 155, 0.08);
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.top-left-tools {
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
left: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.metal-circle {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border-radius: 50%;
|
||||
color: #d7f0ff;
|
||||
font-size: 26px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(156, 171, 191, 0.82), rgba(79, 94, 114, 0.86)),
|
||||
radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.28), transparent 42%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22),
|
||||
0 8px 16px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.left-counter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 42px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
color: #fff4cf;
|
||||
background: rgba(13, 15, 20, 0.78);
|
||||
}
|
||||
|
||||
.counter-light {
|
||||
width: 16px;
|
||||
height: 24px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(180deg, #54e061 0%, #179928 100%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.36),
|
||||
0 0 8px rgba(84, 224, 97, 0.35);
|
||||
}
|
||||
|
||||
.top-right-clock {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
color: #f1f6ff;
|
||||
background: rgba(14, 16, 22, 0.46);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.signal-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scene-watermark {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 38%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
color: rgba(7, 42, 19, 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scene-watermark strong {
|
||||
display: block;
|
||||
font-size: clamp(30px, 4vw, 52px);
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.scene-watermark span,
|
||||
.scene-watermark small {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: rgba(7, 42, 19, 0.24);
|
||||
}
|
||||
|
||||
.wall {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
filter: drop-shadow(0 8px 8px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.wall img {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.wall-top,
|
||||
.wall-bottom {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.wall-left,
|
||||
.wall-right {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wall-top {
|
||||
top: 54px;
|
||||
}
|
||||
|
||||
.wall-top img,
|
||||
.wall-bottom img {
|
||||
width: 24px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.wall-right {
|
||||
right: 110px;
|
||||
}
|
||||
|
||||
.wall-left {
|
||||
left: 110px;
|
||||
}
|
||||
|
||||
.wall-left img,
|
||||
.wall-right img {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.wall-bottom {
|
||||
bottom: 126px;
|
||||
}
|
||||
|
||||
.center-desk {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 46px);
|
||||
grid-template-areas:
|
||||
'north west'
|
||||
'count count'
|
||||
'south east';
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
border: 2px solid rgba(69, 55, 38, 0.72);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(107, 51, 41, 0.94), rgba(33, 35, 32, 0.96) 50%, rgba(26, 69, 36, 0.92)),
|
||||
radial-gradient(circle at center, rgba(255, 255, 255, 0.06), transparent 52%);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.center-desk strong {
|
||||
grid-area: count;
|
||||
text-align: center;
|
||||
font-size: 32px;
|
||||
color: #3ec37f;
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
|
||||
.wind {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wind.north {
|
||||
grid-area: north;
|
||||
}
|
||||
|
||||
.wind.west {
|
||||
grid-area: west;
|
||||
}
|
||||
|
||||
.wind.south {
|
||||
grid-area: south;
|
||||
}
|
||||
|
||||
.wind.east {
|
||||
grid-area: east;
|
||||
}
|
||||
|
||||
.floating-status {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #ffcf4d;
|
||||
font-size: clamp(24px, 2.6vw, 42px);
|
||||
font-weight: 800;
|
||||
text-shadow:
|
||||
0 0 12px rgba(255, 195, 0, 0.32),
|
||||
0 3px 0 rgba(130, 74, 0, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.floating-status img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.floating-status.top {
|
||||
top: 48px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.floating-status.left {
|
||||
left: 24%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.floating-status.right {
|
||||
right: 24%;
|
||||
top: 50%;
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
|
||||
.claim-banner {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 58%;
|
||||
transform: translate(-50%, -50%);
|
||||
min-width: 380px;
|
||||
padding: 14px 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
color: #eef7ef;
|
||||
background: rgba(13, 31, 17, 0.24);
|
||||
}
|
||||
|
||||
.claim-banner span {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.claim-banner strong {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.bottom-control-panel {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 8px;
|
||||
transform: translateX(-50%);
|
||||
width: min(100% - 120px, 1180px);
|
||||
padding: 8px 14px 12px;
|
||||
}
|
||||
|
||||
.control-copy {
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
color: rgba(239, 247, 237, 0.84);
|
||||
}
|
||||
|
||||
.control-copy p {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.control-copy small {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-orbs {
|
||||
position: absolute;
|
||||
right: 38px;
|
||||
top: -66px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.orb-button {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
inset 0 3px 8px rgba(255, 255, 255, 0.18),
|
||||
0 10px 20px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.orb-button.theme-gold {
|
||||
color: #8a4e00;
|
||||
background: radial-gradient(circle at 35% 28%, #fff7bf 0%, #ffd85d 42%, #d89a19 100%);
|
||||
}
|
||||
|
||||
.orb-button.theme-jade {
|
||||
color: #efffff;
|
||||
background: radial-gradient(circle at 35% 28%, #c4fff2 0%, #3ad8b4 42%, #00876e 100%);
|
||||
}
|
||||
|
||||
.orb-button.theme-blue {
|
||||
color: #effff2;
|
||||
background: radial-gradient(circle at 35% 28%, #bff2c8 0%, #6bc77c 42%, #2e7a43 100%);
|
||||
}
|
||||
|
||||
.orb-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.player-hand {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.tile-chip {
|
||||
min-width: 90px;
|
||||
height: 126px;
|
||||
border: 1px solid rgba(70, 80, 92, 0.18);
|
||||
border-radius: 8px;
|
||||
color: #14181d;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
background:
|
||||
linear-gradient(180deg, #ffffff 0%, #f8fafc 68%, #dfe6ed 100%);
|
||||
box-shadow:
|
||||
inset 0 -4px 0 #1ea328,
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||
0 6px 12px rgba(0, 0, 0, 0.18);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tile-chip.selected {
|
||||
transform: translateY(-18px);
|
||||
}
|
||||
|
||||
.empty-hand {
|
||||
text-align: center;
|
||||
color: rgba(237, 244, 253, 0.82);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-side-buttons {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 42%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.side-round {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #edf8ef;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(126, 140, 122, 0.82), rgba(59, 72, 57, 0.86)),
|
||||
radial-gradient(circle at 35% 28%, rgba(255, 255, 255, 0.22), transparent 40%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||
0 8px 14px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.side-round.gold {
|
||||
color: #7a4600;
|
||||
background: radial-gradient(circle at 35% 28%, #fff6c2 0%, #ffe16c 42%, #e3aa23 100%);
|
||||
}
|
||||
|
||||
.ws-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 36px);
|
||||
min-height: calc(100vh - 36px);
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 226, 175, 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(45, 24, 18, 0.94), rgba(26, 14, 11, 0.96)),
|
||||
radial-gradient(circle at top, rgba(255, 219, 154, 0.06), transparent 40%);
|
||||
box-shadow: 0 16px 28px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.sidebar-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #ffe2a0;
|
||||
}
|
||||
|
||||
.sidebar-head small {
|
||||
color: rgba(248, 233, 199, 0.68);
|
||||
}
|
||||
|
||||
.sidebar-btn {
|
||||
min-width: 76px;
|
||||
height: 38px;
|
||||
border: 1px solid rgba(255, 223, 164, 0.16);
|
||||
border-radius: 999px;
|
||||
color: #ffe9b7;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.sidebar-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.sidebar-stat {
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.sidebar-stat span {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: rgba(244, 233, 208, 0.62);
|
||||
}
|
||||
|
||||
.sidebar-stat strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #fff0c2;
|
||||
font-size: 15px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sidebar-error {
|
||||
margin-top: 14px;
|
||||
color: #ffc1c1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sidebar-log {
|
||||
flex: 1 1 auto;
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(9, 12, 19, 0.34);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sidebar-empty,
|
||||
.sidebar-line {
|
||||
font-size: 12px;
|
||||
color: #e6eef8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sidebar-line + .sidebar-line {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.picture-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table-desk,
|
||||
.table-felt {
|
||||
width: min(100%, calc((100dvh - 290px) * 16 / 9));
|
||||
}
|
||||
|
||||
.ws-sidebar {
|
||||
height: auto;
|
||||
min-height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.picture-scene {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.table-desk,
|
||||
.table-felt {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.wall-right {
|
||||
right: 88px;
|
||||
}
|
||||
|
||||
.wall-left {
|
||||
left: 88px;
|
||||
}
|
||||
|
||||
.inner-outline.mid {
|
||||
inset: 70px 72px 120px;
|
||||
}
|
||||
|
||||
.tile-chip {
|
||||
min-width: 70px;
|
||||
height: 102px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.action-orbs {
|
||||
right: 18px;
|
||||
top: -54px;
|
||||
}
|
||||
|
||||
.orb-button {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.table-desk,
|
||||
.table-felt {
|
||||
aspect-ratio: 9 / 16;
|
||||
}
|
||||
|
||||
.inner-outline.mid {
|
||||
inset: 92px 34px 190px;
|
||||
}
|
||||
|
||||
.inner-outline.diamond {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.wall-top,
|
||||
.wall-bottom {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wall-left {
|
||||
left: 32px;
|
||||
}
|
||||
|
||||
.wall-right {
|
||||
right: 32px;
|
||||
}
|
||||
|
||||
.floating-status.left,
|
||||
.floating-status.right {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.claim-banner {
|
||||
min-width: 0;
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.bottom-control-panel {
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
.action-orbs {
|
||||
position: static;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tile-chip {
|
||||
min-width: 48px;
|
||||
height: 76px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.table-side-buttons {
|
||||
right: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.side-round {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
12
src/components/game/BottomPlayerCard.vue
Normal file
12
src/components/game/BottomPlayerCard.vue
Normal 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>
|
||||
12
src/components/game/LeftPlayerCard.vue
Normal file
12
src/components/game/LeftPlayerCard.vue
Normal 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>
|
||||
12
src/components/game/RightPlayerCard.vue
Normal file
12
src/components/game/RightPlayerCard.vue
Normal 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>
|
||||
47
src/components/game/SeatPlayerCard.vue
Normal file
47
src/components/game/SeatPlayerCard.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import wanIcon from '../../assets/images/flowerClolor/wan.png'
|
||||
import tongIcon from '../../assets/images/flowerClolor/tong.png'
|
||||
import tiaoIcon from '../../assets/images/flowerClolor/tiao.png'
|
||||
import type { SeatPlayerCardModel } from './seat-player-card'
|
||||
|
||||
const props = defineProps<{
|
||||
seatClass: string
|
||||
player: SeatPlayerCardModel
|
||||
}>()
|
||||
|
||||
const missingSuitIcon = computed(() => {
|
||||
if (props.player.missingSuitLabel === '万') {
|
||||
return wanIcon
|
||||
}
|
||||
if (props.player.missingSuitLabel === '筒') {
|
||||
return tongIcon
|
||||
}
|
||||
if (props.player.missingSuitLabel === '条') {
|
||||
return tiaoIcon
|
||||
}
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="player-badge"
|
||||
:class="[seatClass, { 'is-turn': player.isTurn, offline: !player.isOnline }]"
|
||||
>
|
||||
<div class="avatar-panel">
|
||||
<div class="avatar-card">{{ player.avatar }}</div>
|
||||
<span v-if="player.dealer" class="dealer-mark">庄</span>
|
||||
</div>
|
||||
|
||||
<div class="player-meta">
|
||||
<p>{{ player.name }}</p>
|
||||
<strong>{{ player.money }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="missing-mark">
|
||||
<img v-if="missingSuitIcon" :src="missingSuitIcon" alt="" />
|
||||
<span v-else>{{ player.missingSuitLabel }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
12
src/components/game/TopPlayerCard.vue
Normal file
12
src/components/game/TopPlayerCard.vue
Normal 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>
|
||||
9
src/components/game/seat-player-card.ts
Normal file
9
src/components/game/seat-player-card.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface SeatPlayerCardModel {
|
||||
avatar: string
|
||||
name: string
|
||||
money: string
|
||||
dealer: boolean
|
||||
isTurn: boolean
|
||||
isOnline: boolean
|
||||
missingSuitLabel: string
|
||||
}
|
||||
1212
src/features/chengdu-game/useChengduGameRoom.ts
Normal file
1212
src/features/chengdu-game/useChengduGameRoom.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,13 @@ export type RoomStatus = 'waiting' | 'playing' | 'finished'
|
||||
export interface RoomPlayerState {
|
||||
index: number
|
||||
playerId: string
|
||||
displayName?: string
|
||||
ready: boolean
|
||||
handCount?: number
|
||||
melds?: string[]
|
||||
outTiles?: string[]
|
||||
hasHu?: boolean
|
||||
missingSuit?: string | null
|
||||
}
|
||||
|
||||
export interface RuleState {
|
||||
@@ -57,6 +63,7 @@ export interface RoomState {
|
||||
game: GameState | null
|
||||
players: RoomPlayerState[]
|
||||
currentTurnIndex: number | null
|
||||
myHand: string[]
|
||||
}
|
||||
|
||||
function createInitialRoomState(): RoomState {
|
||||
@@ -73,6 +80,7 @@ function createInitialRoomState(): RoomState {
|
||||
game: null,
|
||||
players: [],
|
||||
currentTurnIndex: null,
|
||||
myHand: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +100,7 @@ export function resetActiveRoomState(seed?: Partial<RoomState>): void {
|
||||
...activeRoomState.value,
|
||||
...seed,
|
||||
players: seed.players ?? [],
|
||||
myHand: seed.myHand ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,10 +112,17 @@ export function mergeActiveRoomState(next: RoomState): void {
|
||||
activeRoomState.value = {
|
||||
...activeRoomState.value,
|
||||
...next,
|
||||
name: next.name || activeRoomState.value.name,
|
||||
gameType: next.gameType || activeRoomState.value.gameType,
|
||||
ownerId: next.ownerId || activeRoomState.value.ownerId,
|
||||
status: next.status || activeRoomState.value.status,
|
||||
createdAt: next.createdAt || activeRoomState.value.createdAt,
|
||||
updatedAt: next.updatedAt || activeRoomState.value.updatedAt,
|
||||
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,
|
||||
myHand: next.myHand.length > 0 ? next.myHand : activeRoomState.value.myHand,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +138,7 @@ export function hydrateActiveRoomFromSelection(input: {
|
||||
updatedAt?: string
|
||||
players?: RoomPlayerState[]
|
||||
currentTurnIndex?: number | null
|
||||
myHand?: string[]
|
||||
}): void {
|
||||
resetActiveRoomState({
|
||||
id: input.roomId,
|
||||
@@ -135,5 +152,6 @@ export function hydrateActiveRoomFromSelection(input: {
|
||||
updatedAt: input.updatedAt ?? '',
|
||||
players: input.players ?? [],
|
||||
currentTurnIndex: input.currentTurnIndex ?? null,
|
||||
myHand: input.myHand ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
53
tests/e2e/chengdu-flow.spec.ts
Normal file
53
tests/e2e/chengdu-flow.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
function uniqueUser() {
|
||||
const stamp = Date.now().toString()
|
||||
return {
|
||||
username: `pw${stamp.slice(-8)}`,
|
||||
phone: `13${stamp.slice(-9)}`,
|
||||
email: `pw${stamp}@example.com`,
|
||||
password: 'playwright123',
|
||||
roomName: `pw-room-${stamp.slice(-6)}`,
|
||||
}
|
||||
}
|
||||
|
||||
test('register, login, create room, enter game, and back to hall', async ({ page }) => {
|
||||
const user = uniqueUser()
|
||||
|
||||
await page.goto('/register')
|
||||
|
||||
const registerInputs = page.locator('.auth-card .form input')
|
||||
await registerInputs.nth(0).fill(user.username)
|
||||
await registerInputs.nth(1).fill(user.phone)
|
||||
await registerInputs.nth(2).fill(user.email)
|
||||
await registerInputs.nth(3).fill(user.password)
|
||||
await registerInputs.nth(4).fill(user.password)
|
||||
await page.locator('.auth-card .primary-btn[type="submit"]').click()
|
||||
|
||||
await page.waitForURL(/\/login/)
|
||||
|
||||
const loginInputs = page.locator('.auth-card .form input')
|
||||
await expect(loginInputs.nth(0)).toHaveValue(user.phone)
|
||||
await loginInputs.nth(1).fill(user.password)
|
||||
await page.locator('.auth-card .primary-btn[type="submit"]').click()
|
||||
|
||||
await page.waitForURL(/\/hall/)
|
||||
await expect(page.locator('.hall-page')).toBeVisible()
|
||||
|
||||
await page.locator('.room-actions-footer .primary-btn').click()
|
||||
await expect(page.locator('.modal-card')).toBeVisible()
|
||||
|
||||
await page.locator('.modal-card .field input').first().fill(user.roomName)
|
||||
await page.locator('.modal-card .modal-actions .primary-btn').click()
|
||||
|
||||
await expect(page.locator('.copy-line')).toHaveCount(2)
|
||||
await page.locator('.modal-card .modal-actions .primary-btn').click()
|
||||
|
||||
await page.waitForURL(/\/game\/chengdu\//)
|
||||
await expect(page.locator('.game-page')).toBeVisible()
|
||||
await expect(page.locator('.table-felt')).toBeVisible()
|
||||
|
||||
await page.locator('.topbar-back-btn').click()
|
||||
await page.waitForURL(/\/hall/)
|
||||
await expect(page.locator('.hall-page')).toBeVisible()
|
||||
})
|
||||
@@ -1,20 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api/v1': {
|
||||
target: 'http://127.0.0.1:19000',
|
||||
changeOrigin: true,
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:19000').replace(/\/$/, '')
|
||||
const wsProxyTarget = (env.VITE_WS_PROXY_TARGET || apiProxyTarget).replace(/\/$/, '')
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/ws': {
|
||||
target: wsProxyTarget,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewriteWsOrigin: true,
|
||||
},
|
||||
'/api/v1': {
|
||||
target: apiProxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
'/api/v1/ws': {
|
||||
target: 'ws://127.0.0.1:19000',
|
||||
ws: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user