Compare commits
55 Commits
main
...
3c876c4c3d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c876c4c3d | |||
| cfc65070ea | |||
| 0bf68d4e49 | |||
| e96c45739e | |||
| 39d85f2998 | |||
| e6cba75f9b | |||
| 6c3fca3530 | |||
| 9b3e3fdb90 | |||
| e435fa9d96 | |||
| 941d878931 | |||
| 100d950eb8 | |||
| 06b25bde62 | |||
| 2625baf266 | |||
| 43439cb09d | |||
| be9bd8c76d | |||
| 623ee94b04 | |||
| 7751d3b8e3 | |||
| 5c9c2a180d | |||
| 4f7a54cf08 | |||
| d60a505226 | |||
| d1220cc45d | |||
| 7289635340 | |||
| dc09c7e487 | |||
| fd8f6d47fa | |||
| b1e394d675 | |||
| 921f47d916 | |||
| 0fa3c4f1df | |||
| 603f910e8b | |||
| f3137493af | |||
| 0f1684b8d7 | |||
| 6fde4bbc0d | |||
| 43744c2203 | |||
| 774dbbdc25 | |||
| ae5d8d48c4 | |||
| 6168117eb2 | |||
| 66834d8a7a | |||
| 2737971608 | |||
| 4a9b2f2db2 | |||
| 148e21f3b0 | |||
| 4f6ef1d0ec | |||
| 7316588d9e | |||
| 3219639b04 | |||
| 679116e455 | |||
| 716bc2b106 | |||
| ceba41fb08 | |||
| 72253b1391 | |||
| 58fe43607a | |||
| 292a4181ce | |||
| 84ce67b9be | |||
| 1b15748d0d | |||
| f97f1ffdbc | |||
| d4e217b11b | |||
| a5c833c769 | |||
| fcb9a02c68 | |||
| bb3b55f69b |
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
@@ -22,3 +22,6 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
|
||||||
|
.tmp/
|
||||||
|
|||||||
@@ -27,3 +27,6 @@ Preview the production build:
|
|||||||
```bash
|
```bash
|
||||||
pnpm preview
|
pnpm preview
|
||||||
```
|
```
|
||||||
|
|
||||||
|
测试账号:A,B,C,D
|
||||||
|
测试密码:123456
|
||||||
607
chengdu-mahjong-features.md
Normal 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 人基础发牌和回合流转
|
||||||
|
- 已具备碰/杠/胡/过等核心动作
|
||||||
|
- 已具备部分成都胡型与番数计算
|
||||||
|
- 但尚未实现完整成都特色流程与结算
|
||||||
|
|
||||||
|
如果你是拿它和“标准成都麻将产品需求”对照,当前更像“成都麻将基础可玩内核 + 房间联机骨架”,还不是完整商用品规。
|
||||||
@@ -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"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 3.2 MiB |
14
playwright.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
headless: true,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
})
|
||||||
128
pnpm-lock.yaml
generated
@@ -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)
|
||||||
|
|||||||
@@ -39,6 +39,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}`
|
||||||
|
}
|
||||||
|
|
||||||
// Avoid duplicated API prefix, e.g. base: /api/v1 + path: /api/v1/auth/login
|
// Avoid duplicated API prefix, e.g. base: /api/v1 + path: /api/v1/auth/login
|
||||||
try {
|
try {
|
||||||
const baseUrl = new URL(API_BASE_URL)
|
const baseUrl = new URL(API_BASE_URL)
|
||||||
@@ -161,7 +170,7 @@ export async function login(input: { loginId: string; password: string }): Promi
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -171,15 +180,28 @@ export async function refreshAccessToken(input: AuthSessionInput): Promise<AuthR
|
|||||||
throw new Error('缺少 refresh_token,无法刷新登录状态')
|
throw new Error('缺少 refresh_token,无法刷新登录状态')
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await request<Record<string, unknown>>(
|
const refreshBody = {
|
||||||
|
refreshToken: input.refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容不同后端实现:
|
||||||
|
// 1) 有的要求 Authorization + refresh token
|
||||||
|
// 2) 有的只接受 refresh token,不接受 Authorization
|
||||||
|
let payload: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
payload = await request<Record<string, unknown>>(
|
||||||
buildUrl(REFRESH_PATH),
|
buildUrl(REFRESH_PATH),
|
||||||
{
|
refreshBody,
|
||||||
refreshToken: input.refreshToken,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Authorization: createAuthHeader(input.token, input.tokenType),
|
Authorization: createAuthHeader(input.token, input.tokenType),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
} catch {
|
||||||
|
payload = await request<Record<string, unknown>>(
|
||||||
|
buildUrl(REFRESH_PATH),
|
||||||
|
refreshBody,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return parseAuthResult(payload)
|
return parseAuthResult(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(/\/$/, '')
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { authedRequest, type AuthSession } from './authed-request'
|
import {authedRequest, type AuthSession} from './authed-request'
|
||||||
|
|
||||||
export interface RoomItem {
|
export interface Room {
|
||||||
room_id: string
|
room_id: string
|
||||||
name: string
|
name: string
|
||||||
game_type: string
|
game_type: string
|
||||||
@@ -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
|
||||||
@@ -18,7 +20,7 @@ export interface RoomItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomListResult {
|
export interface RoomListResult {
|
||||||
items: RoomItem[]
|
items: Room[]
|
||||||
page: number
|
page: number
|
||||||
size: number
|
size: number
|
||||||
total: number
|
total: number
|
||||||
@@ -31,10 +33,10 @@ 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<Room> {
|
||||||
return authedRequest<RoomItem>({
|
return authedRequest<Room>({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: ROOM_CREATE_PATH,
|
path: ROOM_CREATE_PATH,
|
||||||
auth,
|
auth,
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -63,8 +66,8 @@ export async function joinRoom(
|
|||||||
auth: AuthSession,
|
auth: AuthSession,
|
||||||
input: { roomId: string },
|
input: { roomId: string },
|
||||||
onAuthUpdated?: (next: AuthSession) => void,
|
onAuthUpdated?: (next: AuthSession) => void,
|
||||||
): Promise<RoomItem> {
|
): Promise<Room> {
|
||||||
return authedRequest<RoomItem>({
|
return authedRequest<Room>({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: ROOM_JOIN_PATH,
|
path: ROOM_JOIN_PATH,
|
||||||
auth,
|
auth,
|
||||||
|
|||||||
BIN
src/assets/images/actions/hu.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/images/actions/pass.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/assets/images/actions/peng.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
BIN
src/assets/images/desk/desk_01_1920_945.png
Normal file
|
After Width: | Height: | Size: 1006 KiB |
BIN
src/assets/images/direction/bei.png
Executable file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src/assets/images/direction/dong.png
Executable file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
src/assets/images/direction/nan.png
Executable file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src/assets/images/direction/xi.png
Executable file
|
After Width: | Height: | Size: 5.3 KiB |
1
src/assets/images/icons/avatar.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="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 |
BIN
src/assets/images/icons/cancel.png
Executable file
|
After Width: | Height: | Size: 11 KiB |
1
src/assets/images/icons/exit.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="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
|
After Width: | Height: | Size: 11 KiB |
1
src/assets/images/icons/refresh.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="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 |
1
src/assets/images/icons/robot.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="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 |
1
src/assets/images/icons/square.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1774505292809" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15817" width="256" height="256" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M1024 127.937531v767.625183c0 70.665495-57.272035 127.937531-127.937531 127.93753h-767.625183c-70.665495 0-127.937531-57.272035-127.93753-127.93753v-767.625183c0-70.665495 57.272035-127.937531 127.93753-127.937531h767.625183c70.665495 0 127.937531 57.272035 127.937531 127.937531z" p-id="15818" fill="#ffffff"></path></svg>
|
||||||
|
After Width: | Height: | Size: 657 B |
1
src/assets/images/icons/triangle.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1774491457300" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6759" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M535.466667 812.8l450.133333-563.2c14.933333-19.2 2.133333-49.066667-23.466667-49.066667H61.866667c-25.6 0-38.4 29.866667-23.466667 49.066667l450.133333 563.2c12.8 14.933333 34.133333 14.933333 46.933334 0z" fill="#ffffff" p-id="6760"></path></svg>
|
||||||
|
After Width: | Height: | Size: 581 B |
@@ -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
151
src/assets/styles/windowSquare.css
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
.wind-square {
|
||||||
|
position: relative;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 22px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 18px rgba(0, 0, 0, 0.28),
|
||||||
|
inset 0 0 0 1px rgba(255, 240, 196, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.square-base {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
filter: sepia(1) hue-rotate(92deg) saturate(3.3) brightness(0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-square::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(circle at 28% 22%, rgba(255, 238, 191, 0.08), transparent 42%),
|
||||||
|
linear-gradient(145deg, rgba(5, 33, 24, 0.34), rgba(0, 0, 0, 0.16));
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 四个三角形区域 ===== */
|
||||||
|
.quadrant {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 上三角 */
|
||||||
|
.quadrant-top {
|
||||||
|
clip-path: polygon(50% 50%, 0 0, 100% 0);
|
||||||
|
background: radial-gradient(circle at 50% 38%, rgba(255, 225, 180, 0.30), transparent 68%),
|
||||||
|
linear-gradient(to bottom, rgba(180, 95, 55, 0.28), rgba(80, 35, 20, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右三角 */
|
||||||
|
.quadrant-right {
|
||||||
|
clip-path: polygon(50% 50%, 100% 0, 100% 100%);
|
||||||
|
background: radial-gradient(circle at 62% 50%, rgba(255, 225, 180, 0.30), transparent 68%),
|
||||||
|
linear-gradient(to left, rgba(180, 95, 55, 0.28), rgba(80, 35, 20, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下三角 */
|
||||||
|
.quadrant-bottom {
|
||||||
|
clip-path: polygon(50% 50%, 0 100%, 100% 100%);
|
||||||
|
background: radial-gradient(circle at 50% 62%, rgba(255, 225, 180, 0.30), transparent 68%),
|
||||||
|
linear-gradient(to top, rgba(180, 95, 55, 0.28), rgba(80, 35, 20, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左三角 */
|
||||||
|
.quadrant-left {
|
||||||
|
clip-path: polygon(50% 50%, 0 0, 0 100%);
|
||||||
|
background: radial-gradient(circle at 38% 50%, rgba(255, 225, 180, 0.30), transparent 68%),
|
||||||
|
linear-gradient(to right, rgba(180, 95, 55, 0.28), rgba(80, 35, 20, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 激活时闪烁 */
|
||||||
|
.quadrant.active {
|
||||||
|
opacity: 1;
|
||||||
|
animation: quadrant-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes quadrant-pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 0.22;
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.72;
|
||||||
|
filter: brightness(1.18);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.22;
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagonal {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 160%;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(0, 0, 0, 0) 0%,
|
||||||
|
rgba(80, 35, 20, 0.6) 25%,
|
||||||
|
rgba(160, 85, 50, 0.9) 50%,
|
||||||
|
rgba(80, 35, 20, 0.6) 75%,
|
||||||
|
rgba(0, 0, 0, 0) 100%
|
||||||
|
);
|
||||||
|
transform-origin: center;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagonal-a {
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagonal-b {
|
||||||
|
transform: translate(-50%, -50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-slot {
|
||||||
|
position: absolute;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: brightness(0) invert(1) drop-shadow(0 0 2px rgba(255, 220, 180, 0.8)) drop-shadow(0 0 4px rgba(120, 60, 30, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-top {
|
||||||
|
top: 5px;
|
||||||
|
left: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-right {
|
||||||
|
top: 34px;
|
||||||
|
right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-bottom {
|
||||||
|
bottom: 5px;
|
||||||
|
left: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wind-left {
|
||||||
|
top: 34px;
|
||||||
|
left: 5px;
|
||||||
|
}
|
||||||
123
src/components/chengdu/ChengduBottomActions.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ClaimOptionState } from '../../types/state'
|
||||||
|
import type { Tile } from '../../types/tile'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
showDingQueChooser: boolean
|
||||||
|
showReadyToggle: boolean
|
||||||
|
showStartGameButton: boolean
|
||||||
|
selectedDiscardTile: Tile | null
|
||||||
|
dingQuePending: boolean
|
||||||
|
canConfirmDiscard: boolean
|
||||||
|
discardPending: boolean
|
||||||
|
confirmDiscardLabel: string
|
||||||
|
readyTogglePending: boolean
|
||||||
|
myReadyState: boolean
|
||||||
|
canDrawTile: boolean
|
||||||
|
canStartGame: boolean
|
||||||
|
isRoomOwner: boolean
|
||||||
|
canSelfGang: boolean
|
||||||
|
canSelfHu: boolean
|
||||||
|
showClaimActions: boolean
|
||||||
|
turnActionPending: boolean
|
||||||
|
visibleClaimOptions: ClaimOptionState[]
|
||||||
|
claimActionPending: boolean
|
||||||
|
showWaitingOwnerTip: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
chooseDingQue: [suit: Tile['suit']]
|
||||||
|
confirmDiscard: []
|
||||||
|
toggleReadyState: []
|
||||||
|
drawTile: []
|
||||||
|
startGame: []
|
||||||
|
submitSelfGang: []
|
||||||
|
submitSelfHu: []
|
||||||
|
submitClaim: [action: ClaimOptionState]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="showWaitingOwnerTip" class="waiting-owner-tip">
|
||||||
|
<span>等待房主开始游戏</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-control-panel">
|
||||||
|
<div v-if="showDingQueChooser || showReadyToggle || showStartGameButton || selectedDiscardTile" class="bottom-action-bar">
|
||||||
|
<div v-if="showDingQueChooser" class="ding-que-bar">
|
||||||
|
<button class="ding-que-button" data-testid="ding-que-w" type="button" :disabled="dingQuePending" @click="emit('chooseDingQue', 'W')">万</button>
|
||||||
|
<button class="ding-que-button" data-testid="ding-que-t" type="button" :disabled="dingQuePending" @click="emit('chooseDingQue', 'T')">筒</button>
|
||||||
|
<button class="ding-que-button" data-testid="ding-que-b" type="button" :disabled="dingQuePending" @click="emit('chooseDingQue', 'B')">条</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="selectedDiscardTile"
|
||||||
|
class="ready-toggle ready-toggle-inline discard-confirm-button"
|
||||||
|
data-testid="confirm-discard"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canConfirmDiscard || discardPending"
|
||||||
|
@click="emit('confirmDiscard')"
|
||||||
|
>
|
||||||
|
<span class="ready-toggle-label">{{ confirmDiscardLabel }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="showReadyToggle"
|
||||||
|
class="ready-toggle ready-toggle-inline"
|
||||||
|
data-testid="ready-toggle"
|
||||||
|
type="button"
|
||||||
|
:disabled="readyTogglePending"
|
||||||
|
@click="emit('toggleReadyState')"
|
||||||
|
>
|
||||||
|
<span class="ready-toggle-label">{{ myReadyState ? '取 消' : '准 备' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button v-if="canDrawTile" class="ready-toggle ready-toggle-inline" data-testid="draw-tile" type="button" @click="emit('drawTile')">
|
||||||
|
<span class="ready-toggle-label">摸牌</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="showStartGameButton && isRoomOwner"
|
||||||
|
class="ready-toggle ready-toggle-inline"
|
||||||
|
data-testid="start-game"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canStartGame"
|
||||||
|
@click="emit('startGame')"
|
||||||
|
>
|
||||||
|
<span class="ready-toggle-label">开始游戏</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canSelfGang" class="hand-action-bar">
|
||||||
|
<button class="hand-action-tile" data-testid="hand-gang" type="button" :disabled="turnActionPending" @click="emit('submitSelfGang')">
|
||||||
|
杠
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canSelfHu || showClaimActions" class="claim-action-bar" data-testid="claim-action-bar">
|
||||||
|
<button
|
||||||
|
v-if="canSelfHu"
|
||||||
|
class="ready-toggle ready-toggle-inline"
|
||||||
|
data-testid="claim-self-hu"
|
||||||
|
type="button"
|
||||||
|
:disabled="turnActionPending"
|
||||||
|
@click="emit('submitSelfHu')"
|
||||||
|
>
|
||||||
|
<span class="ready-toggle-label">胡</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="option in visibleClaimOptions"
|
||||||
|
:key="option"
|
||||||
|
class="ready-toggle ready-toggle-inline"
|
||||||
|
:data-testid="`claim-${option}`"
|
||||||
|
type="button"
|
||||||
|
:disabled="claimActionPending"
|
||||||
|
@click="emit('submitClaim', option)"
|
||||||
|
>
|
||||||
|
<span class="ready-toggle-label">
|
||||||
|
{{ option === 'peng' ? '碰' : option === 'gang' ? '杠' : option === 'hu' ? '胡' : '过' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
55
src/components/chengdu/ChengduDeskZones.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SeatKey } from '../../game/seat'
|
||||||
|
import type { DeskSeatState } from '../../views/chengdu/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
deskSeats: Record<SeatKey, DeskSeatState>
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="deskSeats.top.tiles.length > 0 || deskSeats.top.hasHu" class="desk-zone desk-zone-top">
|
||||||
|
<img
|
||||||
|
v-for="tile in deskSeats.top.tiles"
|
||||||
|
:key="tile.key"
|
||||||
|
class="desk-tile"
|
||||||
|
:class="{ 'is-group-start': tile.isGroupStart, 'is-covered': tile.imageType === 'covered' }"
|
||||||
|
:src="tile.src"
|
||||||
|
:alt="tile.alt"
|
||||||
|
/>
|
||||||
|
<span v-if="deskSeats.top.hasHu" class="desk-hu-flag">胡</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="deskSeats.right.tiles.length > 0 || deskSeats.right.hasHu" class="desk-zone desk-zone-right">
|
||||||
|
<img
|
||||||
|
v-for="tile in deskSeats.right.tiles"
|
||||||
|
:key="tile.key"
|
||||||
|
class="desk-tile"
|
||||||
|
:class="{ 'is-group-start': tile.isGroupStart, 'is-covered': tile.imageType === 'covered' }"
|
||||||
|
:src="tile.src"
|
||||||
|
:alt="tile.alt"
|
||||||
|
/>
|
||||||
|
<span v-if="deskSeats.right.hasHu" class="desk-hu-flag">胡</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="deskSeats.bottom.tiles.length > 0 || deskSeats.bottom.hasHu" class="desk-zone desk-zone-bottom">
|
||||||
|
<img
|
||||||
|
v-for="tile in deskSeats.bottom.tiles"
|
||||||
|
:key="tile.key"
|
||||||
|
class="desk-tile"
|
||||||
|
:class="{ 'is-group-start': tile.isGroupStart, 'is-covered': tile.imageType === 'covered' }"
|
||||||
|
:src="tile.src"
|
||||||
|
:alt="tile.alt"
|
||||||
|
/>
|
||||||
|
<span v-if="deskSeats.bottom.hasHu" class="desk-hu-flag">胡</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="deskSeats.left.tiles.length > 0 || deskSeats.left.hasHu" class="desk-zone desk-zone-left">
|
||||||
|
<img
|
||||||
|
v-for="tile in deskSeats.left.tiles"
|
||||||
|
:key="tile.key"
|
||||||
|
class="desk-tile"
|
||||||
|
:class="{ 'is-group-start': tile.isGroupStart, 'is-covered': tile.imageType === 'covered' }"
|
||||||
|
:src="tile.src"
|
||||||
|
:alt="tile.alt"
|
||||||
|
/>
|
||||||
|
<span v-if="deskSeats.left.hasHu" class="desk-hu-flag">胡</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
73
src/components/chengdu/ChengduSettlementOverlay.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
show: boolean
|
||||||
|
isLastRound: boolean
|
||||||
|
currentRound: number
|
||||||
|
totalRounds: number
|
||||||
|
settlementPlayers: Array<{
|
||||||
|
playerId: string
|
||||||
|
displayName: string
|
||||||
|
score: number
|
||||||
|
isWinner: boolean
|
||||||
|
seatIndex: number
|
||||||
|
}>
|
||||||
|
loggedInUserId: string
|
||||||
|
nextRoundPending: boolean
|
||||||
|
settlementCountdown: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
nextRound: []
|
||||||
|
backHall: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="show" class="settlement-overlay">
|
||||||
|
<div class="settlement-panel">
|
||||||
|
<h2 class="settlement-title">
|
||||||
|
{{ isLastRound ? '最终结算' : `第 ${currentRound} 局结算` }}
|
||||||
|
</h2>
|
||||||
|
<p v-if="totalRounds > 0" class="settlement-round-info">{{ currentRound }} / {{ totalRounds }} 局</p>
|
||||||
|
<div class="settlement-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in settlementPlayers"
|
||||||
|
:key="item.playerId"
|
||||||
|
class="settlement-row"
|
||||||
|
:class="{ 'is-winner': item.isWinner, 'is-self': item.playerId === loggedInUserId }"
|
||||||
|
>
|
||||||
|
<span class="settlement-rank">{{ index + 1 }}</span>
|
||||||
|
<span class="settlement-name">
|
||||||
|
{{ item.displayName }}
|
||||||
|
<span v-if="item.isWinner" class="settlement-winner-badge">胡</span>
|
||||||
|
</span>
|
||||||
|
<span class="settlement-score" :class="{ 'is-positive': item.score > 0, 'is-negative': item.score < 0 }">
|
||||||
|
{{ item.score > 0 ? '+' : '' }}{{ item.score }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settlement-actions">
|
||||||
|
<button
|
||||||
|
v-if="!isLastRound"
|
||||||
|
class="ready-toggle ready-toggle-inline settlement-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="nextRoundPending"
|
||||||
|
@click="emit('nextRound')"
|
||||||
|
>
|
||||||
|
<span class="ready-toggle-label">
|
||||||
|
{{
|
||||||
|
nextRoundPending
|
||||||
|
? '准备中...'
|
||||||
|
: settlementCountdown != null && settlementCountdown > 0
|
||||||
|
? `下一局 (${settlementCountdown}s)`
|
||||||
|
: '下一局'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button v-else class="ready-toggle ready-toggle-inline settlement-btn" type="button" @click="emit('backHall')">
|
||||||
|
<span class="ready-toggle-label">返回大厅</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
112
src/components/chengdu/ChengduTableHeader.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ActionCountdownView } from '../../views/chengdu/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
leaveRoomPending: boolean
|
||||||
|
menuOpen: boolean
|
||||||
|
menuTriggerActive: boolean
|
||||||
|
isTrustMode: boolean
|
||||||
|
wallCount: number
|
||||||
|
networkLabel: string
|
||||||
|
wsStatus: string
|
||||||
|
formattedClock: string
|
||||||
|
roomName: string
|
||||||
|
currentPhaseText: string
|
||||||
|
playerCount: number
|
||||||
|
maxPlayers: number
|
||||||
|
roundText: string
|
||||||
|
roomStatusText: string
|
||||||
|
wsError: string
|
||||||
|
actionCountdown: ActionCountdownView | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggleMenu: []
|
||||||
|
toggleTrustMode: []
|
||||||
|
leaveRoom: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="top-left-tools">
|
||||||
|
<div class="menu-trigger-wrap">
|
||||||
|
<button
|
||||||
|
class="metal-circle menu-trigger"
|
||||||
|
:class="{ 'is-feedback': menuTriggerActive }"
|
||||||
|
type="button"
|
||||||
|
:disabled="leaveRoomPending"
|
||||||
|
@click.stop="emit('toggleMenu')"
|
||||||
|
>
|
||||||
|
<span class="menu-trigger-icon">☰</span>
|
||||||
|
</button>
|
||||||
|
<transition name="menu-pop">
|
||||||
|
<div v-if="menuOpen" class="menu-popover" @click.stop>
|
||||||
|
<div class="menu-list">
|
||||||
|
<button class="menu-item menu-item-delay-1" type="button" @click="emit('toggleTrustMode')">
|
||||||
|
<slot name="robot-icon" />
|
||||||
|
<span>{{ isTrustMode ? '取消托管' : '托管' }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="menu-item menu-item-danger menu-item-delay-2"
|
||||||
|
type="button"
|
||||||
|
:disabled="leaveRoomPending"
|
||||||
|
@click="emit('leaveRoom')"
|
||||||
|
>
|
||||||
|
<slot name="exit-icon" />
|
||||||
|
<span>{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
<div class="left-counter">
|
||||||
|
<span class="counter-light"></span>
|
||||||
|
<strong>{{ wallCount }}</strong>
|
||||||
|
</div>
|
||||||
|
<span v-if="isTrustMode" class="trust-chip">托管中</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="top-right-clock">
|
||||||
|
<div class="signal-chip">
|
||||||
|
<span class="wifi-dot" :class="`is-${wsStatus}`"></span>
|
||||||
|
<strong>{{ networkLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
<span>{{ formattedClock }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="room-status-panel">
|
||||||
|
<div class="room-status-grid">
|
||||||
|
<div class="room-status-item">
|
||||||
|
<span>房间</span>
|
||||||
|
<strong>{{ roomName || '未命名' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="room-status-item">
|
||||||
|
<span>阶段</span>
|
||||||
|
<strong>{{ currentPhaseText }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="room-status-item">
|
||||||
|
<span>人数</span>
|
||||||
|
<strong>{{ playerCount }}/{{ maxPlayers }}</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="roundText" class="room-status-item">
|
||||||
|
<span>局数</span>
|
||||||
|
<strong>{{ roundText }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="room-status-item">
|
||||||
|
<span>状态</span>
|
||||||
|
<strong>{{ roomStatusText }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="wsError" class="room-status-error">{{ wsError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="actionCountdown" class="action-countdown" :class="{ 'is-self': actionCountdown.isSelf }">
|
||||||
|
<div class="action-countdown-head">
|
||||||
|
<span>{{ actionCountdown.playerLabel }}操作倒计时</span>
|
||||||
|
<strong>{{ actionCountdown.remaining }}s</strong>
|
||||||
|
</div>
|
||||||
|
<div class="action-countdown-track">
|
||||||
|
<span class="action-countdown-fill" :style="{ width: `${actionCountdown.progress}%` }"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
90
src/components/chengdu/ChengduWallSeats.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SeatKey } from '../../game/seat'
|
||||||
|
import type { Tile } from '../../types/tile'
|
||||||
|
import type { WallSeatState } from '../../views/chengdu/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
wallSeats: Record<SeatKey, WallSeatState>
|
||||||
|
selectedDiscardTileId: number | null
|
||||||
|
discardBlockedReason: string
|
||||||
|
discardTileBlockedReason: (tile: Tile) => string
|
||||||
|
formatTile: (tile: Tile) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
selectDiscardTile: [tile: Tile]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="wallSeats.top.tiles.length > 0" class="wall wall-top wall-live">
|
||||||
|
<img
|
||||||
|
v-for="(tile, index) in wallSeats.top.tiles"
|
||||||
|
:key="tile.key"
|
||||||
|
class="wall-live-tile"
|
||||||
|
:class="{
|
||||||
|
'is-group-start': index > 0 && tile.suit && wallSeats.top.tiles[index - 1]?.suit !== tile.suit,
|
||||||
|
'is-exposed': tile.imageType !== 'hand',
|
||||||
|
}"
|
||||||
|
:src="tile.src"
|
||||||
|
:alt="tile.alt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="wallSeats.right.tiles.length > 0" class="wall wall-right wall-live">
|
||||||
|
<img
|
||||||
|
v-for="(tile, index) in wallSeats.right.tiles"
|
||||||
|
:key="tile.key"
|
||||||
|
class="wall-live-tile"
|
||||||
|
:class="{
|
||||||
|
'is-group-start': index > 0 && tile.suit && wallSeats.right.tiles[index - 1]?.suit !== tile.suit,
|
||||||
|
'is-exposed': tile.imageType !== 'hand',
|
||||||
|
}"
|
||||||
|
:src="tile.src"
|
||||||
|
:alt="tile.alt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="wallSeats.bottom.tiles.length > 0" class="wall wall-bottom wall-live">
|
||||||
|
<template v-for="(tile, index) in wallSeats.bottom.tiles" :key="tile.key">
|
||||||
|
<button
|
||||||
|
v-if="tile.tile && tile.imageType === 'hand'"
|
||||||
|
class="wall-live-tile-button"
|
||||||
|
:class="{
|
||||||
|
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
||||||
|
'is-lack-tagged': tile.showLackTag,
|
||||||
|
'is-selected': selectedDiscardTileId === tile.tile.id,
|
||||||
|
}"
|
||||||
|
:data-testid="`hand-tile-${tile.tile.id}`"
|
||||||
|
type="button"
|
||||||
|
:disabled="Boolean(discardBlockedReason)"
|
||||||
|
:title="discardTileBlockedReason(tile.tile) || formatTile(tile.tile)"
|
||||||
|
@click="emit('selectDiscardTile', tile.tile)"
|
||||||
|
>
|
||||||
|
<span v-if="tile.showLackTag" class="wall-live-tile-lack-tag">缺</span>
|
||||||
|
<img class="wall-live-tile" :src="tile.src" :alt="tile.alt" />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
class="wall-live-tile"
|
||||||
|
:class="{
|
||||||
|
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
||||||
|
'is-exposed': tile.imageType !== 'hand',
|
||||||
|
}"
|
||||||
|
:src="tile.src"
|
||||||
|
:alt="tile.alt"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="wallSeats.left.tiles.length > 0" class="wall wall-left wall-live">
|
||||||
|
<img
|
||||||
|
v-for="(tile, index) in wallSeats.left.tiles"
|
||||||
|
:key="tile.key"
|
||||||
|
class="wall-live-tile"
|
||||||
|
:class="{
|
||||||
|
'is-group-start': index > 0 && tile.suit && wallSeats.left.tiles[index - 1]?.suit !== tile.suit,
|
||||||
|
'is-exposed': tile.imageType !== 'hand',
|
||||||
|
}"
|
||||||
|
:src="tile.src"
|
||||||
|
:alt="tile.alt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
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
@@ -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
@@ -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>
|
||||||
63
src/components/game/SeatPlayerCard.vue
Normal 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>
|
||||||
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>
|
||||||
42
src/components/game/WindSquare.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import squareIcon from '../../assets/images/icons/square.svg'
|
||||||
|
import '@src/assets/styles/windowSquare.css'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
seatWinds: {
|
||||||
|
top: string
|
||||||
|
right: string
|
||||||
|
bottom: string
|
||||||
|
left: string
|
||||||
|
}
|
||||||
|
activePosition?: 'top' | 'right' | 'bottom' | 'left' | ''
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="wind-square">
|
||||||
|
<img class="square-base" :src="squareIcon" alt="" />
|
||||||
|
|
||||||
|
<!-- 四个三角形高亮区域 -->
|
||||||
|
<div class="quadrant quadrant-top" :class="{ active: activePosition === 'top' }"></div>
|
||||||
|
<div class="quadrant quadrant-right" :class="{ active: activePosition === 'right' }"></div>
|
||||||
|
<div class="quadrant quadrant-bottom" :class="{ active: activePosition === 'bottom' }"></div>
|
||||||
|
<div class="quadrant quadrant-left" :class="{ active: activePosition === 'left' }"></div>
|
||||||
|
|
||||||
|
<div class="diagonal diagonal-a"></div>
|
||||||
|
<div class="diagonal diagonal-b"></div>
|
||||||
|
|
||||||
|
<span class="wind-slot wind-top">
|
||||||
|
<img class="wind-icon" :src="seatWinds.top" alt="上方位风" />
|
||||||
|
</span>
|
||||||
|
<span class="wind-slot wind-right">
|
||||||
|
<img class="wind-icon" :src="seatWinds.right" alt="右方位风" />
|
||||||
|
</span>
|
||||||
|
<span class="wind-slot wind-bottom">
|
||||||
|
<img class="wind-icon" :src="seatWinds.bottom" alt="下方位风" />
|
||||||
|
</span>
|
||||||
|
<span class="wind-slot wind-left">
|
||||||
|
<img class="wind-icon" :src="seatWinds.left" alt="左方位风" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
10
src/components/game/seat-player-card.ts
Normal 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
@@ -0,0 +1,299 @@
|
|||||||
|
// src/config/bottomTileMap.ts
|
||||||
|
|
||||||
|
export type Suit = 'W' | 'T' | 'B' | 'F' | 'D'
|
||||||
|
|
||||||
|
export interface Tile {
|
||||||
|
id: number
|
||||||
|
suit: Suit
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片用途:
|
||||||
|
* - hand: 手牌
|
||||||
|
* - exposed: 碰/杠/胡等明牌
|
||||||
|
* - covered: 盖住的牌
|
||||||
|
*/
|
||||||
|
export type TileImageType = 'hand' | 'exposed' | 'covered'
|
||||||
|
|
||||||
|
export type TilePosition = 'bottom'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手牌图索引:
|
||||||
|
* p4b1_x => 万
|
||||||
|
* p4b2_x => 筒
|
||||||
|
* p4b3_x => 条
|
||||||
|
* p4b4_x => 东南西北中发白
|
||||||
|
*/
|
||||||
|
const HAND_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
|
||||||
|
W: 1,
|
||||||
|
T: 2,
|
||||||
|
B: 3,
|
||||||
|
F: 4,
|
||||||
|
D: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 明牌图索引:
|
||||||
|
* p4s1_x => 万
|
||||||
|
* p4s2_x => 筒
|
||||||
|
* p4s3_x => 条
|
||||||
|
* p4s4_x => 东南西北中发白
|
||||||
|
*/
|
||||||
|
const EXPOSED_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
|
||||||
|
W: 1,
|
||||||
|
T: 2,
|
||||||
|
B: 3,
|
||||||
|
F: 4,
|
||||||
|
D: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字牌 value 映射:
|
||||||
|
* 风牌 F:
|
||||||
|
* 1=东 2=南 3=西 4=北
|
||||||
|
*
|
||||||
|
* 箭牌 D:
|
||||||
|
* 1=中 2=发 3=白
|
||||||
|
*
|
||||||
|
* 在图片资源中:
|
||||||
|
* 1=东 2=南 3=西 4=北 5=中 6=发 7=白
|
||||||
|
*/
|
||||||
|
function getHonorImageValue(suit: Suit, value: number): number {
|
||||||
|
if (suit === 'F') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (suit === 'D') {
|
||||||
|
return value + 4
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建手牌图片 key
|
||||||
|
* 例如:
|
||||||
|
* /src/assets/images/tiles/bottom/p4b1_1.png
|
||||||
|
* /src/assets/images/tiles/bottom/p4b4_5.png
|
||||||
|
*/
|
||||||
|
function buildHandTileImageKey(
|
||||||
|
suit: Suit,
|
||||||
|
value: number,
|
||||||
|
position: TilePosition = 'bottom',
|
||||||
|
): string {
|
||||||
|
const suitIndex = HAND_SUIT_INDEX_MAP[suit]
|
||||||
|
const imageValue = suit === 'F' || suit === 'D'
|
||||||
|
? getHonorImageValue(suit, value)
|
||||||
|
: value
|
||||||
|
|
||||||
|
return `/src/assets/images/tiles/${position}/p4b${suitIndex}_${imageValue}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建明牌图片 key(碰/杠/胡漏出的牌)
|
||||||
|
* 例如:
|
||||||
|
* /src/assets/images/tiles/bottom/p4s1_1.png
|
||||||
|
* /src/assets/images/tiles/bottom/p4s4_5.png
|
||||||
|
*/
|
||||||
|
function buildExposedTileImageKey(
|
||||||
|
suit: Suit,
|
||||||
|
value: number,
|
||||||
|
position: TilePosition = 'bottom',
|
||||||
|
): string {
|
||||||
|
const suitIndex = EXPOSED_SUIT_INDEX_MAP[suit]
|
||||||
|
const imageValue = suit === 'F' || suit === 'D'
|
||||||
|
? getHonorImageValue(suit, value)
|
||||||
|
: value
|
||||||
|
|
||||||
|
return `/src/assets/images/tiles/${position}/p4s${suitIndex}_${imageValue}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建盖牌图片 key
|
||||||
|
*/
|
||||||
|
function buildCoveredTileImageKey(position: TilePosition = 'bottom'): string {
|
||||||
|
return `/src/assets/images/tiles/${position}/tdbgs_4.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 Vite 收集所有麻将牌资源
|
||||||
|
*/
|
||||||
|
const tileImageModules = import.meta.glob(
|
||||||
|
'/src/assets/images/tiles/bottom/*.png',
|
||||||
|
{
|
||||||
|
eager: true,
|
||||||
|
import: 'default',
|
||||||
|
},
|
||||||
|
) as Record<string, string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法花色
|
||||||
|
*/
|
||||||
|
export function isValidSuit(suit: string): suit is Suit {
|
||||||
|
return suit === 'W' || suit === 'T' || suit === 'B' || suit === 'F' || suit === 'D'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法点数
|
||||||
|
* W/T/B => 1~9
|
||||||
|
* F => 1~4
|
||||||
|
* D => 1~3
|
||||||
|
*/
|
||||||
|
export function isValidTileValueBySuit(suit: Suit, value: number): boolean {
|
||||||
|
if (!Number.isInteger(value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (suit) {
|
||||||
|
case 'W':
|
||||||
|
case 'T':
|
||||||
|
case 'B':
|
||||||
|
return value >= 1 && value <= 9
|
||||||
|
case 'F':
|
||||||
|
return value >= 1 && value <= 4
|
||||||
|
case 'D':
|
||||||
|
return value >= 1 && value <= 3
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法牌
|
||||||
|
*/
|
||||||
|
export function isValidTile(tile: { suit: string; value: number }): tile is Pick<Tile, 'suit' | 'value'> {
|
||||||
|
if (!isValidSuit(tile.suit)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isValidTileValueBySuit(tile.suit, tile.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取手牌图片
|
||||||
|
*/
|
||||||
|
export function getHandTileImage(
|
||||||
|
tile: Pick<Tile, 'suit' | 'value'>,
|
||||||
|
position: TilePosition = 'bottom',
|
||||||
|
): string {
|
||||||
|
if (!isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = buildHandTileImageKey(tile.suit, tile.value, position)
|
||||||
|
return tileImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取碰/杠/胡漏出的明牌图片
|
||||||
|
*/
|
||||||
|
export function getExposedTileImage(
|
||||||
|
tile: Pick<Tile, 'suit' | 'value'>,
|
||||||
|
position: TilePosition = 'bottom',
|
||||||
|
): string {
|
||||||
|
if (!isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = buildExposedTileImageKey(tile.suit, tile.value, position)
|
||||||
|
return tileImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取盖住的牌图片
|
||||||
|
*/
|
||||||
|
export function getCoveredTileImage(position: TilePosition = 'bottom'): string {
|
||||||
|
const key = buildCoveredTileImageKey(position)
|
||||||
|
return tileImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一获取牌图片
|
||||||
|
*/
|
||||||
|
export function getTileImage(
|
||||||
|
tile: Pick<Tile, 'suit' | 'value'>,
|
||||||
|
imageType: TileImageType = 'hand',
|
||||||
|
position: TilePosition = 'bottom',
|
||||||
|
): string {
|
||||||
|
if (imageType === 'covered') {
|
||||||
|
return getCoveredTileImage(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageType === 'exposed') {
|
||||||
|
return getExposedTileImage(tile, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
return getHandTileImage(tile, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有基础牌(不含重复)
|
||||||
|
* 包含:
|
||||||
|
* - 万 1~9
|
||||||
|
* - 筒 1~9
|
||||||
|
* - 条 1~9
|
||||||
|
* - 东南西北
|
||||||
|
* - 中发白
|
||||||
|
*/
|
||||||
|
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
|
||||||
|
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
|
||||||
|
|
||||||
|
// 万筒条
|
||||||
|
const numberSuits: Array<'W' | 'T' | 'B'> = ['W', 'T', 'B']
|
||||||
|
for (const suit of numberSuits) {
|
||||||
|
for (let value = 1; value <= 9; value++) {
|
||||||
|
result.push({ suit, value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 东南西北
|
||||||
|
for (let value = 1; value <= 4; value++) {
|
||||||
|
result.push({ suit: 'F', value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中发白
|
||||||
|
for (let value = 1; value <= 3; value++) {
|
||||||
|
result.push({ suit: 'D', value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取牌的中文名称
|
||||||
|
*/
|
||||||
|
export function getTileLabel(tile: Pick<Tile, 'suit' | 'value'>): string {
|
||||||
|
if (!isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (tile.suit) {
|
||||||
|
case 'W':
|
||||||
|
return `${tile.value}万`
|
||||||
|
case 'T':
|
||||||
|
return `${tile.value}筒`
|
||||||
|
case 'B':
|
||||||
|
return `${tile.value}条`
|
||||||
|
case 'F': {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
1: '东',
|
||||||
|
2: '南',
|
||||||
|
3: '西',
|
||||||
|
4: '北',
|
||||||
|
}
|
||||||
|
return map[tile.value] || ''
|
||||||
|
}
|
||||||
|
case 'D': {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
1: '中',
|
||||||
|
2: '发',
|
||||||
|
3: '白',
|
||||||
|
}
|
||||||
|
return map[tile.value] || ''
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/config/deskImageMap.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// src/config/deskImageMap.ts
|
||||||
|
|
||||||
|
export interface DeskAsset {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
ratio: number
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有桌面资源
|
||||||
|
export const DESK_ASSETS: DeskAsset[] = [
|
||||||
|
{
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
ratio: 1920 / 1080,
|
||||||
|
src: new URL('@/assets/images/desk/desk_01_1920_1080.png', import.meta.url).href,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
width: 1920,
|
||||||
|
height: 945,
|
||||||
|
ratio: 1920 / 945,
|
||||||
|
src: new URL('@/assets/images/desk/desk_01_1920_945.png', import.meta.url).href,
|
||||||
|
},
|
||||||
|
]
|
||||||
41
src/config/flowerColorMap.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// src/config/lackSuitMap.ts
|
||||||
|
|
||||||
|
|
||||||
|
import type {Suit} from "../types/tile.ts";
|
||||||
|
|
||||||
|
const lackSuitImageModules = import.meta.glob(
|
||||||
|
'/src/assets/images/flowerClolor/*.png',
|
||||||
|
{
|
||||||
|
eager: true,
|
||||||
|
import: 'default',
|
||||||
|
},
|
||||||
|
) as Record<string, string>
|
||||||
|
|
||||||
|
const SUIT_FILE_MAP: Record<Suit, 'wan' | 'tong' | 'tiao'> = {
|
||||||
|
W: 'wan',
|
||||||
|
T: 'tong',
|
||||||
|
B: 'tiao',
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLackSuitImageKey(suit: Suit): string {
|
||||||
|
const fileName = SUIT_FILE_MAP[suit]
|
||||||
|
return `/src/assets/images/flowerClolor/${fileName}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据花色获取缺门图标
|
||||||
|
* W -> wan.png
|
||||||
|
* T -> tong.png
|
||||||
|
* B -> tiao.png
|
||||||
|
*/
|
||||||
|
export function getLackSuitImage(suit: Suit): string {
|
||||||
|
const key = buildLackSuitImageKey(suit)
|
||||||
|
return lackSuitImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法缺门花色
|
||||||
|
*/
|
||||||
|
export function isValidLackSuit(suit: string): suit is Suit {
|
||||||
|
return suit === 'W' || suit === 'T' || suit === 'B'
|
||||||
|
}
|
||||||
251
src/config/leftTileMap.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
// src/config/leftTileMap.ts
|
||||||
|
|
||||||
|
export type Suit = 'W' | 'T' | 'B' | 'F' | 'D'
|
||||||
|
|
||||||
|
export interface Tile {
|
||||||
|
id: number
|
||||||
|
suit: Suit
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片用途:
|
||||||
|
* - hand: 左侧手牌背面
|
||||||
|
* - exposed: 左侧碰/杠/胡等明牌
|
||||||
|
* - covered: 左侧盖住的牌
|
||||||
|
*/
|
||||||
|
export type TileImageType = 'hand' | 'exposed' | 'covered'
|
||||||
|
|
||||||
|
export type TilePosition = 'left'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 明牌图索引:
|
||||||
|
* p3s1_x => 万
|
||||||
|
* p3s2_x => 筒
|
||||||
|
* p3s3_x => 条
|
||||||
|
* p3s4_x => 东南西北中发白
|
||||||
|
*/
|
||||||
|
const EXPOSED_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
|
||||||
|
W: 1,
|
||||||
|
T: 2,
|
||||||
|
B: 3,
|
||||||
|
F: 4,
|
||||||
|
D: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字牌 value 映射:
|
||||||
|
* F:
|
||||||
|
* 1=东 2=南 3=西 4=北
|
||||||
|
*
|
||||||
|
* D:
|
||||||
|
* 1=中 2=发 3=白
|
||||||
|
*
|
||||||
|
* 图片资源中:
|
||||||
|
* 1=东 2=南 3=西 4=北 5=中 6=发 7=白
|
||||||
|
*/
|
||||||
|
function getHonorImageValue(suit: Suit, value: number): number {
|
||||||
|
if (suit === 'F') return value
|
||||||
|
if (suit === 'D') return value + 4
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建左侧明牌图片 key
|
||||||
|
* 例如:
|
||||||
|
* /src/assets/images/tiles/left/p3s1_1.png
|
||||||
|
* /src/assets/images/tiles/left/p3s4_5.png
|
||||||
|
*/
|
||||||
|
function buildExposedTileImageKey(
|
||||||
|
suit: Suit,
|
||||||
|
value: number,
|
||||||
|
position: TilePosition = 'left',
|
||||||
|
): string {
|
||||||
|
const suitIndex = EXPOSED_SUIT_INDEX_MAP[suit]
|
||||||
|
const imageValue =
|
||||||
|
suit === 'F' || suit === 'D' ? getHonorImageValue(suit, value) : value
|
||||||
|
|
||||||
|
return `/src/assets/images/tiles/${position}/p3s${suitIndex}_${imageValue}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建左侧手牌背面图片 key
|
||||||
|
*/
|
||||||
|
function buildHandTileImageKey(position: TilePosition = 'left'): string {
|
||||||
|
return `/src/assets/images/tiles/${position}/tbgs_3.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建左侧盖牌图片 key
|
||||||
|
*/
|
||||||
|
function buildCoveredTileImageKey(position: TilePosition = 'left'): string {
|
||||||
|
return `/src/assets/images/tiles/${position}/tdbgs_3.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 Vite 收集左侧麻将牌资源
|
||||||
|
*/
|
||||||
|
const tileImageModules = import.meta.glob('/src/assets/images/tiles/left/*.png', {
|
||||||
|
eager: true,
|
||||||
|
import: 'default',
|
||||||
|
}) as Record<string, string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法花色
|
||||||
|
*/
|
||||||
|
export function isValidSuit(suit: string): suit is Suit {
|
||||||
|
return suit === 'W' || suit === 'T' || suit === 'B' || suit === 'F' || suit === 'D'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法点数
|
||||||
|
* W/T/B => 1~9
|
||||||
|
* F => 1~4
|
||||||
|
* D => 1~3
|
||||||
|
*/
|
||||||
|
export function isValidTileValueBySuit(suit: Suit, value: number): boolean {
|
||||||
|
if (!Number.isInteger(value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (suit) {
|
||||||
|
case 'W':
|
||||||
|
case 'T':
|
||||||
|
case 'B':
|
||||||
|
return value >= 1 && value <= 9
|
||||||
|
case 'F':
|
||||||
|
return value >= 1 && value <= 4
|
||||||
|
case 'D':
|
||||||
|
return value >= 1 && value <= 3
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法牌
|
||||||
|
*/
|
||||||
|
export function isValidTile(tile: {
|
||||||
|
suit: string
|
||||||
|
value: number
|
||||||
|
}): tile is Pick<Tile, 'suit' | 'value'> {
|
||||||
|
if (!isValidSuit(tile.suit)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isValidTileValueBySuit(tile.suit, tile.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取左侧手牌背面图
|
||||||
|
*/
|
||||||
|
export function getHandTileImage(position: TilePosition = 'left'): string {
|
||||||
|
const key = buildHandTileImageKey(position)
|
||||||
|
return tileImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取左侧碰/杠/胡等明牌图片
|
||||||
|
*/
|
||||||
|
export function getExposedTileImage(
|
||||||
|
tile: Pick<Tile, 'suit' | 'value'>,
|
||||||
|
position: TilePosition = 'left',
|
||||||
|
): string {
|
||||||
|
if (!isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = buildExposedTileImageKey(tile.suit, tile.value, position)
|
||||||
|
return tileImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取左侧盖牌图片
|
||||||
|
*/
|
||||||
|
export function getCoveredTileImage(position: TilePosition = 'left'): string {
|
||||||
|
const key = buildCoveredTileImageKey(position)
|
||||||
|
return tileImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一获取左侧牌图片
|
||||||
|
*/
|
||||||
|
export function getTileImage(
|
||||||
|
tile?: Pick<Tile, 'suit' | 'value'>,
|
||||||
|
imageType: TileImageType = 'hand',
|
||||||
|
position: TilePosition = 'left',
|
||||||
|
): string {
|
||||||
|
if (imageType === 'hand') {
|
||||||
|
return getHandTileImage(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageType === 'covered') {
|
||||||
|
return getCoveredTileImage(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tile || !isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return getExposedTileImage(tile, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有基础牌(不含重复)
|
||||||
|
*/
|
||||||
|
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
|
||||||
|
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
|
||||||
|
|
||||||
|
const numberSuits: Array<'W' | 'T' | 'B'> = ['W', 'T', 'B']
|
||||||
|
for (const suit of numberSuits) {
|
||||||
|
for (let value = 1; value <= 9; value++) {
|
||||||
|
result.push({ suit, value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let value = 1; value <= 4; value++) {
|
||||||
|
result.push({ suit: 'F', value })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let value = 1; value <= 3; value++) {
|
||||||
|
result.push({ suit: 'D', value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取牌的中文名称
|
||||||
|
*/
|
||||||
|
export function getTileLabel(tile: Pick<Tile, 'suit' | 'value'>): string {
|
||||||
|
if (!isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (tile.suit) {
|
||||||
|
case 'W':
|
||||||
|
return `${tile.value}万`
|
||||||
|
case 'T':
|
||||||
|
return `${tile.value}筒`
|
||||||
|
case 'B':
|
||||||
|
return `${tile.value}条`
|
||||||
|
case 'F': {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
1: '东',
|
||||||
|
2: '南',
|
||||||
|
3: '西',
|
||||||
|
4: '北',
|
||||||
|
}
|
||||||
|
return map[tile.value] || ''
|
||||||
|
}
|
||||||
|
case 'D': {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
1: '中',
|
||||||
|
2: '发',
|
||||||
|
3: '白',
|
||||||
|
}
|
||||||
|
return map[tile.value] || ''
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/config/rightTileMap.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
// src/config/rightTileMap.ts
|
||||||
|
|
||||||
|
export type Suit = 'W' | 'T' | 'B' | 'F' | 'D'
|
||||||
|
|
||||||
|
export interface Tile {
|
||||||
|
id: number
|
||||||
|
suit: Suit
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片用途:
|
||||||
|
* - hand: 右侧手牌背面
|
||||||
|
* - exposed: 右侧碰/杠/胡等明牌
|
||||||
|
* - covered: 右侧盖住的牌
|
||||||
|
*/
|
||||||
|
export type TileImageType = 'hand' | 'exposed' | 'covered'
|
||||||
|
|
||||||
|
export type TilePosition = 'right'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 明牌图索引:
|
||||||
|
* p1s1_x => 万
|
||||||
|
* p1s2_x => 筒
|
||||||
|
* p1s3_x => 条
|
||||||
|
* p1s4_x => 东南西北中发白
|
||||||
|
*/
|
||||||
|
const EXPOSED_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
|
||||||
|
W: 1,
|
||||||
|
T: 2,
|
||||||
|
B: 3,
|
||||||
|
F: 4,
|
||||||
|
D: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字牌 value 映射:
|
||||||
|
* F:
|
||||||
|
* 1=东 2=南 3=西 4=北
|
||||||
|
*
|
||||||
|
* D:
|
||||||
|
* 1=中 2=发 3=白
|
||||||
|
*
|
||||||
|
* 图片资源中:
|
||||||
|
* 1=东 2=南 3=西 4=北 5=中 6=发 7=白
|
||||||
|
*/
|
||||||
|
function getHonorImageValue(suit: Suit, value: number): number {
|
||||||
|
if (suit === 'F') return value
|
||||||
|
if (suit === 'D') return value + 4
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建右侧明牌图片 key
|
||||||
|
* 例如:
|
||||||
|
* /src/assets/images/tiles/right/p1s1_1.png
|
||||||
|
* /src/assets/images/tiles/right/p1s4_5.png
|
||||||
|
*/
|
||||||
|
function buildExposedTileImageKey(
|
||||||
|
suit: Suit,
|
||||||
|
value: number,
|
||||||
|
position: TilePosition = 'right',
|
||||||
|
): string {
|
||||||
|
const suitIndex = EXPOSED_SUIT_INDEX_MAP[suit]
|
||||||
|
const imageValue =
|
||||||
|
suit === 'F' || suit === 'D' ? getHonorImageValue(suit, value) : value
|
||||||
|
|
||||||
|
return `/src/assets/images/tiles/${position}/p1s${suitIndex}_${imageValue}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建右侧手牌背面图片 key
|
||||||
|
*/
|
||||||
|
function buildHandTileImageKey(position: TilePosition = 'right'): string {
|
||||||
|
return `/src/assets/images/tiles/${position}/tbgs_1.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建右侧盖牌图片 key
|
||||||
|
*/
|
||||||
|
function buildCoveredTileImageKey(position: TilePosition = 'right'): string {
|
||||||
|
return `/src/assets/images/tiles/${position}/tdbgs_1.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 Vite 收集右侧麻将牌资源
|
||||||
|
*/
|
||||||
|
const tileImageModules = import.meta.glob('/src/assets/images/tiles/right/*.png', {
|
||||||
|
eager: true,
|
||||||
|
import: 'default',
|
||||||
|
}) as Record<string, string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法花色
|
||||||
|
*/
|
||||||
|
export function isValidSuit(suit: string): suit is Suit {
|
||||||
|
return suit === 'W' || suit === 'T' || suit === 'B' || suit === 'F' || suit === 'D'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法点数
|
||||||
|
* W/T/B => 1~9
|
||||||
|
* F => 1~4
|
||||||
|
* D => 1~3
|
||||||
|
*/
|
||||||
|
export function isValidTileValueBySuit(suit: Suit, value: number): boolean {
|
||||||
|
if (!Number.isInteger(value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (suit) {
|
||||||
|
case 'W':
|
||||||
|
case 'T':
|
||||||
|
case 'B':
|
||||||
|
return value >= 1 && value <= 9
|
||||||
|
case 'F':
|
||||||
|
return value >= 1 && value <= 4
|
||||||
|
case 'D':
|
||||||
|
return value >= 1 && value <= 3
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法牌
|
||||||
|
*/
|
||||||
|
export function isValidTile(tile: {
|
||||||
|
suit: string
|
||||||
|
value: number
|
||||||
|
}): tile is Pick<Tile, 'suit' | 'value'> {
|
||||||
|
if (!isValidSuit(tile.suit)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isValidTileValueBySuit(tile.suit, tile.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取右侧手牌背面图
|
||||||
|
*/
|
||||||
|
export function getHandTileImage(position: TilePosition = 'right'): string {
|
||||||
|
const key = buildHandTileImageKey(position)
|
||||||
|
return tileImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取右侧碰/杠/胡等明牌图片
|
||||||
|
*/
|
||||||
|
export function getExposedTileImage(
|
||||||
|
tile: Pick<Tile, 'suit' | 'value'>,
|
||||||
|
position: TilePosition = 'right',
|
||||||
|
): string {
|
||||||
|
if (!isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = buildExposedTileImageKey(tile.suit, tile.value, position)
|
||||||
|
return tileImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取右侧盖牌图片
|
||||||
|
*/
|
||||||
|
export function getCoveredTileImage(position: TilePosition = 'right'): string {
|
||||||
|
const key = buildCoveredTileImageKey(position)
|
||||||
|
return tileImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一获取右侧牌图片
|
||||||
|
*/
|
||||||
|
export function getTileImage(
|
||||||
|
tile?: Pick<Tile, 'suit' | 'value'>,
|
||||||
|
imageType: TileImageType = 'hand',
|
||||||
|
position: TilePosition = 'right',
|
||||||
|
): string {
|
||||||
|
if (imageType === 'hand') {
|
||||||
|
return getHandTileImage(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageType === 'covered') {
|
||||||
|
return getCoveredTileImage(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tile || !isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return getExposedTileImage(tile, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有基础牌(不含重复)
|
||||||
|
*/
|
||||||
|
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
|
||||||
|
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
|
||||||
|
|
||||||
|
const numberSuits: Array<'W' | 'T' | 'B'> = ['W', 'T', 'B']
|
||||||
|
for (const suit of numberSuits) {
|
||||||
|
for (let value = 1; value <= 9; value++) {
|
||||||
|
result.push({ suit, value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let value = 1; value <= 4; value++) {
|
||||||
|
result.push({ suit: 'F', value })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let value = 1; value <= 3; value++) {
|
||||||
|
result.push({ suit: 'D', value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取牌的中文名称
|
||||||
|
*/
|
||||||
|
export function getTileLabel(tile: Pick<Tile, 'suit' | 'value'>): string {
|
||||||
|
if (!isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (tile.suit) {
|
||||||
|
case 'W':
|
||||||
|
return `${tile.value}万`
|
||||||
|
case 'T':
|
||||||
|
return `${tile.value}筒`
|
||||||
|
case 'B':
|
||||||
|
return `${tile.value}条`
|
||||||
|
case 'F': {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
1: '东',
|
||||||
|
2: '南',
|
||||||
|
3: '西',
|
||||||
|
4: '北',
|
||||||
|
}
|
||||||
|
return map[tile.value] || ''
|
||||||
|
}
|
||||||
|
case 'D': {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
1: '中',
|
||||||
|
2: '发',
|
||||||
|
3: '白',
|
||||||
|
}
|
||||||
|
return map[tile.value] || ''
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/config/topTileMap.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
// src/config/topTileMap.ts
|
||||||
|
|
||||||
|
export type Suit = 'W' | 'T' | 'B' | 'F' | 'D'
|
||||||
|
|
||||||
|
export interface Tile {
|
||||||
|
id: number
|
||||||
|
suit: Suit
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片用途:
|
||||||
|
* - hand: 上方手牌背面
|
||||||
|
* - exposed: 上方碰/杠/胡等明牌
|
||||||
|
* - covered: 上方盖住的牌
|
||||||
|
*/
|
||||||
|
export type TileImageType = 'hand' | 'exposed' | 'covered'
|
||||||
|
|
||||||
|
export type TilePosition = 'top'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 明牌图索引:
|
||||||
|
* p2s1_x => 万
|
||||||
|
* p2s2_x => 筒
|
||||||
|
* p2s3_x => 条
|
||||||
|
* p2s4_x => 东南西北中发白
|
||||||
|
*/
|
||||||
|
const EXPOSED_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
|
||||||
|
W: 1,
|
||||||
|
T: 2,
|
||||||
|
B: 3,
|
||||||
|
F: 4,
|
||||||
|
D: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字牌 value 映射:
|
||||||
|
* F:
|
||||||
|
* 1=东 2=南 3=西 4=北
|
||||||
|
*
|
||||||
|
* D:
|
||||||
|
* 1=中 2=发 3=白
|
||||||
|
*
|
||||||
|
* 图片资源中:
|
||||||
|
* 1=东 2=南 3=西 4=北 5=中 6=发 7=白
|
||||||
|
*/
|
||||||
|
function getHonorImageValue(suit: Suit, value: number): number {
|
||||||
|
if (suit === 'F') return value
|
||||||
|
if (suit === 'D') return value + 4
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建上方明牌图片 key
|
||||||
|
* 例如:
|
||||||
|
* /src/assets/images/tiles/top/p2s1_1.png
|
||||||
|
* /src/assets/images/tiles/top/p2s4_5.png
|
||||||
|
*/
|
||||||
|
function buildExposedTileImageKey(
|
||||||
|
suit: Suit,
|
||||||
|
value: number,
|
||||||
|
position: TilePosition = 'top',
|
||||||
|
): string {
|
||||||
|
const suitIndex = EXPOSED_SUIT_INDEX_MAP[suit]
|
||||||
|
const imageValue =
|
||||||
|
suit === 'F' || suit === 'D' ? getHonorImageValue(suit, value) : value
|
||||||
|
|
||||||
|
return `/src/assets/images/tiles/${position}/p2s${suitIndex}_${imageValue}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建上方手牌背面图片 key
|
||||||
|
*/
|
||||||
|
function buildHandTileImageKey(position: TilePosition = 'top'): string {
|
||||||
|
return `/src/assets/images/tiles/${position}/tbgs_2.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建上方盖牌图片 key
|
||||||
|
*/
|
||||||
|
function buildCoveredTileImageKey(position: TilePosition = 'top'): string {
|
||||||
|
return `/src/assets/images/tiles/${position}/tdbgs_2.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 Vite 收集上方麻将牌资源
|
||||||
|
*/
|
||||||
|
const tileImageModules = import.meta.glob('/src/assets/images/tiles/top/*.png', {
|
||||||
|
eager: true,
|
||||||
|
import: 'default',
|
||||||
|
}) as Record<string, string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法花色
|
||||||
|
*/
|
||||||
|
export function isValidSuit(suit: string): suit is Suit {
|
||||||
|
return suit === 'W' || suit === 'T' || suit === 'B' || suit === 'F' || suit === 'D'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法点数
|
||||||
|
* W/T/B => 1~9
|
||||||
|
* F => 1~4
|
||||||
|
* D => 1~3
|
||||||
|
*/
|
||||||
|
export function isValidTileValueBySuit(suit: Suit, value: number): boolean {
|
||||||
|
if (!Number.isInteger(value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (suit) {
|
||||||
|
case 'W':
|
||||||
|
case 'T':
|
||||||
|
case 'B':
|
||||||
|
return value >= 1 && value <= 9
|
||||||
|
case 'F':
|
||||||
|
return value >= 1 && value <= 4
|
||||||
|
case 'D':
|
||||||
|
return value >= 1 && value <= 3
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为合法牌
|
||||||
|
*/
|
||||||
|
export function isValidTile(tile: {
|
||||||
|
suit: string
|
||||||
|
value: number
|
||||||
|
}): tile is Pick<Tile, 'suit' | 'value'> {
|
||||||
|
if (!isValidSuit(tile.suit)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return isValidTileValueBySuit(tile.suit, tile.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取上方手牌背面图
|
||||||
|
*/
|
||||||
|
export function getHandTileImage(position: TilePosition = 'top'): string {
|
||||||
|
const key = buildHandTileImageKey(position)
|
||||||
|
return tileImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取上方碰/杠/胡等明牌图片
|
||||||
|
*/
|
||||||
|
export function getExposedTileImage(
|
||||||
|
tile: Pick<Tile, 'suit' | 'value'>,
|
||||||
|
position: TilePosition = 'top',
|
||||||
|
): string {
|
||||||
|
if (!isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = buildExposedTileImageKey(tile.suit, tile.value, position)
|
||||||
|
return tileImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取上方盖牌图片
|
||||||
|
*/
|
||||||
|
export function getCoveredTileImage(position: TilePosition = 'top'): string {
|
||||||
|
const key = buildCoveredTileImageKey(position)
|
||||||
|
return tileImageModules[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一获取上方牌图片
|
||||||
|
*/
|
||||||
|
export function getTileImage(
|
||||||
|
tile?: Pick<Tile, 'suit' | 'value'>,
|
||||||
|
imageType: TileImageType = 'hand',
|
||||||
|
position: TilePosition = 'top',
|
||||||
|
): string {
|
||||||
|
if (imageType === 'hand') {
|
||||||
|
return getHandTileImage(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageType === 'covered') {
|
||||||
|
return getCoveredTileImage(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tile || !isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return getExposedTileImage(tile, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有基础牌(不含重复)
|
||||||
|
*/
|
||||||
|
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
|
||||||
|
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
|
||||||
|
|
||||||
|
const numberSuits: Array<'W' | 'T' | 'B'> = ['W', 'T', 'B']
|
||||||
|
for (const suit of numberSuits) {
|
||||||
|
for (let value = 1; value <= 9; value++) {
|
||||||
|
result.push({ suit, value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let value = 1; value <= 4; value++) {
|
||||||
|
result.push({ suit: 'F', value })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let value = 1; value <= 3; value++) {
|
||||||
|
result.push({ suit: 'D', value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取牌的中文名称
|
||||||
|
*/
|
||||||
|
export function getTileLabel(tile: Pick<Tile, 'suit' | 'value'>): string {
|
||||||
|
if (!isValidTile(tile)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (tile.suit) {
|
||||||
|
case 'W':
|
||||||
|
return `${tile.value}万`
|
||||||
|
case 'T':
|
||||||
|
return `${tile.value}筒`
|
||||||
|
case 'B':
|
||||||
|
return `${tile.value}条`
|
||||||
|
case 'F': {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
1: '东',
|
||||||
|
2: '南',
|
||||||
|
3: '西',
|
||||||
|
4: '北',
|
||||||
|
}
|
||||||
|
return map[tile.value] || ''
|
||||||
|
}
|
||||||
|
case 'D': {
|
||||||
|
const map: Record<number, string> = {
|
||||||
|
1: '中',
|
||||||
|
2: '发',
|
||||||
|
3: '白',
|
||||||
|
}
|
||||||
|
return map[tile.value] || ''
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/domain/tile/tile.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/game/actions.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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
|
||||||
|
is_ready?: boolean
|
||||||
|
MissingSuit?: string | null
|
||||||
|
missing_suit?: string | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomTrusteePayload {
|
||||||
|
player_id?: string
|
||||||
|
playerId?: string
|
||||||
|
trustee?: boolean
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscardActionPayload {
|
||||||
|
player_id?: string
|
||||||
|
playerId?: string
|
||||||
|
PlayerID?: string
|
||||||
|
tile?: Tile
|
||||||
|
next_seat?: number
|
||||||
|
nextSeat?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrawActionPayload {
|
||||||
|
player_id?: string
|
||||||
|
playerId?: string
|
||||||
|
PlayerID?: string
|
||||||
|
tile?: Tile
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerTurnPayload {
|
||||||
|
player_id?: string
|
||||||
|
playerId?: string
|
||||||
|
PlayerID?: string
|
||||||
|
timeout?: number
|
||||||
|
Timeout?: number
|
||||||
|
start_at?: number
|
||||||
|
startAt?: number
|
||||||
|
StartAt?: number
|
||||||
|
allow_actions?: string[]
|
||||||
|
allowActions?: string[]
|
||||||
|
AllowActions?: string[]
|
||||||
|
available_actions?: string[]
|
||||||
|
availableActions?: string[]
|
||||||
|
AvailableActions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏动作定义(只描述“发生了什么”)
|
||||||
|
*/
|
||||||
|
export type GameAction =
|
||||||
|
// 初始化整局(进入房间 / 断线重连)
|
||||||
|
| {
|
||||||
|
type: 'GAME_INIT'
|
||||||
|
payload: GameState
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始游戏(发牌完成)
|
||||||
|
| {
|
||||||
|
type: 'GAME_START'
|
||||||
|
payload: {
|
||||||
|
dealerIndex: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 摸牌
|
||||||
|
| {
|
||||||
|
type: 'DRAW_TILE'
|
||||||
|
payload: DrawActionPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
// 出牌
|
||||||
|
| {
|
||||||
|
type: 'PLAY_TILE'
|
||||||
|
payload: DiscardActionPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进入操作窗口(碰/杠/胡)
|
||||||
|
| {
|
||||||
|
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
|
||||||
|
}
|
||||||
283
src/game/chengdu/messageNormalizers.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import type {
|
||||||
|
ClaimOptionState,
|
||||||
|
MeldState,
|
||||||
|
PendingClaimState,
|
||||||
|
PlayerState,
|
||||||
|
Tile,
|
||||||
|
} from '../../types/state'
|
||||||
|
|
||||||
|
export function normalizeWsType(type: string): string {
|
||||||
|
return type.replace(/[-\s]/g, '_').toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readString(source: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key]
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readNumber(source: Record<string, unknown>, ...keys: string[]): number | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key]
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTimestampMs(value: number | null): number | null {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return value >= 1_000_000_000_000 ? value : value * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStringArray(source: Record<string, unknown>, ...keys: string[]): string[] {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key]
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((item): item is string => typeof item === 'string')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBoolean(source: Record<string, unknown>, ...keys: string[]): boolean | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = source[key]
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (value === 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (value === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
if (normalized === 'true' || normalized === '1') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (normalized === 'false' || normalized === '0') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readMissingSuit(source: Record<string, unknown> | null | undefined): string | null {
|
||||||
|
if (!source) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return readString(source, 'missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit') || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readMissingSuitWithPresence(
|
||||||
|
source: Record<string, unknown> | null | undefined,
|
||||||
|
): { present: boolean; value: string | null } {
|
||||||
|
if (!source) {
|
||||||
|
return { present: false, value: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = ['missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit']
|
||||||
|
const hasMissingSuitField = keys.some((key) => Object.prototype.hasOwnProperty.call(source, key))
|
||||||
|
if (!hasMissingSuitField) {
|
||||||
|
return { present: false, value: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { present: true, value: readMissingSuit(source) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tileToText(tile: Tile): string {
|
||||||
|
return `${tile.suit}${tile.value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPlayerTurnPlayerId(payload: Record<string, unknown>): string {
|
||||||
|
return (
|
||||||
|
(typeof payload.player_id === 'string' && payload.player_id) ||
|
||||||
|
(typeof payload.playerId === 'string' && payload.playerId) ||
|
||||||
|
(typeof payload.PlayerID === 'string' && payload.PlayerID) ||
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPlayerTurnAllowActions(payload: Record<string, unknown>): string[] {
|
||||||
|
const source =
|
||||||
|
payload.allow_actions ??
|
||||||
|
payload.allowActions ??
|
||||||
|
payload.AllowActions ??
|
||||||
|
payload.available_actions ??
|
||||||
|
payload.availableActions ??
|
||||||
|
payload.AvailableActions
|
||||||
|
if (!Array.isArray(source)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = source
|
||||||
|
.filter((item): item is string => typeof item === 'string')
|
||||||
|
.map((item) => item.trim().toLowerCase())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
return Array.from(new Set(actions))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTile(tile: unknown): Tile | null {
|
||||||
|
const source = asRecord(tile)
|
||||||
|
if (!source) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = readNumber(source, 'id')
|
||||||
|
const suit = readString(source, 'suit') as Tile['suit'] | ''
|
||||||
|
const value = readNumber(source, 'value')
|
||||||
|
if (typeof id !== 'number' || !suit || typeof value !== 'number') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suit !== 'W' && suit !== 'T' && suit !== 'B') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id, suit, value }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTiles(value: unknown): Tile[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.map((item) => normalizeTile(item)).filter((item): item is Tile => Boolean(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePendingClaim(
|
||||||
|
gameState: Record<string, unknown> | null | undefined,
|
||||||
|
loggedInUserId: string,
|
||||||
|
): PendingClaimState | undefined {
|
||||||
|
if (!gameState || !loggedInUserId) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingClaim = asRecord(gameState.pending_claim ?? gameState.pendingClaim)
|
||||||
|
if (!pendingClaim) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfOptions = asRecord(pendingClaim[loggedInUserId])
|
||||||
|
if (!selfOptions) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: ClaimOptionState[] = []
|
||||||
|
if (readBoolean(selfOptions, 'hu')) {
|
||||||
|
options.push('hu')
|
||||||
|
}
|
||||||
|
if (readBoolean(selfOptions, 'gang')) {
|
||||||
|
options.push('gang')
|
||||||
|
}
|
||||||
|
if (readBoolean(selfOptions, 'peng')) {
|
||||||
|
options.push('peng')
|
||||||
|
}
|
||||||
|
if (options.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push('pass')
|
||||||
|
|
||||||
|
return {
|
||||||
|
tile: normalizeTile(gameState.last_discard_tile ?? gameState.lastDiscardTile) ?? undefined,
|
||||||
|
fromPlayerId: readString(gameState, 'last_discard_by', 'lastDiscardBy') || undefined,
|
||||||
|
options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMeldType(value: unknown, concealed = false): MeldState['type'] | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return concealed ? 'an_gang' : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.replace(/[-\s]/g, '_').toLowerCase()
|
||||||
|
if (normalized === 'peng') {
|
||||||
|
return 'peng'
|
||||||
|
}
|
||||||
|
if (normalized === 'ming_gang' || normalized === 'gang' || normalized === 'gang_open') {
|
||||||
|
return concealed ? 'an_gang' : 'ming_gang'
|
||||||
|
}
|
||||||
|
if (normalized === 'an_gang' || normalized === 'angang' || normalized === 'concealed_gang') {
|
||||||
|
return 'an_gang'
|
||||||
|
}
|
||||||
|
|
||||||
|
return concealed ? 'an_gang' : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMelds(value: unknown): PlayerState['melds'] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => {
|
||||||
|
if (Array.isArray(item)) {
|
||||||
|
const tiles = normalizeTiles(item)
|
||||||
|
if (tiles.length === 3) {
|
||||||
|
return { type: 'peng', tiles, fromPlayerId: '' } satisfies MeldState
|
||||||
|
}
|
||||||
|
if (tiles.length === 4) {
|
||||||
|
return { type: 'ming_gang', tiles, fromPlayerId: '' } satisfies MeldState
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = asRecord(item)
|
||||||
|
if (!source) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiles = normalizeTiles(
|
||||||
|
source.tiles ??
|
||||||
|
source.meld_tiles ??
|
||||||
|
source.meldTiles ??
|
||||||
|
source.cards ??
|
||||||
|
source.card_list,
|
||||||
|
)
|
||||||
|
if (tiles.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const concealed =
|
||||||
|
readBoolean(source, 'concealed', 'is_concealed', 'isConcealed', 'hidden', 'is_hidden') ?? false
|
||||||
|
const explicitType = normalizeMeldType(
|
||||||
|
source.type ?? source.meld_type ?? source.meldType ?? source.kind,
|
||||||
|
concealed,
|
||||||
|
)
|
||||||
|
const type =
|
||||||
|
explicitType ??
|
||||||
|
(tiles.length === 4 ? (concealed ? 'an_gang' : 'ming_gang') : tiles.length === 3 ? 'peng' : null)
|
||||||
|
|
||||||
|
if (type === 'peng' || type === 'ming_gang') {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
tiles,
|
||||||
|
fromPlayerId: readString(source, 'from_player_id', 'fromPlayerId'),
|
||||||
|
} satisfies MeldState
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'an_gang') {
|
||||||
|
return { type, tiles } satisfies MeldState
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.filter((item): item is MeldState => Boolean(item))
|
||||||
|
}
|
||||||
49
src/game/dispatcher.ts
Normal 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
2
src/game/seat.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export type SeatKey = 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
|
||||||
5
src/game/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type ActionButtonState = {
|
||||||
|
type: 'discard' | 'peng' | 'gang' | 'hu' | 'pass'
|
||||||
|
label: string
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
16
src/main.ts
@@ -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')
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
324
src/store/gameStore.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
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; player_id?: string; PlayerID?: string; tile?: Tile }) {
|
||||||
|
const playerId =
|
||||||
|
(typeof data.playerId === 'string' && data.playerId) ||
|
||||||
|
(typeof data.player_id === 'string' && data.player_id) ||
|
||||||
|
(typeof data.PlayerID === 'string' && data.PlayerID) ||
|
||||||
|
''
|
||||||
|
const tile = data.tile
|
||||||
|
if (!playerId || !tile) return
|
||||||
|
|
||||||
|
const player = this.players[playerId]
|
||||||
|
if (!player) return
|
||||||
|
|
||||||
|
// 只更新自己的手牌
|
||||||
|
if (player.playerId === this.getMyPlayerId()) {
|
||||||
|
player.handTiles.push(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
|
||||||
|
player_id?: string
|
||||||
|
PlayerID?: string
|
||||||
|
tile?: Tile
|
||||||
|
nextSeat?: number
|
||||||
|
next_seat?: number
|
||||||
|
}) {
|
||||||
|
const playerId =
|
||||||
|
(typeof data.playerId === 'string' && data.playerId) ||
|
||||||
|
(typeof data.player_id === 'string' && data.player_id) ||
|
||||||
|
(typeof data.PlayerID === 'string' && data.PlayerID) ||
|
||||||
|
''
|
||||||
|
const tile = data.tile
|
||||||
|
if (!playerId || !tile) return
|
||||||
|
|
||||||
|
const player = this.players[playerId]
|
||||||
|
if (!player) return
|
||||||
|
|
||||||
|
// 如果是自己,移除手牌
|
||||||
|
if (player.playerId === this.getMyPlayerId()) {
|
||||||
|
const index = player.handTiles.findIndex(
|
||||||
|
(t) => t.id === tile.id
|
||||||
|
)
|
||||||
|
if (index !== -1) {
|
||||||
|
player.handTiles.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.handCount = Math.max(0, player.handCount - 1)
|
||||||
|
|
||||||
|
// 加入出牌<E587BA>?
|
||||||
|
player.discardTiles.push(tile)
|
||||||
|
|
||||||
|
// 更新回合
|
||||||
|
const nextSeat =
|
||||||
|
typeof data.nextSeat === 'number'
|
||||||
|
? data.nextSeat
|
||||||
|
: typeof data.next_seat === 'number'
|
||||||
|
? data.next_seat
|
||||||
|
: this.currentTurn
|
||||||
|
this.currentTurn = nextSeat
|
||||||
|
this.needDraw = true
|
||||||
|
|
||||||
|
// 等待其他玩家响应
|
||||||
|
this.phase = GAME_PHASE.ACTION
|
||||||
|
},
|
||||||
|
|
||||||
|
// 触发操作窗口(碰/<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 ?? raw.is_ready
|
||||||
|
const ready = parseBooleanish(readyRaw)
|
||||||
|
const displayNameRaw = raw.PlayerName ?? raw.player_name
|
||||||
|
const avatarUrlRaw = raw.AvatarUrl ?? raw.avatar_url
|
||||||
|
const missingSuitRaw = raw.MissingSuit ?? raw.missing_suit
|
||||||
|
|
||||||
|
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 ''
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
48
src/store/index.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type {
|
||||||
|
RoomMetaSnapshotInput,
|
||||||
|
RoomMetaSnapshotState,
|
||||||
|
} from './state'
|
||||||
|
import { clearRoomMetaSnapshot, readRoomMetaSnapshot, saveRoomMetaSnapshot } from './storage'
|
||||||
|
|
||||||
|
const roomMetaSnapshot = ref<RoomMetaSnapshotState | null>(readRoomMetaSnapshot())
|
||||||
|
|
||||||
|
function normalizeRoom(input: RoomMetaSnapshotInput): RoomMetaSnapshotState {
|
||||||
|
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 setRoomMetaSnapshot(input: RoomMetaSnapshotInput) {
|
||||||
|
const next = normalizeRoom(input)
|
||||||
|
roomMetaSnapshot.value = next
|
||||||
|
saveRoomMetaSnapshot(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRoomMetaSnapshotState() {
|
||||||
|
roomMetaSnapshot.value = null
|
||||||
|
clearRoomMetaSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoomMetaSnapshotState() {
|
||||||
|
return roomMetaSnapshot
|
||||||
|
}
|
||||||
52
src/store/state.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// 房间玩家状态
|
||||||
|
export interface RoomMetaPlayerState {
|
||||||
|
index: number
|
||||||
|
playerId: string
|
||||||
|
displayName?: string
|
||||||
|
missingSuit?: string | null
|
||||||
|
ready: boolean
|
||||||
|
trustee?: boolean
|
||||||
|
hand: string[]
|
||||||
|
melds: string[]
|
||||||
|
outTiles: string[]
|
||||||
|
hasHu: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 房间整体状态
|
||||||
|
export interface RoomMetaSnapshotState {
|
||||||
|
roomId: string
|
||||||
|
roomName: string
|
||||||
|
gameType: string
|
||||||
|
ownerId: string
|
||||||
|
maxPlayers: number
|
||||||
|
playerCount: number
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
players: RoomMetaPlayerState[]
|
||||||
|
myHand: string[]
|
||||||
|
game?: {
|
||||||
|
state?: {
|
||||||
|
wall?: string[]
|
||||||
|
scores?: Record<string, number>
|
||||||
|
dealerIndex?: number
|
||||||
|
currentTurn?: number
|
||||||
|
phase?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomMetaSnapshotInput {
|
||||||
|
roomId: string
|
||||||
|
roomName?: string
|
||||||
|
gameType?: string
|
||||||
|
ownerId?: string
|
||||||
|
maxPlayers?: number
|
||||||
|
playerCount?: number
|
||||||
|
status?: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
players?: RoomMetaPlayerState[]
|
||||||
|
myHand?: string[]
|
||||||
|
game?: RoomMetaSnapshotState['game']
|
||||||
|
}
|
||||||
25
src/store/storage.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { RoomMetaSnapshotState } from './state'
|
||||||
|
|
||||||
|
const KEY = 'mahjong_active_room'
|
||||||
|
|
||||||
|
// 读取缓存
|
||||||
|
export function readRoomMetaSnapshot(): RoomMetaSnapshotState | null {
|
||||||
|
const raw = localStorage.getItem(KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入缓存
|
||||||
|
export function saveRoomMetaSnapshot(state: RoomMetaSnapshotState) {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除缓存
|
||||||
|
export function clearRoomMetaSnapshot() {
|
||||||
|
localStorage.removeItem(KEY)
|
||||||
|
}
|
||||||
9
src/types/state/claimOptionState.ts
Normal 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]
|
||||||
21
src/types/state/gamePhaseState.ts
Normal 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: '结算',
|
||||||
|
}
|
||||||
42
src/types/state/gameState.ts
Normal 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
|
||||||
|
}
|
||||||
12
src/types/state/huWayState.ts
Normal 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
@@ -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'
|
||||||
17
src/types/state/meldState.ts
Normal 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[]
|
||||||
|
}
|
||||||
14
src/types/state/pendingClaimState.ts
Normal 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[]
|
||||||
|
}
|
||||||
28
src/types/state/playerState.ts
Normal 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
@@ -0,0 +1,9 @@
|
|||||||
|
export type Suit = 'W' | 'T' | 'B'
|
||||||
|
|
||||||
|
export interface Tile {
|
||||||
|
id: number
|
||||||
|
suit: Suit
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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 { setRoomMetaSnapshot } 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({
|
setRoomMetaSnapshot({
|
||||||
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({
|
setRoomMetaSnapshot({
|
||||||
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({
|
setRoomMetaSnapshot({
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
433
src/views/chengdu/composables/useChengduGameActions.ts
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
import {computed} from 'vue'
|
||||||
|
import {sendWsMessage} from '../../../ws/sender'
|
||||||
|
import {formatTile} from './useChengduTableView'
|
||||||
|
import type {ClaimOptionState} from '../../../types/state'
|
||||||
|
import type {Tile} from '../../../types/tile'
|
||||||
|
import type {DisplayPlayer} from '../types'
|
||||||
|
|
||||||
|
interface UseChengduGameActionsOptions {
|
||||||
|
gameStore: {
|
||||||
|
roomId: string
|
||||||
|
phase: string
|
||||||
|
currentTurn: number
|
||||||
|
needDraw: boolean
|
||||||
|
currentRound: number
|
||||||
|
totalRounds: number
|
||||||
|
pendingClaim?: {
|
||||||
|
tile?: Tile
|
||||||
|
options: ClaimOptionState[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roomMeta: { value: { roomId: string; ownerId: string } | null }
|
||||||
|
gamePlayers: { value: DisplayPlayer[] }
|
||||||
|
myPlayer: { value: DisplayPlayer | undefined }
|
||||||
|
session: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChengduGameActions(options: UseChengduGameActionsOptions) {
|
||||||
|
const isLastRound = computed(
|
||||||
|
() => options.gameStore.currentRound >= options.gameStore.totalRounds && options.gameStore.totalRounds > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const myReadyState = computed(() => Boolean(options.myPlayer.value?.isReady))
|
||||||
|
|
||||||
|
const isRoomOwner = computed(() => {
|
||||||
|
const room = options.roomMeta.value
|
||||||
|
return Boolean(
|
||||||
|
room &&
|
||||||
|
room.roomId === options.gameStore.roomId &&
|
||||||
|
room.ownerId &&
|
||||||
|
options.session.loggedInUserId.value &&
|
||||||
|
room.ownerId === options.session.loggedInUserId.value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const allPlayersReady = computed(
|
||||||
|
() => options.gamePlayers.value.length === 4 && options.gamePlayers.value.every((player) => Boolean(player.isReady)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasRoundStarted = computed(() =>
|
||||||
|
options.gamePlayers.value.some(
|
||||||
|
(player) =>
|
||||||
|
player.handCount > 0 ||
|
||||||
|
player.handTiles.length > 0 ||
|
||||||
|
player.melds.length > 0 ||
|
||||||
|
player.discardTiles.length > 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const showStartGameButton = computed(
|
||||||
|
() => options.gameStore.phase === 'waiting' && allPlayersReady.value && !hasRoundStarted.value,
|
||||||
|
)
|
||||||
|
const showWaitingOwnerTip = computed(() => showStartGameButton.value && !isRoomOwner.value)
|
||||||
|
const canStartGame = computed(
|
||||||
|
() => showStartGameButton.value && isRoomOwner.value && !options.session.startGamePending.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const showReadyToggle = computed(() => {
|
||||||
|
if (options.gameStore.phase !== 'waiting' || !options.gameStore.roomId || hasRoundStarted.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (showStartGameButton.value) {
|
||||||
|
return !isRoomOwner.value
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const showDingQueChooser = computed(() => {
|
||||||
|
const player = options.myPlayer.value
|
||||||
|
if (!player || options.gameStore.phase === 'settlement') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return player.handTiles.length > 0 && !player.missingSuit
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedDiscardTile = computed(() => {
|
||||||
|
const player = options.myPlayer.value
|
||||||
|
if (!player || options.session.selectedDiscardTileId.value === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return player.handTiles.find((tile) => tile.id === options.session.selectedDiscardTileId.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasMissingSuitTiles = computed(() => {
|
||||||
|
const player = options.myPlayer.value
|
||||||
|
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
|
||||||
|
if (!player || !missingSuit) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return player.handTiles.some((tile) => tile.suit === missingSuit)
|
||||||
|
})
|
||||||
|
|
||||||
|
function missingSuitLabel(value: string | null | undefined): string {
|
||||||
|
const suitMap: Record<string, string> = {
|
||||||
|
w: '万',
|
||||||
|
t: '筒',
|
||||||
|
b: '条',
|
||||||
|
wan: '万',
|
||||||
|
tong: '筒',
|
||||||
|
tiao: '条',
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return suitMap[value.trim().toLowerCase()] ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
const discardBlockedReason = computed(() => {
|
||||||
|
const player = options.myPlayer.value
|
||||||
|
if (!player || !options.gameStore.roomId) {
|
||||||
|
return '未进入房间'
|
||||||
|
}
|
||||||
|
if (options.session.wsStatus.value !== 'connected') {
|
||||||
|
return 'WebSocket 未连接'
|
||||||
|
}
|
||||||
|
if (showDingQueChooser.value) {
|
||||||
|
return '请先完成定缺'
|
||||||
|
}
|
||||||
|
if (options.gameStore.phase !== 'playing') {
|
||||||
|
return '当前不是出牌阶段'
|
||||||
|
}
|
||||||
|
if (player.seatIndex !== options.gameStore.currentTurn) {
|
||||||
|
return '未轮到你出牌'
|
||||||
|
}
|
||||||
|
if (options.gameStore.needDraw) {
|
||||||
|
return '请先摸牌'
|
||||||
|
}
|
||||||
|
if (options.gameStore.pendingClaim) {
|
||||||
|
return '等待当前操作结算'
|
||||||
|
}
|
||||||
|
if (player.handTiles.length === 0) {
|
||||||
|
return '当前没有可出的手牌'
|
||||||
|
}
|
||||||
|
if (options.session.discardPending.value) {
|
||||||
|
return '正在提交出牌'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function discardTileBlockedReason(tile: Tile): string {
|
||||||
|
if (discardBlockedReason.value) {
|
||||||
|
return discardBlockedReason.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = options.myPlayer.value
|
||||||
|
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
|
||||||
|
if (player && missingSuit && hasMissingSuitTiles.value && tile.suit !== missingSuit) {
|
||||||
|
return `当前必须先打${missingSuitLabel(missingSuit)}牌`
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const canConfirmDiscard = computed(() => {
|
||||||
|
const tile = selectedDiscardTile.value
|
||||||
|
return Boolean(tile && !discardTileBlockedReason(tile))
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirmDiscardLabel = computed(() => {
|
||||||
|
const tile = selectedDiscardTile.value
|
||||||
|
return tile ? `出牌 ${formatTile(tile)}` : '出牌'
|
||||||
|
})
|
||||||
|
|
||||||
|
const canDrawTile = computed(() => {
|
||||||
|
const player = options.myPlayer.value
|
||||||
|
if (!player || !options.gameStore.roomId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
options.gameStore.phase === 'playing' &&
|
||||||
|
options.gameStore.needDraw &&
|
||||||
|
player.seatIndex === options.gameStore.currentTurn
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const myClaimState = computed(() => options.gameStore.pendingClaim)
|
||||||
|
const visibleClaimOptions = computed<ClaimOptionState[]>(() => {
|
||||||
|
const current = myClaimState.value?.options ?? []
|
||||||
|
const order: ClaimOptionState[] = ['hu', 'gang', 'peng', 'pass']
|
||||||
|
return order.filter((option) => current.includes(option))
|
||||||
|
})
|
||||||
|
const showClaimActions = computed(() => visibleClaimOptions.value.length > 0)
|
||||||
|
|
||||||
|
const canSelfHu = computed(() => {
|
||||||
|
const player = options.myPlayer.value
|
||||||
|
if (!player || !options.gameStore.roomId || options.session.wsStatus.value !== 'connected') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
showDingQueChooser.value ||
|
||||||
|
options.gameStore.phase !== 'playing' ||
|
||||||
|
options.gameStore.needDraw ||
|
||||||
|
options.gameStore.pendingClaim
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (player.seatIndex !== options.gameStore.currentTurn || options.session.turnActionPending.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return options.session.selfTurnAllowActions.value.includes('hu')
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSelfGang = computed(() => {
|
||||||
|
const player = options.myPlayer.value
|
||||||
|
if (!player || !options.gameStore.roomId || options.session.wsStatus.value !== 'connected') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
showDingQueChooser.value ||
|
||||||
|
options.gameStore.phase !== 'playing' ||
|
||||||
|
options.gameStore.needDraw ||
|
||||||
|
options.gameStore.pendingClaim
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (player.seatIndex !== options.gameStore.currentTurn || options.session.turnActionPending.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return options.session.selfTurnAllowActions.value.includes('gang')
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleReadyState(): void {
|
||||||
|
if (options.session.readyTogglePending.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextReady = !myReadyState.value
|
||||||
|
options.session.readyTogglePending.value = true
|
||||||
|
sendWsMessage({
|
||||||
|
type: 'set_ready',
|
||||||
|
roomId: options.gameStore.roomId,
|
||||||
|
payload: {
|
||||||
|
ready: nextReady,
|
||||||
|
isReady: nextReady,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGame(): void {
|
||||||
|
if (!canStartGame.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.session.startGamePending.value = true
|
||||||
|
sendWsMessage({
|
||||||
|
type: 'start_game',
|
||||||
|
roomId: options.gameStore.roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: options.gameStore.roomId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextRound(): void {
|
||||||
|
if (
|
||||||
|
options.session.nextRoundPending.value ||
|
||||||
|
!options.gameStore.roomId ||
|
||||||
|
options.gameStore.phase !== 'settlement'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.session.settlementOverlayDismissed.value = true
|
||||||
|
options.session.nextRoundPending.value = true
|
||||||
|
sendWsMessage({
|
||||||
|
type: 'next_round',
|
||||||
|
roomId: options.gameStore.roomId,
|
||||||
|
payload: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseDingQue(suit: Tile['suit']): void {
|
||||||
|
if (options.session.dingQuePending.value || !showDingQueChooser.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.session.dingQuePending.value = true
|
||||||
|
sendWsMessage({
|
||||||
|
type: 'ding_que',
|
||||||
|
roomId: options.gameStore.roomId,
|
||||||
|
payload: {suit},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDiscardTile(tile: Tile): void {
|
||||||
|
const blockedReason = discardTileBlockedReason(tile)
|
||||||
|
if (blockedReason) {
|
||||||
|
options.session.wsError.value = blockedReason
|
||||||
|
options.session.wsMessages.value.push(`[client-blocked] select ${formatTile(tile)}: ${blockedReason}`)
|
||||||
|
options.session.selectedDiscardTileId.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.session.wsError.value = ''
|
||||||
|
options.session.selectedDiscardTileId.value =
|
||||||
|
options.session.selectedDiscardTileId.value === tile.id ? null : tile.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDiscard(): void {
|
||||||
|
const tile = selectedDiscardTile.value
|
||||||
|
if (!tile) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedReason = discardTileBlockedReason(tile)
|
||||||
|
if (blockedReason || !options.gameStore.roomId) {
|
||||||
|
if (blockedReason) {
|
||||||
|
options.session.wsError.value = blockedReason
|
||||||
|
options.session.wsMessages.value.push(`[client-blocked] discard ${formatTile(tile)}: ${blockedReason}`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.session.wsError.value = ''
|
||||||
|
options.session.markDiscardPendingWithFallback()
|
||||||
|
sendWsMessage({
|
||||||
|
type: 'discard',
|
||||||
|
roomId: options.gameStore.roomId,
|
||||||
|
payload: {
|
||||||
|
tile: {
|
||||||
|
id: tile.id,
|
||||||
|
suit: tile.suit,
|
||||||
|
value: tile.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTile(): void {
|
||||||
|
if (!canDrawTile.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendWsMessage({
|
||||||
|
type: 'draw',
|
||||||
|
roomId: options.gameStore.roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: options.gameStore.roomId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitSelfGang(): void {
|
||||||
|
if (!options.gameStore.roomId || !canSelfGang.value || options.session.turnActionPending.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.session.markTurnActionPending('gang')
|
||||||
|
sendWsMessage({
|
||||||
|
type: 'gang',
|
||||||
|
roomId: options.gameStore.roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: options.gameStore.roomId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitSelfHu(): void {
|
||||||
|
if (!options.gameStore.roomId || !canSelfHu.value || options.session.turnActionPending.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.session.markTurnActionPending('hu')
|
||||||
|
sendWsMessage({
|
||||||
|
type: 'hu',
|
||||||
|
roomId: options.gameStore.roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: options.gameStore.roomId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitClaim(action: ClaimOptionState): void {
|
||||||
|
if (
|
||||||
|
options.session.claimActionPending.value ||
|
||||||
|
!options.gameStore.roomId ||
|
||||||
|
!visibleClaimOptions.value.includes(action)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const claimTile = options.gameStore.pendingClaim?.tile
|
||||||
|
options.session.claimActionPending.value = true
|
||||||
|
sendWsMessage({
|
||||||
|
type: action,
|
||||||
|
roomId: options.gameStore.roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: options.gameStore.roomId,
|
||||||
|
...(action !== 'pass' && claimTile
|
||||||
|
? {
|
||||||
|
tile: {
|
||||||
|
id: claimTile.id,
|
||||||
|
suit: claimTile.suit,
|
||||||
|
value: claimTile.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLastRound,
|
||||||
|
myReadyState,
|
||||||
|
isRoomOwner,
|
||||||
|
showStartGameButton,
|
||||||
|
showWaitingOwnerTip,
|
||||||
|
canStartGame,
|
||||||
|
showReadyToggle,
|
||||||
|
showDingQueChooser,
|
||||||
|
selectedDiscardTile,
|
||||||
|
discardBlockedReason,
|
||||||
|
discardTileBlockedReason,
|
||||||
|
canConfirmDiscard,
|
||||||
|
confirmDiscardLabel,
|
||||||
|
canDrawTile,
|
||||||
|
visibleClaimOptions,
|
||||||
|
showClaimActions,
|
||||||
|
canSelfHu,
|
||||||
|
canSelfGang,
|
||||||
|
toggleReadyState,
|
||||||
|
startGame,
|
||||||
|
nextRound,
|
||||||
|
chooseDingQue,
|
||||||
|
selectDiscardTile,
|
||||||
|
confirmDiscard,
|
||||||
|
drawTile,
|
||||||
|
submitSelfGang,
|
||||||
|
submitSelfHu,
|
||||||
|
submitClaim,
|
||||||
|
}
|
||||||
|
}
|
||||||
459
src/views/chengdu/composables/useChengduGameSession.ts
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||||
|
import { refreshAccessToken } from '../../../api/auth'
|
||||||
|
import { AuthExpiredError, type AuthSession } from '../../../api/authed-request'
|
||||||
|
import { getUserInfo } from '../../../api/user'
|
||||||
|
import type { RoomMetaSnapshotState } from '../../../store/state'
|
||||||
|
import { clearAuth, readStoredAuth, writeStoredAuth } from '../../../utils/auth-storage'
|
||||||
|
import type { WsStatus } from '../../../ws/client'
|
||||||
|
import { wsClient } from '../../../ws/client'
|
||||||
|
import { sendWsMessage } from '../../../ws/sender'
|
||||||
|
import { buildWsUrl } from '../../../ws/url'
|
||||||
|
import type { PlayerActionTimer } from '../types'
|
||||||
|
|
||||||
|
interface UseChengduGameSessionOptions {
|
||||||
|
route: RouteLocationNormalizedLoaded
|
||||||
|
router: Router
|
||||||
|
gameStore: {
|
||||||
|
roomId: string
|
||||||
|
}
|
||||||
|
roomMeta: { value: RoomMetaSnapshotState | null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChengduGameSession(options: UseChengduGameSessionOptions) {
|
||||||
|
const auth = ref(readStoredAuth())
|
||||||
|
const now = ref(Date.now())
|
||||||
|
const wsStatus = ref<WsStatus>('idle')
|
||||||
|
const wsMessages = ref<string[]>([])
|
||||||
|
const wsError = ref('')
|
||||||
|
const roomCountdown = ref<PlayerActionTimer | null>(null)
|
||||||
|
|
||||||
|
const leaveRoomPending = ref(false)
|
||||||
|
const readyTogglePending = ref(false)
|
||||||
|
const startGamePending = ref(false)
|
||||||
|
const dingQuePending = ref(false)
|
||||||
|
const discardPending = ref(false)
|
||||||
|
const claimActionPending = ref(false)
|
||||||
|
const turnActionPending = ref(false)
|
||||||
|
const nextRoundPending = ref(false)
|
||||||
|
const settlementOverlayDismissed = ref(false)
|
||||||
|
const settlementDeadlineMs = ref<number | null>(null)
|
||||||
|
const selectedDiscardTileId = ref<number | null>(null)
|
||||||
|
const selfTurnAllowActions = ref<string[]>([])
|
||||||
|
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
const isTrustMode = ref(false)
|
||||||
|
const menuTriggerActive = ref(false)
|
||||||
|
|
||||||
|
let clockTimer: number | null = null
|
||||||
|
let discardPendingTimer: number | null = null
|
||||||
|
let turnActionPendingTimer: number | null = null
|
||||||
|
let menuTriggerTimer: number | null = null
|
||||||
|
let menuOpenTimer: number | null = null
|
||||||
|
let refreshingWsToken = false
|
||||||
|
let lastForcedRefreshAt = 0
|
||||||
|
|
||||||
|
const loggedInUserId = computed(() => {
|
||||||
|
const source = auth.value?.user as Record<string, unknown> | undefined
|
||||||
|
const rawId = source?.id ?? source?.userID ?? source?.user_id
|
||||||
|
|
||||||
|
if (typeof rawId === 'string' && rawId.trim()) {
|
||||||
|
return rawId
|
||||||
|
}
|
||||||
|
if (typeof rawId === 'number') {
|
||||||
|
return String(rawId)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const loggedInUserName = computed(() => auth.value?.user?.nickname || auth.value?.user?.username || '')
|
||||||
|
|
||||||
|
const localCachedAvatarUrl = computed(() => {
|
||||||
|
const source = auth.value?.user as Record<string, unknown> | undefined
|
||||||
|
if (!source) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarCandidates = [
|
||||||
|
source.avatar,
|
||||||
|
source.avatar_url,
|
||||||
|
source.avatarUrl,
|
||||||
|
source.head_img,
|
||||||
|
source.headImg,
|
||||||
|
source.profile_image,
|
||||||
|
source.profileImage,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of avatarCandidates) {
|
||||||
|
if (typeof candidate === 'string' && candidate.trim()) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const networkLabel = computed(() => {
|
||||||
|
const map: Record<WsStatus, string> = {
|
||||||
|
connected: '已连接',
|
||||||
|
connecting: '连接中',
|
||||||
|
error: '连接异常',
|
||||||
|
idle: '未连接',
|
||||||
|
closed: '未连接',
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[wsStatus.value] ?? '未连接'
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedClock = computed(() =>
|
||||||
|
new Date(now.value).toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleMenu(): void {
|
||||||
|
menuTriggerActive.value = true
|
||||||
|
if (menuTriggerTimer !== null) {
|
||||||
|
window.clearTimeout(menuTriggerTimer)
|
||||||
|
}
|
||||||
|
menuTriggerTimer = window.setTimeout(() => {
|
||||||
|
menuTriggerActive.value = false
|
||||||
|
menuTriggerTimer = null
|
||||||
|
}, 180)
|
||||||
|
|
||||||
|
if (menuOpen.value) {
|
||||||
|
menuOpen.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuOpenTimer !== null) {
|
||||||
|
window.clearTimeout(menuOpenTimer)
|
||||||
|
}
|
||||||
|
menuOpenTimer = window.setTimeout(() => {
|
||||||
|
menuOpen.value = true
|
||||||
|
menuOpenTimer = null
|
||||||
|
}, 85)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTrustMode(): void {
|
||||||
|
isTrustMode.value = !isTrustMode.value
|
||||||
|
menuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTurnActionPending(): void {
|
||||||
|
turnActionPending.value = false
|
||||||
|
if (turnActionPendingTimer !== null) {
|
||||||
|
window.clearTimeout(turnActionPendingTimer)
|
||||||
|
turnActionPendingTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markTurnActionPending(kind: 'gang' | 'hu'): void {
|
||||||
|
clearTurnActionPending()
|
||||||
|
turnActionPending.value = true
|
||||||
|
turnActionPendingTimer = window.setTimeout(() => {
|
||||||
|
turnActionPending.value = false
|
||||||
|
turnActionPendingTimer = null
|
||||||
|
wsError.value = `${kind === 'gang' ? '杠牌' : '胡牌'}未收到服务器确认`
|
||||||
|
}, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDiscardPendingTimer(): void {
|
||||||
|
if (discardPendingTimer !== null) {
|
||||||
|
window.clearTimeout(discardPendingTimer)
|
||||||
|
discardPendingTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDiscardCompleted(): void {
|
||||||
|
clearDiscardPendingTimer()
|
||||||
|
discardPending.value = false
|
||||||
|
selectedDiscardTileId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDiscardPendingWithFallback(): void {
|
||||||
|
clearDiscardPendingTimer()
|
||||||
|
discardPending.value = true
|
||||||
|
discardPendingTimer = window.setTimeout(() => {
|
||||||
|
discardPending.value = false
|
||||||
|
selectedDiscardTileId.value = null
|
||||||
|
discardPendingTimer = null
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoutToLogin(): void {
|
||||||
|
clearAuth()
|
||||||
|
auth.value = null
|
||||||
|
wsClient.close()
|
||||||
|
void options.router.replace('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSession(): AuthSession | null {
|
||||||
|
const current = auth.value
|
||||||
|
if (!current?.token) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: current.token,
|
||||||
|
tokenType: current.tokenType,
|
||||||
|
refreshToken: current.refreshToken,
|
||||||
|
expiresIn: current.expiresIn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAuthSession(next: AuthSession): void {
|
||||||
|
if (!auth.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.value = {
|
||||||
|
...auth.value,
|
||||||
|
token: next.token,
|
||||||
|
tokenType: next.tokenType ?? auth.value.tokenType,
|
||||||
|
refreshToken: next.refreshToken ?? auth.value.refreshToken,
|
||||||
|
expiresIn: next.expiresIn,
|
||||||
|
}
|
||||||
|
writeStoredAuth(auth.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCurrentUserID(userID: string): void {
|
||||||
|
if (!userID || loggedInUserId.value || !auth.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.value = {
|
||||||
|
...auth.value,
|
||||||
|
user: {
|
||||||
|
...(auth.value.user ?? {}),
|
||||||
|
id: userID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
writeStoredAuth(auth.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCurrentUserLoaded(): Promise<void> {
|
||||||
|
if (loggedInUserId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAuth = auth.value
|
||||||
|
const session = currentSession()
|
||||||
|
if (!session) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userInfo = await getUserInfo(session, syncAuthSession)
|
||||||
|
const resolvedId = userInfo.userID ?? userInfo.user_id ?? userInfo.id ?? currentAuth?.user?.id
|
||||||
|
const nextUser = {
|
||||||
|
...(currentAuth?.user ?? {}),
|
||||||
|
...userInfo,
|
||||||
|
id: typeof resolvedId === 'string' || typeof resolvedId === 'number' ? resolvedId : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentAuth) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.value = {
|
||||||
|
...currentAuth,
|
||||||
|
user: nextUser,
|
||||||
|
}
|
||||||
|
writeStoredAuth(auth.value)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthExpiredError) {
|
||||||
|
logoutToLogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJwtExpMs(token: string): number | null {
|
||||||
|
const payloadPart = token.split('.')[1]
|
||||||
|
if (!payloadPart) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalized = payloadPart.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||||
|
const payload = JSON.parse(window.atob(padded)) as { exp?: number }
|
||||||
|
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRefreshWsToken(token: string): boolean {
|
||||||
|
const expMs = decodeJwtExpMs(token)
|
||||||
|
return Boolean(expMs && expMs <= Date.now() + 30_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWsToken(forceRefresh = false, logoutOnRefreshFail = false): Promise<string | null> {
|
||||||
|
const current = auth.value
|
||||||
|
if (!current?.token) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh && !shouldRefreshWsToken(current.token)) {
|
||||||
|
return current.token
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current.refreshToken || refreshingWsToken) {
|
||||||
|
return current.token
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshingWsToken = true
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshAccessToken({
|
||||||
|
token: current.token,
|
||||||
|
tokenType: current.tokenType,
|
||||||
|
refreshToken: current.refreshToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextAuth = {
|
||||||
|
...current,
|
||||||
|
token: refreshed.token,
|
||||||
|
tokenType: refreshed.tokenType ?? current.tokenType,
|
||||||
|
refreshToken: refreshed.refreshToken ?? current.refreshToken,
|
||||||
|
expiresIn: refreshed.expiresIn,
|
||||||
|
}
|
||||||
|
auth.value = nextAuth
|
||||||
|
writeStoredAuth(nextAuth)
|
||||||
|
return nextAuth.token
|
||||||
|
} catch {
|
||||||
|
if (logoutOnRefreshFail) {
|
||||||
|
logoutToLogin()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
refreshingWsToken = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWsConnected(forceRefresh = false): Promise<void> {
|
||||||
|
const token = await resolveWsToken(forceRefresh, false)
|
||||||
|
if (!token) {
|
||||||
|
wsError.value = '未找到登录凭证,无法建立连接'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wsError.value = ''
|
||||||
|
wsClient.connect(buildWsUrl(), token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function backHall(): void {
|
||||||
|
leaveRoomPending.value = true
|
||||||
|
const roomId = options.gameStore.roomId
|
||||||
|
sendWsMessage({
|
||||||
|
type: 'leave_room',
|
||||||
|
roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: roomId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
wsClient.close()
|
||||||
|
void options.router.push('/hall').finally(() => {
|
||||||
|
leaveRoomPending.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalClick(event: MouseEvent): void {
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
if (!target || target.closest('.menu-trigger-wrap')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
menuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalEsc(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
menuOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
clockTimer = window.setInterval(() => {
|
||||||
|
now.value = Date.now()
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
window.addEventListener('click', handleGlobalClick)
|
||||||
|
window.addEventListener('keydown', handleGlobalEsc)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (clockTimer !== null) {
|
||||||
|
window.clearInterval(clockTimer)
|
||||||
|
clockTimer = null
|
||||||
|
}
|
||||||
|
clearDiscardPendingTimer()
|
||||||
|
clearTurnActionPending()
|
||||||
|
|
||||||
|
window.removeEventListener('click', handleGlobalClick)
|
||||||
|
window.removeEventListener('keydown', handleGlobalEsc)
|
||||||
|
|
||||||
|
if (menuTriggerTimer !== null) {
|
||||||
|
window.clearTimeout(menuTriggerTimer)
|
||||||
|
menuTriggerTimer = null
|
||||||
|
}
|
||||||
|
if (menuOpenTimer !== null) {
|
||||||
|
window.clearTimeout(menuOpenTimer)
|
||||||
|
menuOpenTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
auth,
|
||||||
|
now,
|
||||||
|
wsStatus,
|
||||||
|
wsMessages,
|
||||||
|
wsError,
|
||||||
|
roomCountdown,
|
||||||
|
leaveRoomPending,
|
||||||
|
readyTogglePending,
|
||||||
|
startGamePending,
|
||||||
|
dingQuePending,
|
||||||
|
discardPending,
|
||||||
|
claimActionPending,
|
||||||
|
turnActionPending,
|
||||||
|
nextRoundPending,
|
||||||
|
settlementOverlayDismissed,
|
||||||
|
settlementDeadlineMs,
|
||||||
|
selectedDiscardTileId,
|
||||||
|
selfTurnAllowActions,
|
||||||
|
menuOpen,
|
||||||
|
isTrustMode,
|
||||||
|
menuTriggerActive,
|
||||||
|
loggedInUserId,
|
||||||
|
loggedInUserName,
|
||||||
|
localCachedAvatarUrl,
|
||||||
|
networkLabel,
|
||||||
|
formattedClock,
|
||||||
|
toggleMenu,
|
||||||
|
toggleTrustMode,
|
||||||
|
clearTurnActionPending,
|
||||||
|
markTurnActionPending,
|
||||||
|
clearDiscardPendingTimer,
|
||||||
|
markDiscardCompleted,
|
||||||
|
markDiscardPendingWithFallback,
|
||||||
|
logoutToLogin,
|
||||||
|
currentSession,
|
||||||
|
syncAuthSession,
|
||||||
|
syncCurrentUserID,
|
||||||
|
ensureCurrentUserLoaded,
|
||||||
|
resolveWsToken,
|
||||||
|
ensureWsConnected,
|
||||||
|
backHall,
|
||||||
|
lastForcedRefreshAtRef: {
|
||||||
|
get value() {
|
||||||
|
return lastForcedRefreshAt
|
||||||
|
},
|
||||||
|
set value(next: number) {
|
||||||
|
lastForcedRefreshAt = next
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/views/chengdu/composables/useChengduGameSocket.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, type ComputedRef } from 'vue'
|
||||||
|
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||||
|
import type { RoomMetaSnapshotState } from '../../../store/state'
|
||||||
|
import type { PlayerState, Tile } from '../../../types/state'
|
||||||
|
import { wsClient, type WsStatus } from '../../../ws/client'
|
||||||
|
import { buildWsUrl } from '../../../ws/url'
|
||||||
|
import type { DisplayPlayer } from '../types'
|
||||||
|
import { useChengduGameSession } from './useChengduGameSession'
|
||||||
|
import { createChengduMessageHandlers } from '../socket/createMessageHandlers'
|
||||||
|
|
||||||
|
interface UseChengduGameSocketOptions {
|
||||||
|
route: RouteLocationNormalizedLoaded
|
||||||
|
router: Router
|
||||||
|
gameStore: {
|
||||||
|
roomId: string
|
||||||
|
phase: string
|
||||||
|
players: Record<string, PlayerState>
|
||||||
|
dealerIndex: number
|
||||||
|
currentTurn: number
|
||||||
|
remainingTiles: number
|
||||||
|
needDraw: boolean
|
||||||
|
pendingClaim?: any
|
||||||
|
scores: Record<string, number>
|
||||||
|
winners: string[]
|
||||||
|
currentRound: number
|
||||||
|
totalRounds: number
|
||||||
|
resetGame: () => void
|
||||||
|
}
|
||||||
|
roomMeta: { value: RoomMetaSnapshotState | null }
|
||||||
|
roomName: ComputedRef<string>
|
||||||
|
myHandTiles: ComputedRef<Tile[]>
|
||||||
|
myPlayer: ComputedRef<DisplayPlayer | undefined>
|
||||||
|
session: ReturnType<typeof useChengduGameSession>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChengduGameSocket(options: UseChengduGameSocketOptions) {
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
let needsInitialRoomInfo = false
|
||||||
|
|
||||||
|
const showSettlementOverlay = computed(
|
||||||
|
() => options.gameStore.phase === 'settlement' && !options.session.settlementOverlayDismissed.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const settlementCountdown = computed(() => {
|
||||||
|
if (!showSettlementOverlay.value || !options.session.settlementDeadlineMs.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil((options.session.settlementDeadlineMs.value - options.session.now.value) / 1000),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlers = createChengduMessageHandlers({
|
||||||
|
router: options.router,
|
||||||
|
gameStore: options.gameStore,
|
||||||
|
roomMeta: options.roomMeta,
|
||||||
|
roomName: options.roomName,
|
||||||
|
myHandTiles: options.myHandTiles,
|
||||||
|
myPlayer: options.myPlayer,
|
||||||
|
session: options.session,
|
||||||
|
})
|
||||||
|
|
||||||
|
function requestRoomInfo(): void {
|
||||||
|
const routeRoomId = typeof options.route.params.roomId === 'string' ? options.route.params.roomId : ''
|
||||||
|
const roomId = routeRoomId || options.gameStore.roomId || options.roomMeta.value?.roomId || ''
|
||||||
|
if (!roomId || options.session.wsStatus.value !== 'connected') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
needsInitialRoomInfo = false
|
||||||
|
options.session.wsMessages.value.push(`[client] get_room_info ${roomId}`)
|
||||||
|
wsClient.send({
|
||||||
|
type: 'get_room_info',
|
||||||
|
roomId,
|
||||||
|
payload: {
|
||||||
|
room_id: roomId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSocketError(message: string): void {
|
||||||
|
options.session.markDiscardCompleted()
|
||||||
|
options.session.clearTurnActionPending()
|
||||||
|
options.session.wsError.value = message
|
||||||
|
options.session.wsMessages.value.push(`[error] ${message}`)
|
||||||
|
|
||||||
|
const nowMs = Date.now()
|
||||||
|
if (nowMs - options.session.lastForcedRefreshAtRef.value > 5000) {
|
||||||
|
options.session.lastForcedRefreshAtRef.value = nowMs
|
||||||
|
void options.session
|
||||||
|
.resolveWsToken(true, true)
|
||||||
|
.then((refreshedToken) => {
|
||||||
|
if (!refreshedToken) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.session.wsError.value = ''
|
||||||
|
wsClient.reconnect(buildWsUrl(), refreshedToken)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
options.session.logoutToLogin()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const routeRoomId = typeof options.route.params.roomId === 'string' ? options.route.params.roomId : ''
|
||||||
|
needsInitialRoomInfo = true
|
||||||
|
void options.session.ensureCurrentUserLoaded().finally(() => {
|
||||||
|
handlers.hydrateFromActiveRoom(routeRoomId)
|
||||||
|
if (routeRoomId) {
|
||||||
|
options.gameStore.roomId = routeRoomId
|
||||||
|
}
|
||||||
|
if (options.session.wsStatus.value === 'connected' && needsInitialRoomInfo) {
|
||||||
|
requestRoomInfo()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusHandler = (status: WsStatus) => {
|
||||||
|
options.session.wsStatus.value = status
|
||||||
|
if (status === 'connected' && needsInitialRoomInfo) {
|
||||||
|
requestRoomInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wsClient.onMessage(handlers.handleSocketMessage)
|
||||||
|
wsClient.onError(handleSocketError)
|
||||||
|
unsubscribe = wsClient.onStatusChange(statusHandler)
|
||||||
|
void options.session.ensureWsConnected()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe()
|
||||||
|
unsubscribe = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
showSettlementOverlay,
|
||||||
|
settlementCountdown,
|
||||||
|
}
|
||||||
|
}
|
||||||
430
src/views/chengdu/composables/useChengduTableView.ts
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import eastWind from '../../../assets/images/direction/dong.png'
|
||||||
|
import southWind from '../../../assets/images/direction/nan.png'
|
||||||
|
import westWind from '../../../assets/images/direction/xi.png'
|
||||||
|
import northWind from '../../../assets/images/direction/bei.png'
|
||||||
|
import type { SeatPlayerCardModel } from '../../../components/game/seat-player-card'
|
||||||
|
import { getTileImage as getBottomTileImage } from '../../../config/bottomTileMap'
|
||||||
|
import { getTileImage as getLeftTileImage } from '../../../config/leftTileMap'
|
||||||
|
import { getTileImage as getRightTileImage } from '../../../config/rightTileMap'
|
||||||
|
import { getTileImage as getTopTileImage } from '../../../config/topTileMap'
|
||||||
|
import type { SeatKey } from '../../../game/seat'
|
||||||
|
import type { Tile } from '../../../types/tile'
|
||||||
|
import type {
|
||||||
|
DeskSeatState,
|
||||||
|
DisplayPlayer,
|
||||||
|
HandSuitLabel,
|
||||||
|
TableTileImageType,
|
||||||
|
TableViewDeps,
|
||||||
|
TableViewResult,
|
||||||
|
WallSeatState,
|
||||||
|
WallTileItem,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
const handSuitOrder: Record<Tile['suit'], number> = { W: 0, T: 1, B: 2 }
|
||||||
|
const handSuitLabelMap: Record<Tile['suit'], HandSuitLabel> = { W: '万', T: '筒', B: '条' }
|
||||||
|
|
||||||
|
function buildWallTileImage(
|
||||||
|
seat: SeatKey,
|
||||||
|
tile: Tile | undefined,
|
||||||
|
imageType: TableTileImageType,
|
||||||
|
): string {
|
||||||
|
switch (seat) {
|
||||||
|
case 'top':
|
||||||
|
return getTopTileImage(tile, imageType, 'top')
|
||||||
|
case 'right':
|
||||||
|
return getRightTileImage(tile, imageType, 'right')
|
||||||
|
case 'left':
|
||||||
|
return getLeftTileImage(tile, imageType, 'left')
|
||||||
|
case 'bottom':
|
||||||
|
default:
|
||||||
|
return tile ? getBottomTileImage(tile, imageType, 'bottom') : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function missingSuitLabel(value: string | null | undefined): string {
|
||||||
|
const suitMap: Record<string, string> = {
|
||||||
|
w: '万',
|
||||||
|
t: '筒',
|
||||||
|
b: '条',
|
||||||
|
wan: '万',
|
||||||
|
tong: '筒',
|
||||||
|
tiao: '条',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase()
|
||||||
|
return suitMap[normalized] ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTile(tile: Tile): string {
|
||||||
|
return `${tile.suit}${tile.value}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyWallSeat(): WallSeatState {
|
||||||
|
return { tiles: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDeskSeat(): DeskSeatState {
|
||||||
|
return { tiles: [], hasHu: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChengduTableView(deps: TableViewDeps): TableViewResult {
|
||||||
|
const visibleHandTileGroups = computed(() => {
|
||||||
|
const grouped = new Map<HandSuitLabel, Tile[]>()
|
||||||
|
|
||||||
|
deps.myHandTiles.value
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => {
|
||||||
|
const suitDiff = handSuitOrder[left.suit] - handSuitOrder[right.suit]
|
||||||
|
if (suitDiff !== 0) {
|
||||||
|
return suitDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueDiff = left.value - right.value
|
||||||
|
if (valueDiff !== 0) {
|
||||||
|
return valueDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.id - right.id
|
||||||
|
})
|
||||||
|
.forEach((tile) => {
|
||||||
|
const label = handSuitLabelMap[tile.suit]
|
||||||
|
const current = grouped.get(label) ?? []
|
||||||
|
current.push(tile)
|
||||||
|
grouped.set(label, current)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (['万', '筒', '条'] as HandSuitLabel[])
|
||||||
|
.map((suit) => ({
|
||||||
|
suit,
|
||||||
|
tiles: grouped.get(suit) ?? [],
|
||||||
|
}))
|
||||||
|
.filter((group) => group.tiles.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedVisibleHandTiles = computed(() => visibleHandTileGroups.value.flatMap((group) => group.tiles))
|
||||||
|
|
||||||
|
const roomName = computed(() => {
|
||||||
|
const activeRoomName =
|
||||||
|
deps.roomMeta.value && deps.roomMeta.value.roomId === deps.gameStore.roomId
|
||||||
|
? deps.roomMeta.value.roomName
|
||||||
|
: ''
|
||||||
|
return deps.routeRoomName.value || activeRoomName || `房间 ${deps.gameStore.roomId || '--'}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const roomState = computed(() => {
|
||||||
|
const status =
|
||||||
|
deps.gameStore.phase === 'waiting'
|
||||||
|
? 'waiting'
|
||||||
|
: deps.gameStore.phase === 'settlement'
|
||||||
|
? 'finished'
|
||||||
|
: 'playing'
|
||||||
|
const wall = Array.from({ length: deps.gameStore.remainingTiles }, (_, index) => `wall-${index}`)
|
||||||
|
const maxPlayers =
|
||||||
|
deps.roomMeta.value && deps.roomMeta.value.roomId === deps.gameStore.roomId
|
||||||
|
? deps.roomMeta.value.maxPlayers
|
||||||
|
: 4
|
||||||
|
|
||||||
|
return {
|
||||||
|
roomId: deps.gameStore.roomId,
|
||||||
|
name: roomName.value,
|
||||||
|
playerCount: deps.gamePlayers.value.length,
|
||||||
|
maxPlayers,
|
||||||
|
status,
|
||||||
|
game: {
|
||||||
|
state: {
|
||||||
|
wall,
|
||||||
|
dealerIndex: deps.gameStore.dealerIndex,
|
||||||
|
currentTurn: deps.gameStore.currentTurn,
|
||||||
|
phase: deps.gameStore.phase,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const seatViews = computed(() => {
|
||||||
|
const players = deps.gamePlayers.value
|
||||||
|
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
|
||||||
|
const selfSeatIndex =
|
||||||
|
deps.myPlayer.value?.seatIndex ??
|
||||||
|
players.find((player) => player.playerId === deps.loggedInUserId.value)?.seatIndex ??
|
||||||
|
0
|
||||||
|
|
||||||
|
return players.slice(0, 4).map((player) => {
|
||||||
|
const relativeIndex = (selfSeatIndex - player.seatIndex + 4) % 4
|
||||||
|
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
||||||
|
return {
|
||||||
|
key: seatKey,
|
||||||
|
player,
|
||||||
|
isSelf: player.playerId === deps.loggedInUserId.value,
|
||||||
|
isTurn: player.seatIndex === deps.gameStore.currentTurn,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const seatWinds = computed<Record<SeatKey, string>>(() => {
|
||||||
|
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
|
||||||
|
const players = deps.gamePlayers.value
|
||||||
|
const selfSeatIndex =
|
||||||
|
deps.myPlayer.value?.seatIndex ??
|
||||||
|
players.find((player) => player.playerId === deps.loggedInUserId.value)?.seatIndex ??
|
||||||
|
0
|
||||||
|
|
||||||
|
const directionBySeatIndex = [eastWind, southWind, westWind, northWind]
|
||||||
|
const result: Record<SeatKey, string> = {
|
||||||
|
top: northWind,
|
||||||
|
right: eastWind,
|
||||||
|
bottom: southWind,
|
||||||
|
left: westWind,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let absoluteSeat = 0; absoluteSeat < 4; absoluteSeat += 1) {
|
||||||
|
const relativeIndex = (selfSeatIndex - absoluteSeat + 4) % 4
|
||||||
|
const seatKey = tableOrder[relativeIndex] ?? 'top'
|
||||||
|
result[seatKey] = directionBySeatIndex[absoluteSeat] ?? northWind
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTurnSeat = computed<SeatKey | ''>(() => seatViews.value.find((seat) => seat.isTurn)?.key ?? '')
|
||||||
|
|
||||||
|
const currentPhaseText = computed(() => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
waiting: '等待中',
|
||||||
|
dealing: '发牌中',
|
||||||
|
playing: '对局中',
|
||||||
|
action: '操作中',
|
||||||
|
settlement: '已结算',
|
||||||
|
}
|
||||||
|
return map[deps.gameStore.phase] ?? deps.gameStore.phase
|
||||||
|
})
|
||||||
|
|
||||||
|
const roomStatusText = computed(() => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
waiting: '等待玩家',
|
||||||
|
playing: '游戏中',
|
||||||
|
finished: '已结束',
|
||||||
|
}
|
||||||
|
const status = roomState.value.status
|
||||||
|
return map[status] ?? status ?? '--'
|
||||||
|
})
|
||||||
|
|
||||||
|
const roundText = computed(() => {
|
||||||
|
if (deps.gameStore.totalRounds > 0) {
|
||||||
|
return `${deps.gameStore.currentRound}/${deps.gameStore.totalRounds}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const settlementPlayers = computed(() => {
|
||||||
|
const winnerSet = new Set(deps.gameStore.winners)
|
||||||
|
return Object.values(deps.gameStore.players as Record<string, DisplayPlayer>)
|
||||||
|
.map((player) => ({
|
||||||
|
playerId: player.playerId,
|
||||||
|
displayName: player.displayName || `玩家${player.seatIndex + 1}`,
|
||||||
|
score: deps.gameStore.scores[player.playerId] ?? 0,
|
||||||
|
isWinner: winnerSet.has(player.playerId),
|
||||||
|
seatIndex: player.seatIndex,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
})
|
||||||
|
|
||||||
|
const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
|
||||||
|
const emptyState: Record<SeatKey, WallSeatState> = {
|
||||||
|
top: emptyWallSeat(),
|
||||||
|
right: emptyWallSeat(),
|
||||||
|
bottom: emptyWallSeat(),
|
||||||
|
left: emptyWallSeat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deps.gameStore.phase === 'waiting' && deps.myHandTiles.value.length === 0) {
|
||||||
|
return emptyState
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const seat of seatViews.value) {
|
||||||
|
if (!seat.player) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const seatTiles: WallTileItem[] = []
|
||||||
|
const targetSeat = seat.key
|
||||||
|
|
||||||
|
if (seat.isSelf) {
|
||||||
|
const missingSuit = seat.player.missingSuit as Tile['suit'] | null | undefined
|
||||||
|
sortedVisibleHandTiles.value.forEach((tile, index) => {
|
||||||
|
const src = buildWallTileImage(targetSeat, tile, 'hand')
|
||||||
|
if (!src) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const previousTile = index > 0 ? sortedVisibleHandTiles.value[index - 1] : undefined
|
||||||
|
const isMissingSuitGroupStart = Boolean(
|
||||||
|
missingSuit &&
|
||||||
|
tile.suit === missingSuit &&
|
||||||
|
(!previousTile || previousTile.suit !== tile.suit),
|
||||||
|
)
|
||||||
|
|
||||||
|
seatTiles.push({
|
||||||
|
key: `hand-${tile.id}-${index}`,
|
||||||
|
src,
|
||||||
|
alt: formatTile(tile),
|
||||||
|
imageType: 'hand',
|
||||||
|
showLackTag: isMissingSuitGroupStart,
|
||||||
|
suit: tile.suit,
|
||||||
|
tile,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
for (let index = 0; index < seat.player.handCount; index += 1) {
|
||||||
|
const src = buildWallTileImage(targetSeat, undefined, 'hand')
|
||||||
|
if (!src) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seatTiles.push({
|
||||||
|
key: `concealed-${index}`,
|
||||||
|
src,
|
||||||
|
alt: '手牌背面',
|
||||||
|
imageType: 'hand',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyState[targetSeat] = { tiles: seatTiles }
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyState
|
||||||
|
})
|
||||||
|
|
||||||
|
const deskSeats = computed<Record<SeatKey, DeskSeatState>>(() => {
|
||||||
|
const emptyState: Record<SeatKey, DeskSeatState> = {
|
||||||
|
top: emptyDeskSeat(),
|
||||||
|
right: emptyDeskSeat(),
|
||||||
|
bottom: emptyDeskSeat(),
|
||||||
|
left: emptyDeskSeat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deps.gameStore.phase === 'waiting' && deps.myHandTiles.value.length === 0) {
|
||||||
|
return emptyState
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const seat of seatViews.value) {
|
||||||
|
if (!seat.player) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const seatTiles: WallTileItem[] = []
|
||||||
|
const targetSeat = seat.key
|
||||||
|
|
||||||
|
seat.player.discardTiles.forEach((tile, index) => {
|
||||||
|
const src = buildWallTileImage(targetSeat, tile, 'exposed')
|
||||||
|
if (!src) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seatTiles.push({
|
||||||
|
key: `discard-${tile.id}-${index}`,
|
||||||
|
src,
|
||||||
|
alt: formatTile(tile),
|
||||||
|
imageType: 'exposed',
|
||||||
|
suit: tile.suit,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
seat.player.melds.forEach((meld, meldIndex) => {
|
||||||
|
meld.tiles.forEach((tile, tileIndex) => {
|
||||||
|
const imageType: TableTileImageType = meld.type === 'an_gang' ? 'covered' : 'exposed'
|
||||||
|
const src = buildWallTileImage(targetSeat, tile, imageType)
|
||||||
|
if (!src) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seatTiles.push({
|
||||||
|
key: `desk-${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`,
|
||||||
|
src,
|
||||||
|
alt: formatTile(tile),
|
||||||
|
imageType,
|
||||||
|
isGroupStart: tileIndex === 0,
|
||||||
|
suit: tile.suit,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
emptyState[targetSeat] = {
|
||||||
|
tiles: seatTiles,
|
||||||
|
hasHu: seat.player.hasHu,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyState
|
||||||
|
})
|
||||||
|
|
||||||
|
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
||||||
|
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
|
||||||
|
const defaultMissingSuitLabel = missingSuitLabel(null)
|
||||||
|
|
||||||
|
const emptySeat = (): SeatPlayerCardModel => ({
|
||||||
|
avatarUrl: '',
|
||||||
|
name: '空位',
|
||||||
|
dealer: false,
|
||||||
|
isTurn: false,
|
||||||
|
isReady: false,
|
||||||
|
isTrustee: false,
|
||||||
|
missingSuitLabel: defaultMissingSuitLabel,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result: Record<SeatKey, SeatPlayerCardModel> = {
|
||||||
|
top: emptySeat(),
|
||||||
|
right: emptySeat(),
|
||||||
|
bottom: emptySeat(),
|
||||||
|
left: emptySeat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const seat of seatViews.value) {
|
||||||
|
if (!seat.player) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = seat.player.displayName || `玩家${seat.player.seatIndex + 1}`
|
||||||
|
const avatarUrl = seat.isSelf
|
||||||
|
? deps.localCachedAvatarUrl.value || seat.player.avatarURL || ''
|
||||||
|
: seat.player.avatarURL || ''
|
||||||
|
const selfDisplayName = seat.player.displayName || deps.loggedInUserName.value || '你自己'
|
||||||
|
|
||||||
|
result[seat.key] = {
|
||||||
|
avatarUrl,
|
||||||
|
name: Array.from(seat.isSelf ? selfDisplayName : displayName)
|
||||||
|
.slice(0, 4)
|
||||||
|
.join(''),
|
||||||
|
dealer: seat.player.seatIndex === dealerIndex,
|
||||||
|
isTurn: seat.isTurn,
|
||||||
|
isReady: Boolean(seat.player.isReady),
|
||||||
|
isTrustee: Boolean(seat.player.isTrustee),
|
||||||
|
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
roomName,
|
||||||
|
roomState,
|
||||||
|
seatViews,
|
||||||
|
seatWinds,
|
||||||
|
currentTurnSeat,
|
||||||
|
currentPhaseText,
|
||||||
|
roomStatusText,
|
||||||
|
roundText,
|
||||||
|
visibleHandTileGroups,
|
||||||
|
sortedVisibleHandTiles,
|
||||||
|
wallSeats,
|
||||||
|
deskSeats,
|
||||||
|
seatDecor,
|
||||||
|
settlementPlayers,
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/views/chengdu/socket/createMessageHandlers.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { createPlayerHandlers } from './handlers/playerHandlers'
|
||||||
|
import { createRoomInfoHandlers } from './handlers/roomInfoHandlers'
|
||||||
|
import { createRoomStateHandlers } from './handlers/roomStateHandlers'
|
||||||
|
import { createStatusHandlers } from './handlers/statusHandlers'
|
||||||
|
import { dispatchSocketGameAction } from './handlers/socketDispatch'
|
||||||
|
import { createTurnHandlers } from './handlers/turnHandlers'
|
||||||
|
import type { SocketHandlerContext } from './types'
|
||||||
|
import { parseSocketEnvelope } from './parsers/socketEnvelope'
|
||||||
|
import { hydrateGameStoreFromActiveRoom } from './room/roomSnapshotSync'
|
||||||
|
import { createSocketMessageRouter } from './router/socketMessageRouter'
|
||||||
|
import { pushWsMessage } from './session/sessionStateAdapter'
|
||||||
|
|
||||||
|
export function createChengduMessageHandlers(context: SocketHandlerContext) {
|
||||||
|
const roomHandlers = {
|
||||||
|
...createRoomInfoHandlers(context),
|
||||||
|
...createRoomStateHandlers(context),
|
||||||
|
}
|
||||||
|
const playerHandlers = createPlayerHandlers(context)
|
||||||
|
const turnHandlers = createTurnHandlers(context)
|
||||||
|
const statusHandlers = createStatusHandlers(context)
|
||||||
|
const router = createSocketMessageRouter({
|
||||||
|
roomHandlers,
|
||||||
|
playerHandlers,
|
||||||
|
turnHandlers,
|
||||||
|
statusHandlers,
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSocketMessage(msg: unknown): void {
|
||||||
|
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
|
||||||
|
pushWsMessage(context.session, `[server] ${text}`)
|
||||||
|
|
||||||
|
const envelope = parseSocketEnvelope(msg)
|
||||||
|
if (!envelope) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.route(envelope.normalizedType, envelope.source)
|
||||||
|
|
||||||
|
dispatchSocketGameAction(
|
||||||
|
context,
|
||||||
|
envelope.source,
|
||||||
|
playerHandlers.syncReadyStatesFromRoomUpdate,
|
||||||
|
playerHandlers.syncTrusteeState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hydrateFromActiveRoom: (routeRoomId: string) => hydrateGameStoreFromActiveRoom(context, routeRoomId),
|
||||||
|
handleSocketMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/views/chengdu/socket/handlers/playerHandlers.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
asRecord,
|
||||||
|
normalizeTiles,
|
||||||
|
normalizeWsType,
|
||||||
|
readBoolean,
|
||||||
|
readString,
|
||||||
|
} from '../../../../game/chengdu/messageNormalizers'
|
||||||
|
import type { RoomPlayerUpdatePayload, RoomTrusteePayload } from '../../../../game/actions'
|
||||||
|
import {
|
||||||
|
clearDingQuePending,
|
||||||
|
clearReadyTogglePending,
|
||||||
|
clearStartGamePending,
|
||||||
|
clearTurnPending,
|
||||||
|
completeDiscard,
|
||||||
|
setTrustMode,
|
||||||
|
syncCurrentUserId,
|
||||||
|
} from '../session/sessionStateAdapter'
|
||||||
|
import { setPlayerHandState, setPlayerMissingSuit, setPlayerReadyState, setPlayerTrusteeState } from '../store/gameStoreAdapter'
|
||||||
|
import type { PlayerHandlerApi, SocketHandlerContext } from '../types'
|
||||||
|
|
||||||
|
export function createPlayerHandlers(context: SocketHandlerContext): PlayerHandlerApi {
|
||||||
|
function applyPlayerReadyState(playerId: string, ready: boolean): void {
|
||||||
|
setPlayerReadyState(context.gameStore, playerId, ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncReadyStatesFromRoomUpdate(payload: RoomPlayerUpdatePayload): void {
|
||||||
|
if (!Array.isArray(payload.players)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of payload.players) {
|
||||||
|
const playerId =
|
||||||
|
(typeof item.PlayerID === 'string' && item.PlayerID) ||
|
||||||
|
(typeof item.player_id === 'string' && item.player_id) ||
|
||||||
|
''
|
||||||
|
const ready =
|
||||||
|
typeof item.Ready === 'boolean'
|
||||||
|
? item.Ready
|
||||||
|
: typeof item.ready === 'boolean'
|
||||||
|
? item.ready
|
||||||
|
: typeof item.is_ready === 'boolean'
|
||||||
|
? item.is_ready
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (!playerId || typeof ready !== 'boolean') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPlayerReadyState(playerId, ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayerHandResponse(message: unknown): void {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_HAND') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(source.payload)
|
||||||
|
if (!payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncCurrentUserId(context.session, readString(source, 'target'))
|
||||||
|
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId')
|
||||||
|
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const handTiles = normalizeTiles(payload.hand)
|
||||||
|
if (!context.session.loggedInUserId.value || handTiles.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTurnPending(context.session)
|
||||||
|
setPlayerHandState(context.gameStore, context.session.loggedInUserId.value, handTiles)
|
||||||
|
clearDingQuePending(context.session)
|
||||||
|
|
||||||
|
completeDiscard(context.session)
|
||||||
|
if (context.gameStore.phase !== 'waiting') {
|
||||||
|
clearStartGamePending(context.session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTrusteeState(payload: RoomTrusteePayload): void {
|
||||||
|
const playerId =
|
||||||
|
(typeof payload.player_id === 'string' && payload.player_id) ||
|
||||||
|
(typeof payload.playerId === 'string' && payload.playerId) ||
|
||||||
|
''
|
||||||
|
if (!playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const trustee = typeof payload.trustee === 'boolean' ? payload.trustee : true
|
||||||
|
setPlayerTrusteeState(context.gameStore, playerId, trustee)
|
||||||
|
if (playerId === context.session.loggedInUserId.value) {
|
||||||
|
setTrustMode(context.session, trustee)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReadyStateResponse(message: unknown): void {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_READY') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(source.payload)
|
||||||
|
if (!payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = typeof payload.room_id === 'string' ? payload.room_id : readString(source, 'roomId')
|
||||||
|
const userId =
|
||||||
|
(typeof payload.player_id === 'string' && payload.player_id) ||
|
||||||
|
(typeof payload.user_id === 'string' && payload.user_id) ||
|
||||||
|
readString(source, 'target')
|
||||||
|
const ready = readBoolean(payload, 'is_ready', 'ready', 'Ready')
|
||||||
|
|
||||||
|
if (roomId && roomId !== context.gameStore.roomId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ready !== null && userId) {
|
||||||
|
applyPlayerReadyState(userId, ready)
|
||||||
|
}
|
||||||
|
if (userId && userId === context.session.loggedInUserId.value) {
|
||||||
|
clearReadyTogglePending(context.session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayerDingQueResponse(message: unknown): void {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_DING_QUE') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(source.payload)
|
||||||
|
if (!payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId')
|
||||||
|
if (roomId && roomId !== context.gameStore.roomId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId =
|
||||||
|
readString(payload, 'user_id', 'userId', 'player_id', 'playerId') || readString(source, 'target')
|
||||||
|
const suit = readString(payload, 'suit', 'Suit')
|
||||||
|
if (!userId || !suit) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlayerMissingSuit(context.gameStore, userId, suit)
|
||||||
|
|
||||||
|
if (userId === context.session.loggedInUserId.value) {
|
||||||
|
clearDingQuePending(context.session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
applyPlayerReadyState,
|
||||||
|
syncReadyStatesFromRoomUpdate,
|
||||||
|
handlePlayerHandResponse,
|
||||||
|
handleReadyStateResponse,
|
||||||
|
handlePlayerDingQueResponse,
|
||||||
|
syncTrusteeState,
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/views/chengdu/socket/handlers/roomInfoHandlers.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { asRecord, normalizeWsType, readString } from '../../../../game/chengdu/messageNormalizers'
|
||||||
|
import { parseRoomInfoSnapshot } from '../parsers/roomInfoSnapshot'
|
||||||
|
import { clearRoomAndRedirect, syncActiveRoomFromRoomInfo } from '../room/roomSnapshotSync'
|
||||||
|
import {
|
||||||
|
clearClaimAndTurnPending,
|
||||||
|
clearRoomCountdown,
|
||||||
|
clearDingQuePending,
|
||||||
|
clearSelfTurnAllowActions,
|
||||||
|
clearTurnPending,
|
||||||
|
setRoomCountdown,
|
||||||
|
setSettlementDeadline,
|
||||||
|
syncCurrentUserId,
|
||||||
|
} from '../session/sessionStateAdapter'
|
||||||
|
import { applyRoomSnapshot } from '../store/gameStoreAdapter'
|
||||||
|
import type { SocketHandlerContext } from '../types'
|
||||||
|
|
||||||
|
export function createRoomInfoHandlers(context: SocketHandlerContext) {
|
||||||
|
function handleRoomInfoResponse(message: unknown): void {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedType = normalizeWsType(source.type)
|
||||||
|
if (normalizedType !== 'GET_ROOM_INFO' && normalizedType !== 'ROOM_INFO') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(source.payload) ?? source
|
||||||
|
syncCurrentUserId(context.session, readString(source, 'target'))
|
||||||
|
const room = asRecord(payload.room)
|
||||||
|
const gameState = asRecord(payload.game_state)
|
||||||
|
const playerView = asRecord(payload.player_view)
|
||||||
|
|
||||||
|
if (!room && !gameState && !playerView) {
|
||||||
|
clearRoomAndRedirect(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = parseRoomInfoSnapshot({
|
||||||
|
message: source,
|
||||||
|
loggedInUserId: context.session.loggedInUserId.value,
|
||||||
|
loggedInUserName: context.session.loggedInUserName.value,
|
||||||
|
previousPlayers: context.gameStore.players,
|
||||||
|
})
|
||||||
|
if (!snapshot) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(snapshot.nextPlayers).length > 0) {
|
||||||
|
clearDingQuePending(context.session)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyRoomSnapshot(context.gameStore, {
|
||||||
|
roomId: snapshot.roomId,
|
||||||
|
players: snapshot.nextPlayers,
|
||||||
|
phase: snapshot.phase,
|
||||||
|
wallCount: snapshot.wallCount,
|
||||||
|
dealerIndex: snapshot.dealerIndex,
|
||||||
|
currentTurn: snapshot.currentTurn,
|
||||||
|
needDraw: snapshot.needDraw,
|
||||||
|
pendingClaim: snapshot.pendingClaim,
|
||||||
|
scores: snapshot.scores,
|
||||||
|
winners: snapshot.winners,
|
||||||
|
currentRound: snapshot.currentRound,
|
||||||
|
totalRounds: snapshot.totalRounds,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!snapshot.pendingClaim) {
|
||||||
|
clearClaimAndTurnPending(context.session)
|
||||||
|
} else {
|
||||||
|
clearTurnPending(context.session)
|
||||||
|
}
|
||||||
|
if (snapshot.actionTimer) {
|
||||||
|
setRoomCountdown(context.session, snapshot.actionTimer)
|
||||||
|
} else {
|
||||||
|
clearRoomCountdown(context.session)
|
||||||
|
}
|
||||||
|
if (typeof snapshot.settlementDeadlineMs === 'number' && snapshot.settlementDeadlineMs > 0) {
|
||||||
|
setSettlementDeadline(context.session, snapshot.settlementDeadlineMs)
|
||||||
|
}
|
||||||
|
if (context.gameStore.phase !== 'playing' || snapshot.currentTurnPlayerId !== context.session.loggedInUserId.value) {
|
||||||
|
clearSelfTurnAllowActions(context.session)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncActiveRoomFromRoomInfo(context, {
|
||||||
|
roomId: snapshot.roomId,
|
||||||
|
room: snapshot.room,
|
||||||
|
status: snapshot.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleRoomInfoResponse,
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/views/chengdu/socket/handlers/roomStateHandlers.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
asRecord,
|
||||||
|
normalizeWsType,
|
||||||
|
readNumber,
|
||||||
|
readString,
|
||||||
|
} from '../../../../game/chengdu/messageNormalizers'
|
||||||
|
import { parseRoomStateSnapshot } from '../parsers/roomStateSnapshot'
|
||||||
|
import { syncActiveRoomFromRoomState } from '../room/roomSnapshotSync'
|
||||||
|
import {
|
||||||
|
clearClaimAndTurnPending,
|
||||||
|
clearRoomCountdown,
|
||||||
|
clearSelfTurnAllowActions,
|
||||||
|
clearStartGamePending,
|
||||||
|
clearTurnPending,
|
||||||
|
completeDiscard,
|
||||||
|
resetSettlementOverlayState,
|
||||||
|
setRoomCountdown,
|
||||||
|
setSettlementDeadline,
|
||||||
|
} from '../session/sessionStateAdapter'
|
||||||
|
import { applyRoomSnapshot } from '../store/gameStoreAdapter'
|
||||||
|
import type { SocketHandlerContext } from '../types'
|
||||||
|
|
||||||
|
export function createRoomStateHandlers(context: SocketHandlerContext) {
|
||||||
|
function handleRoomStateResponse(message: unknown): void {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'ROOM_STATE') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(source.payload)
|
||||||
|
if (!payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = readString(payload, 'room_id', 'roomId') || context.gameStore.roomId
|
||||||
|
if (!roomId || (context.gameStore.roomId && roomId !== context.gameStore.roomId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = parseRoomStateSnapshot({
|
||||||
|
payload,
|
||||||
|
roomId,
|
||||||
|
loggedInUserId: context.session.loggedInUserId.value,
|
||||||
|
previousPlayers: context.gameStore.players,
|
||||||
|
})
|
||||||
|
|
||||||
|
applyRoomSnapshot(context.gameStore, {
|
||||||
|
roomId: snapshot.roomId,
|
||||||
|
players: snapshot.nextPlayers,
|
||||||
|
phase: snapshot.phase,
|
||||||
|
wallCount: snapshot.wallCount,
|
||||||
|
currentTurn: snapshot.currentTurn,
|
||||||
|
needDraw: snapshot.needDraw,
|
||||||
|
pendingClaim: snapshot.pendingClaim,
|
||||||
|
scores: snapshot.scores,
|
||||||
|
winners: snapshot.winners,
|
||||||
|
currentRound: snapshot.currentRound,
|
||||||
|
totalRounds: snapshot.totalRounds,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (typeof snapshot.settlementDeadlineMs === 'number' && snapshot.settlementDeadlineMs > 0) {
|
||||||
|
setSettlementDeadline(context.session, snapshot.settlementDeadlineMs)
|
||||||
|
} else if (snapshot.phase !== 'settlement') {
|
||||||
|
setSettlementDeadline(context.session, null)
|
||||||
|
}
|
||||||
|
if (snapshot.actionTimer) {
|
||||||
|
setRoomCountdown(context.session, snapshot.actionTimer)
|
||||||
|
} else {
|
||||||
|
clearRoomCountdown(context.session)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot.pendingClaim) {
|
||||||
|
clearClaimAndTurnPending(context.session)
|
||||||
|
} else {
|
||||||
|
clearTurnPending(context.session)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncActiveRoomFromRoomState(context, {
|
||||||
|
roomId,
|
||||||
|
phase: snapshot.phase,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (snapshot.phase !== 'waiting') {
|
||||||
|
clearStartGamePending(context.session)
|
||||||
|
}
|
||||||
|
if (snapshot.phase !== 'settlement') {
|
||||||
|
resetSettlementOverlayState(context.session)
|
||||||
|
}
|
||||||
|
if (snapshot.phase !== 'playing' || snapshot.currentTurnPlayerId !== context.session.loggedInUserId.value) {
|
||||||
|
clearSelfTurnAllowActions(context.session)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
snapshot.currentTurnPlayerId &&
|
||||||
|
snapshot.currentTurnPlayerId !== context.session.loggedInUserId.value
|
||||||
|
) {
|
||||||
|
completeDiscard(context.session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleRoomStateResponse,
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/views/chengdu/socket/handlers/socketDispatch.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { dispatchGameAction } from '../../../../game/dispatcher'
|
||||||
|
import { parseGameActionMessage } from '../parsers/gameActionMessage'
|
||||||
|
import { applyGameActionSessionEffects } from '../session/gameActionEffects'
|
||||||
|
import type { SocketHandlerContext } from '../types'
|
||||||
|
|
||||||
|
export function dispatchSocketGameAction(
|
||||||
|
context: SocketHandlerContext,
|
||||||
|
msg: unknown,
|
||||||
|
onRoomPlayerUpdate: (payload: any) => void,
|
||||||
|
onRoomTrustee: (payload: any) => void,
|
||||||
|
): void {
|
||||||
|
const gameAction = parseGameActionMessage(msg)
|
||||||
|
if (!gameAction) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchGameAction(gameAction)
|
||||||
|
applyGameActionSessionEffects(context, gameAction, onRoomPlayerUpdate, onRoomTrustee)
|
||||||
|
}
|
||||||
59
src/views/chengdu/socket/handlers/statusHandlers.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
asRecord,
|
||||||
|
normalizeWsType,
|
||||||
|
readString,
|
||||||
|
} from '../../../../game/chengdu/messageNormalizers'
|
||||||
|
import { clearClaimAndTurnPending, clearRoomCountdown, pushWsMessage, setWsError } from '../session/sessionStateAdapter'
|
||||||
|
import type { SocketHandlerContext, StatusHandlerApi } from '../types'
|
||||||
|
|
||||||
|
export function createStatusHandlers(context: SocketHandlerContext): StatusHandlerApi {
|
||||||
|
function handleActionAck(message: unknown): void {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'ACTION_ACK') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(source.payload)
|
||||||
|
const roomId = readString(payload ?? {}, 'room_id', 'roomId') || readString(source, 'roomId')
|
||||||
|
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = normalizeWsType(readString(payload ?? {}, 'action') || readString(source, 'action'))
|
||||||
|
if (
|
||||||
|
action === 'DISCARD' ||
|
||||||
|
action === 'DRAW' ||
|
||||||
|
action === 'PENG' ||
|
||||||
|
action === 'GANG' ||
|
||||||
|
action === 'HU' ||
|
||||||
|
action === 'PASS' ||
|
||||||
|
action === 'DING_QUE'
|
||||||
|
) {
|
||||||
|
clearRoomCountdown(context.session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionError(message: unknown): void {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'ACTION_ERROR') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(source.payload)
|
||||||
|
const roomId = readString(payload ?? {}, 'room_id', 'roomId') || readString(source, 'roomId')
|
||||||
|
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = readString(payload ?? {}, 'action') || 'unknown'
|
||||||
|
const messageText = readString(payload ?? {}, 'message') || '操作失败'
|
||||||
|
clearClaimAndTurnPending(context.session)
|
||||||
|
setWsError(context.session, messageText)
|
||||||
|
pushWsMessage(context.session, `[action-error] ${action}: ${messageText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleActionAck,
|
||||||
|
handleActionError,
|
||||||
|
}
|
||||||
|
}
|
||||||
197
src/views/chengdu/socket/handlers/turnHandlers.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import {
|
||||||
|
asRecord,
|
||||||
|
normalizeTimestampMs,
|
||||||
|
normalizeWsType,
|
||||||
|
readNumber,
|
||||||
|
readPlayerTurnAllowActions,
|
||||||
|
readPlayerTurnPlayerId,
|
||||||
|
readString,
|
||||||
|
readStringArray,
|
||||||
|
} from '../../../../game/chengdu/messageNormalizers'
|
||||||
|
import {
|
||||||
|
clearClaimAndTurnPending,
|
||||||
|
clearDingQuePending,
|
||||||
|
clearNextRoundPending,
|
||||||
|
clearRoomCountdown,
|
||||||
|
clearSelfTurnAllowActions,
|
||||||
|
clearTurnPending,
|
||||||
|
completeDiscard,
|
||||||
|
resetSettlementOverlayState,
|
||||||
|
setRoomCountdown,
|
||||||
|
setSelfTurnAllowActions,
|
||||||
|
} from '../session/sessionStateAdapter'
|
||||||
|
import { resetRoundResolutionState } from '../store/gameStoreAdapter'
|
||||||
|
import type { SocketHandlerContext, TurnHandlerApi, TurnPayloadRecord } from '../types'
|
||||||
|
|
||||||
|
export function createTurnHandlers(context: SocketHandlerContext): TurnHandlerApi {
|
||||||
|
function handlePlayerAllowAction(message: unknown): void {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_ALLOW_ACTION') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(source.payload) ?? source
|
||||||
|
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId', 'room_id')
|
||||||
|
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerId = readPlayerTurnPlayerId(payload)
|
||||||
|
const timeout =
|
||||||
|
readNumber(payload, 'timeout', 'Timeout') ??
|
||||||
|
readNumber(source, 'timeout', 'Timeout') ??
|
||||||
|
0
|
||||||
|
const startAtRaw =
|
||||||
|
readNumber(payload, 'start_at', 'startAt', 'StartAt') ??
|
||||||
|
readNumber(source, 'start_at', 'startAt', 'StartAt')
|
||||||
|
|
||||||
|
if (!playerId || timeout <= 0) {
|
||||||
|
clearRoomCountdown(context.session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAtMs = normalizeTimestampMs(startAtRaw)
|
||||||
|
const deadlineAtMs = startAtMs !== null ? startAtMs + timeout * 1000 : null
|
||||||
|
const remaining =
|
||||||
|
deadlineAtMs !== null ? Math.max(0, Math.ceil((deadlineAtMs - context.session.now.value) / 1000)) : timeout
|
||||||
|
|
||||||
|
setRoomCountdown(context.session, {
|
||||||
|
playerIds: [playerId],
|
||||||
|
actionDeadlineAt: deadlineAtMs !== null ? new Date(deadlineAtMs).toISOString() : null,
|
||||||
|
countdownSeconds: timeout,
|
||||||
|
duration: timeout,
|
||||||
|
remaining,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDingQueCountdown(message: unknown): void {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'DING_QUE_COUNTDOWN') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(source.payload) ?? source
|
||||||
|
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId')
|
||||||
|
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerIds = readStringArray(payload, 'player_ids', 'playerIds', 'PlayerIDs')
|
||||||
|
const fallbackPlayerId = readString(payload, 'player_id', 'playerId', 'PlayerID')
|
||||||
|
const normalizedPlayerIds = playerIds.length > 0 ? playerIds : fallbackPlayerId ? [fallbackPlayerId] : []
|
||||||
|
if (normalizedPlayerIds.length === 0) {
|
||||||
|
clearRoomCountdown(context.session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const countdownSeconds = readNumber(payload, 'countdown_seconds', 'CountdownSeconds') ?? 0
|
||||||
|
const duration = readNumber(payload, 'duration', 'Duration') ?? countdownSeconds
|
||||||
|
const remaining = readNumber(payload, 'remaining', 'Remaining') ?? countdownSeconds
|
||||||
|
const actionDeadlineAt = readString(payload, 'action_deadline_at', 'ActionDeadlineAt') || null
|
||||||
|
|
||||||
|
if (countdownSeconds <= 0 && remaining <= 0 && !actionDeadlineAt) {
|
||||||
|
clearRoomCountdown(context.session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setRoomCountdown(context.session, {
|
||||||
|
playerIds: normalizedPlayerIds,
|
||||||
|
actionDeadlineAt,
|
||||||
|
countdownSeconds,
|
||||||
|
duration,
|
||||||
|
remaining,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPlayerTurnCountdown(payload: TurnPayloadRecord): void {
|
||||||
|
const playerId = readPlayerTurnPlayerId(payload)
|
||||||
|
const timeout =
|
||||||
|
(typeof payload.timeout === 'number' && Number.isFinite(payload.timeout) ? payload.timeout : null) ??
|
||||||
|
(typeof payload.Timeout === 'number' && Number.isFinite(payload.Timeout) ? payload.Timeout : null) ??
|
||||||
|
0
|
||||||
|
const startAtRaw =
|
||||||
|
(typeof payload.start_at === 'number' && Number.isFinite(payload.start_at) ? payload.start_at : null) ??
|
||||||
|
(typeof payload.startAt === 'number' && Number.isFinite(payload.startAt) ? payload.startAt : null) ??
|
||||||
|
(typeof payload.StartAt === 'number' && Number.isFinite(payload.StartAt) ? payload.StartAt : null)
|
||||||
|
|
||||||
|
if (!playerId || timeout <= 0) {
|
||||||
|
clearRoomCountdown(context.session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAtMs = normalizeTimestampMs(startAtRaw)
|
||||||
|
const deadlineAtMs = startAtMs !== null ? startAtMs + timeout * 1000 : null
|
||||||
|
const remaining =
|
||||||
|
deadlineAtMs !== null ? Math.max(0, Math.ceil((deadlineAtMs - context.session.now.value) / 1000)) : timeout
|
||||||
|
|
||||||
|
setRoomCountdown(context.session, {
|
||||||
|
playerIds: [playerId],
|
||||||
|
actionDeadlineAt: deadlineAtMs !== null ? new Date(deadlineAtMs).toISOString() : null,
|
||||||
|
countdownSeconds: timeout,
|
||||||
|
duration: timeout,
|
||||||
|
remaining,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRoundStateForNextTurn(payload: Record<string, unknown>): void {
|
||||||
|
const nextRound = readNumber(payload, 'current_round', 'currentRound')
|
||||||
|
const totalRounds = readNumber(payload, 'total_rounds', 'totalRounds')
|
||||||
|
if (typeof nextRound !== 'number' && typeof totalRounds !== 'number') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof nextRound === 'number') {
|
||||||
|
context.gameStore.currentRound = nextRound
|
||||||
|
}
|
||||||
|
if (typeof totalRounds === 'number') {
|
||||||
|
context.gameStore.totalRounds = totalRounds
|
||||||
|
}
|
||||||
|
|
||||||
|
clearNextRoundPending(context.session)
|
||||||
|
resetSettlementOverlayState(context.session)
|
||||||
|
clearDingQuePending(context.session)
|
||||||
|
clearRoomCountdown(context.session)
|
||||||
|
clearClaimAndTurnPending(context.session)
|
||||||
|
clearSelfTurnAllowActions(context.session)
|
||||||
|
resetRoundResolutionState(context.gameStore)
|
||||||
|
|
||||||
|
completeDiscard(context.session)
|
||||||
|
clearTurnPending(context.session)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayerTurn(message: unknown): void {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedType = normalizeWsType(source.type)
|
||||||
|
if (normalizedType !== 'PLAYER_TURN' && normalizedType !== 'NEXT_TURN') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(source.payload) ?? source
|
||||||
|
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId')
|
||||||
|
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetRoundStateForNextTurn(payload)
|
||||||
|
const turnPayload = payload as TurnPayloadRecord
|
||||||
|
const turnPlayerID = readPlayerTurnPlayerId(turnPayload)
|
||||||
|
if (turnPlayerID && turnPlayerID === context.session.loggedInUserId.value) {
|
||||||
|
setSelfTurnAllowActions(context.session, readPlayerTurnAllowActions(turnPayload))
|
||||||
|
} else {
|
||||||
|
clearSelfTurnAllowActions(context.session)
|
||||||
|
}
|
||||||
|
if (normalizedType === 'PLAYER_TURN') {
|
||||||
|
applyPlayerTurnCountdown(turnPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleDingQueCountdown,
|
||||||
|
handlePlayerAllowAction,
|
||||||
|
handlePlayerTurn,
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/views/chengdu/socket/parsers/actionTimerSnapshot.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { asRecord, readNumber, readString, readStringArray } from '../../../../game/chengdu/messageNormalizers'
|
||||||
|
import type { PlayerActionTimer } from '../../types'
|
||||||
|
|
||||||
|
export function parseActionTimerSnapshot(source: unknown): PlayerActionTimer | null {
|
||||||
|
const timer = asRecord(source)
|
||||||
|
if (!timer) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerIds = readStringArray(timer, 'player_ids', 'playerIds', 'PlayerIDs')
|
||||||
|
const countdownSeconds = readNumber(timer, 'countdown_seconds', 'countdownSeconds', 'CountdownSeconds') ?? 0
|
||||||
|
const duration = readNumber(timer, 'duration', 'Duration') ?? countdownSeconds
|
||||||
|
const remaining = readNumber(timer, 'remaining', 'Remaining') ?? countdownSeconds
|
||||||
|
const actionDeadlineAt = readString(timer, 'action_deadline_at', 'actionDeadlineAt', 'ActionDeadlineAt') || null
|
||||||
|
|
||||||
|
if (playerIds.length === 0 && countdownSeconds <= 0 && remaining <= 0 && !actionDeadlineAt) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerIds,
|
||||||
|
actionDeadlineAt,
|
||||||
|
countdownSeconds,
|
||||||
|
duration,
|
||||||
|
remaining,
|
||||||
|
}
|
||||||
|
}
|
||||||
100
src/views/chengdu/socket/parsers/gameActionMessage.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { DiscardActionPayload, DrawActionPayload, GameAction, PlayerTurnPayload, RoomTrusteePayload } from '../../../../game/actions'
|
||||||
|
import type { ClaimOptionState } from '../../../../types/state'
|
||||||
|
import { asRecord, normalizeTile, normalizeWsType, readNumber, readString } from '../../../../game/chengdu/messageNormalizers'
|
||||||
|
|
||||||
|
export function parseGameActionMessage(message: unknown): GameAction | null {
|
||||||
|
if (!message || typeof message !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = message as Record<string, unknown>
|
||||||
|
if (typeof source.type !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = normalizeWsType(source.type)
|
||||||
|
const payload = source.payload
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'GAME_INIT':
|
||||||
|
case 'GAME_START':
|
||||||
|
case 'DRAW_TILE':
|
||||||
|
case 'PLAY_TILE':
|
||||||
|
case 'PENDING_CLAIM':
|
||||||
|
case 'CLAIM_RESOLVED':
|
||||||
|
case 'ROOM_PLAYER_UPDATE':
|
||||||
|
return payload && typeof payload === 'object' ? ({ type, payload } as GameAction) : null
|
||||||
|
case 'ROOM_MEMBER_JOINED':
|
||||||
|
return payload && typeof payload === 'object'
|
||||||
|
? ({ type: 'ROOM_PLAYER_UPDATE', payload } as GameAction)
|
||||||
|
: null
|
||||||
|
case 'ROOM_TRUSTEE':
|
||||||
|
case 'PLAYER_TRUSTEE':
|
||||||
|
return payload && typeof payload === 'object'
|
||||||
|
? ({ type: 'ROOM_TRUSTEE', payload } as GameAction)
|
||||||
|
: ({ type: 'ROOM_TRUSTEE', payload: source as unknown as RoomTrusteePayload } as GameAction)
|
||||||
|
case 'DRAW': {
|
||||||
|
const resolvedPayload = asRecord(payload)
|
||||||
|
const playerId =
|
||||||
|
readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') ||
|
||||||
|
readString(source, 'target')
|
||||||
|
if (!playerId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'DRAW_TILE',
|
||||||
|
payload: {
|
||||||
|
...(resolvedPayload as DrawActionPayload | null),
|
||||||
|
player_id: playerId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'DISCARD': {
|
||||||
|
const resolvedPayload = asRecord(payload)
|
||||||
|
const playerId =
|
||||||
|
readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') ||
|
||||||
|
readString(source, 'target')
|
||||||
|
const tile = normalizeTile(resolvedPayload?.tile)
|
||||||
|
if (!playerId || !tile) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const nextSeat = readNumber(resolvedPayload ?? {}, 'next_seat', 'nextSeat')
|
||||||
|
return {
|
||||||
|
type: 'PLAY_TILE',
|
||||||
|
payload: {
|
||||||
|
...(resolvedPayload as DiscardActionPayload | null),
|
||||||
|
player_id: playerId,
|
||||||
|
tile,
|
||||||
|
...(typeof nextSeat === 'number' ? { next_seat: nextSeat } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'PLAYER_TURN':
|
||||||
|
case 'NEXT_TURN':
|
||||||
|
return payload && typeof payload === 'object'
|
||||||
|
? ({ type: 'PLAYER_TURN', payload } as GameAction)
|
||||||
|
: ({ type: 'PLAYER_TURN', payload: source as unknown as PlayerTurnPayload } as GameAction)
|
||||||
|
case 'PENG':
|
||||||
|
case 'GANG':
|
||||||
|
case 'HU':
|
||||||
|
case 'PASS': {
|
||||||
|
const resolvedPayload = asRecord(payload)
|
||||||
|
const playerId =
|
||||||
|
readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') ||
|
||||||
|
readString(source, 'target')
|
||||||
|
const action = type.toLowerCase()
|
||||||
|
if (!playerId || !['peng', 'gang', 'hu', 'pass'].includes(action)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'CLAIM_RESOLVED',
|
||||||
|
payload: {
|
||||||
|
playerId,
|
||||||
|
action: action as ClaimOptionState,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
281
src/views/chengdu/socket/parsers/roomInfoSnapshot.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import {
|
||||||
|
asRecord,
|
||||||
|
normalizeMelds,
|
||||||
|
normalizePendingClaim,
|
||||||
|
normalizeTiles,
|
||||||
|
readBoolean,
|
||||||
|
readMissingSuit,
|
||||||
|
readMissingSuitWithPresence,
|
||||||
|
readNumber,
|
||||||
|
readString,
|
||||||
|
readStringArray,
|
||||||
|
tileToText,
|
||||||
|
} from '../../../../game/chengdu/messageNormalizers'
|
||||||
|
import type { RoomMetaSnapshotState } from '../../../../store/state'
|
||||||
|
import type { PendingClaimState, PlayerState } from '../../../../types/state'
|
||||||
|
import type { PlayerActionTimer } from '../../types'
|
||||||
|
import { parseActionTimerSnapshot } from './actionTimerSnapshot'
|
||||||
|
|
||||||
|
interface RoomInfoSnapshotPlayerPair {
|
||||||
|
roomPlayer: RoomMetaSnapshotState['players'][number]
|
||||||
|
gamePlayer: PlayerState
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedRoomInfoSnapshot {
|
||||||
|
room: Record<string, unknown> | null
|
||||||
|
gameState: Record<string, unknown> | null
|
||||||
|
playerView: Record<string, unknown> | null
|
||||||
|
roomId: string
|
||||||
|
roomPlayers: RoomInfoSnapshotPlayerPair[]
|
||||||
|
nextPlayers: Record<string, PlayerState>
|
||||||
|
status: string
|
||||||
|
phase: string
|
||||||
|
wallCount: number | null
|
||||||
|
dealerIndex: number | null
|
||||||
|
currentTurnPlayerId: string
|
||||||
|
currentTurn: number | null
|
||||||
|
needDraw: boolean
|
||||||
|
pendingClaim?: PendingClaimState
|
||||||
|
scores?: Record<string, number>
|
||||||
|
winners: string[]
|
||||||
|
currentRound: number | null
|
||||||
|
totalRounds: number | null
|
||||||
|
settlementDeadlineMs: number | null
|
||||||
|
actionTimer: PlayerActionTimer | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParseRoomInfoSnapshotOptions {
|
||||||
|
message: Record<string, unknown>
|
||||||
|
loggedInUserId: string
|
||||||
|
loggedInUserName: string
|
||||||
|
previousPlayers: Record<string, PlayerState>
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlayerPairs(options: ParseRoomInfoSnapshotOptions, payload: Record<string, unknown>) {
|
||||||
|
const room = asRecord(payload.room)
|
||||||
|
const gameState = asRecord(payload.game_state)
|
||||||
|
const playerView = asRecord(payload.player_view)
|
||||||
|
const roomPlayers = Array.isArray(room?.players) ? room.players : []
|
||||||
|
const gamePlayers = Array.isArray(gameState?.players) ? gameState.players : []
|
||||||
|
const playerMap = new Map<string, RoomInfoSnapshotPlayerPair>()
|
||||||
|
|
||||||
|
roomPlayers.forEach((item, fallbackIndex) => {
|
||||||
|
const player = asRecord(item)
|
||||||
|
if (!player) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerId = readString(player, 'player_id', 'PlayerID', 'id', 'user_id')
|
||||||
|
if (!playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const seatIndex = readNumber(player, 'index', 'Index', 'seat_index', 'seatIndex') ?? fallbackIndex
|
||||||
|
const displayName =
|
||||||
|
readString(player, 'player_name', 'PlayerName', 'display_name', 'displayName', 'nickname', 'username') ||
|
||||||
|
(playerId === options.loggedInUserId ? options.loggedInUserName : '')
|
||||||
|
const ready = readBoolean(player, 'ready', 'Ready') ?? false
|
||||||
|
const missingSuit = readMissingSuit(player)
|
||||||
|
|
||||||
|
playerMap.set(playerId, {
|
||||||
|
roomPlayer: {
|
||||||
|
index: seatIndex,
|
||||||
|
playerId,
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
missingSuit,
|
||||||
|
ready,
|
||||||
|
trustee: false,
|
||||||
|
hand: [],
|
||||||
|
melds: [],
|
||||||
|
outTiles: [],
|
||||||
|
hasHu: false,
|
||||||
|
},
|
||||||
|
gamePlayer: {
|
||||||
|
playerId,
|
||||||
|
seatIndex,
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
avatarURL: readString(player, 'avatar_url', 'AvatarUrl', 'avatar', 'avatarUrl') || undefined,
|
||||||
|
missingSuit,
|
||||||
|
isReady: ready,
|
||||||
|
isTrustee: false,
|
||||||
|
handTiles: [],
|
||||||
|
handCount: 0,
|
||||||
|
melds: [],
|
||||||
|
discardTiles: [],
|
||||||
|
hasHu: false,
|
||||||
|
score: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
gamePlayers.forEach((item, fallbackIndex) => {
|
||||||
|
const player = asRecord(item)
|
||||||
|
if (!player) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerId = readString(player, 'player_id', 'PlayerID')
|
||||||
|
if (!playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = playerMap.get(playerId)
|
||||||
|
const seatIndex =
|
||||||
|
existing?.gamePlayer.seatIndex ??
|
||||||
|
readNumber(player, 'index', 'Index', 'seat_index', 'seatIndex') ??
|
||||||
|
fallbackIndex
|
||||||
|
const displayName =
|
||||||
|
existing?.gamePlayer.displayName || (playerId === options.loggedInUserId ? options.loggedInUserName : '')
|
||||||
|
const missingSuit = readMissingSuitWithPresence(player)
|
||||||
|
const handCount = readNumber(player, 'hand_count', 'handCount') ?? 0
|
||||||
|
const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles)
|
||||||
|
const melds = normalizeMelds(player.melds ?? player.exposed_melds ?? player.exposedMelds ?? player.claims)
|
||||||
|
const hasHu = Boolean(player.has_hu ?? player.hasHu)
|
||||||
|
|
||||||
|
playerMap.set(playerId, {
|
||||||
|
roomPlayer: {
|
||||||
|
index: seatIndex,
|
||||||
|
playerId,
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
missingSuit: missingSuit.present ? missingSuit.value : (existing?.gamePlayer.missingSuit ?? null),
|
||||||
|
ready: existing?.roomPlayer.ready ?? false,
|
||||||
|
trustee: existing?.roomPlayer.trustee ?? false,
|
||||||
|
hand: Array.from({ length: handCount }, () => ''),
|
||||||
|
melds: melds.map((meld) => meld.type),
|
||||||
|
outTiles: outTiles.map((tile) => tileToText(tile)),
|
||||||
|
hasHu,
|
||||||
|
},
|
||||||
|
gamePlayer: {
|
||||||
|
playerId,
|
||||||
|
seatIndex,
|
||||||
|
displayName: displayName || undefined,
|
||||||
|
avatarURL: existing?.gamePlayer.avatarURL,
|
||||||
|
missingSuit: missingSuit.present ? missingSuit.value : (existing?.gamePlayer.missingSuit ?? null),
|
||||||
|
isReady: existing?.gamePlayer.isReady ?? false,
|
||||||
|
isTrustee: existing?.gamePlayer.isTrustee ?? false,
|
||||||
|
handTiles: existing?.gamePlayer.handTiles ?? [],
|
||||||
|
handCount,
|
||||||
|
melds: melds.length > 0 ? melds : existing?.gamePlayer.melds ?? [],
|
||||||
|
discardTiles: outTiles,
|
||||||
|
hasHu,
|
||||||
|
score: existing?.gamePlayer.score ?? 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const privateHandTiles = normalizeTiles(playerView?.hand)
|
||||||
|
const privateHand = privateHandTiles.map((tile) => tileToText(tile))
|
||||||
|
if (options.loggedInUserId && playerMap.has(options.loggedInUserId)) {
|
||||||
|
const current = playerMap.get(options.loggedInUserId)
|
||||||
|
if (current) {
|
||||||
|
const selfMissingSuit = readMissingSuitWithPresence(playerView)
|
||||||
|
current.roomPlayer.hand = privateHand
|
||||||
|
if (selfMissingSuit.present) {
|
||||||
|
current.roomPlayer.missingSuit = selfMissingSuit.value
|
||||||
|
}
|
||||||
|
current.gamePlayer.handTiles = privateHandTiles
|
||||||
|
current.gamePlayer.handCount = privateHandTiles.length
|
||||||
|
if (selfMissingSuit.present) {
|
||||||
|
current.gamePlayer.missingSuit = selfMissingSuit.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
room,
|
||||||
|
gameState,
|
||||||
|
playerView,
|
||||||
|
roomPlayers: Array.from(playerMap.values()).sort((a, b) => a.gamePlayer.seatIndex - b.gamePlayer.seatIndex),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRoomInfoSnapshot(
|
||||||
|
options: ParseRoomInfoSnapshotOptions,
|
||||||
|
): ParsedRoomInfoSnapshot | null {
|
||||||
|
const payload = asRecord(options.message.payload) ?? options.message
|
||||||
|
const { room, gameState, playerView, roomPlayers } = buildPlayerPairs(options, payload)
|
||||||
|
|
||||||
|
const roomId =
|
||||||
|
readString(room ?? {}, 'room_id', 'roomId') ||
|
||||||
|
readString(gameState ?? {}, 'room_id', 'roomId') ||
|
||||||
|
readString(playerView ?? {}, 'room_id', 'roomId') ||
|
||||||
|
readString(payload, 'room_id', 'roomId') ||
|
||||||
|
readString(options.message, 'roomId')
|
||||||
|
if (!roomId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPlayers: Record<string, PlayerState> = {}
|
||||||
|
roomPlayers.forEach(({ gamePlayer }) => {
|
||||||
|
const previous = options.previousPlayers[gamePlayer.playerId]
|
||||||
|
const score =
|
||||||
|
gameState?.scores && typeof gameState.scores === 'object'
|
||||||
|
? (gameState.scores as Record<string, unknown>)[gamePlayer.playerId]
|
||||||
|
: undefined
|
||||||
|
nextPlayers[gamePlayer.playerId] = {
|
||||||
|
playerId: gamePlayer.playerId,
|
||||||
|
seatIndex: gamePlayer.seatIndex,
|
||||||
|
displayName: gamePlayer.displayName ?? previous?.displayName,
|
||||||
|
avatarURL: gamePlayer.avatarURL ?? previous?.avatarURL,
|
||||||
|
missingSuit:
|
||||||
|
typeof gamePlayer.missingSuit === 'undefined' ? (previous?.missingSuit ?? null) : gamePlayer.missingSuit,
|
||||||
|
isTrustee: previous?.isTrustee ?? gamePlayer.isTrustee,
|
||||||
|
handTiles: gamePlayer.handTiles.length > 0 ? gamePlayer.handTiles : previous?.handTiles ?? [],
|
||||||
|
handCount:
|
||||||
|
gamePlayer.handCount > 0
|
||||||
|
? gamePlayer.handCount
|
||||||
|
: gamePlayer.handTiles.length > 0
|
||||||
|
? gamePlayer.handTiles.length
|
||||||
|
: (previous?.handCount ?? 0),
|
||||||
|
melds: gamePlayer.melds.length > 0 ? gamePlayer.melds : previous?.melds ?? [],
|
||||||
|
discardTiles: gamePlayer.discardTiles.length > 0 ? gamePlayer.discardTiles : previous?.discardTiles ?? [],
|
||||||
|
hasHu: gamePlayer.hasHu || previous?.hasHu || false,
|
||||||
|
score: typeof score === 'number' ? score : previous?.score ?? gamePlayer.score ?? 0,
|
||||||
|
isReady: gamePlayer.isReady,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const status =
|
||||||
|
readString(gameState ?? {}, 'status') ||
|
||||||
|
readString(room ?? {}, 'status') ||
|
||||||
|
readString(gameState ?? {}, 'phase') ||
|
||||||
|
'waiting'
|
||||||
|
const rawPendingClaim = asRecord(gameState?.pending_claim ?? gameState?.pendingClaim)
|
||||||
|
const hasPendingClaimWindow = Boolean(rawPendingClaim && Object.keys(rawPendingClaim).length > 0)
|
||||||
|
const phase =
|
||||||
|
hasPendingClaimWindow ? 'action' : readString(gameState ?? {}, 'phase') || readString(room ?? {}, 'status') || 'waiting'
|
||||||
|
const wallCount = readNumber(gameState ?? {}, 'wall_count', 'wallCount')
|
||||||
|
const dealerIndex = readNumber(gameState ?? {}, 'dealer_index', 'dealerIndex')
|
||||||
|
const currentTurnSeat = readNumber(gameState ?? {}, 'current_turn', 'currentTurn')
|
||||||
|
const currentTurnPlayerId = readString(gameState ?? {}, 'current_turn_player', 'currentTurnPlayer') || ''
|
||||||
|
const currentTurn =
|
||||||
|
currentTurnSeat ??
|
||||||
|
(currentTurnPlayerId && nextPlayers[currentTurnPlayerId] ? nextPlayers[currentTurnPlayerId].seatIndex : null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
room,
|
||||||
|
gameState,
|
||||||
|
playerView,
|
||||||
|
roomId,
|
||||||
|
roomPlayers,
|
||||||
|
nextPlayers,
|
||||||
|
status,
|
||||||
|
phase,
|
||||||
|
wallCount,
|
||||||
|
dealerIndex,
|
||||||
|
currentTurnPlayerId,
|
||||||
|
currentTurn,
|
||||||
|
needDraw: readBoolean(gameState ?? {}, 'need_draw', 'needDraw') ?? false,
|
||||||
|
pendingClaim: normalizePendingClaim(gameState, options.loggedInUserId),
|
||||||
|
scores: asRecord(gameState?.scores)
|
||||||
|
? (Object.fromEntries(
|
||||||
|
Object.entries(asRecord(gameState?.scores) ?? {}).filter(([, value]) => typeof value === 'number'),
|
||||||
|
) as Record<string, number>)
|
||||||
|
: undefined,
|
||||||
|
winners: readStringArray(gameState ?? {}, 'winners'),
|
||||||
|
currentRound: readNumber(gameState ?? {}, 'current_round', 'currentRound'),
|
||||||
|
totalRounds: readNumber(gameState ?? {}, 'total_rounds', 'totalRounds'),
|
||||||
|
settlementDeadlineMs: readNumber(gameState ?? {}, 'settlement_deadline_ms', 'settlementDeadlineMs'),
|
||||||
|
actionTimer: parseActionTimerSnapshot(gameState?.action_timer ?? gameState?.actionTimer),
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/views/chengdu/socket/parsers/roomStateSnapshot.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
asRecord,
|
||||||
|
normalizeMelds,
|
||||||
|
normalizePendingClaim,
|
||||||
|
normalizeTiles,
|
||||||
|
readBoolean,
|
||||||
|
readMissingSuitWithPresence,
|
||||||
|
readNumber,
|
||||||
|
readString,
|
||||||
|
readStringArray,
|
||||||
|
} from '../../../../game/chengdu/messageNormalizers'
|
||||||
|
import type { PendingClaimState, PlayerState } from '../../../../types/state'
|
||||||
|
import type { PlayerActionTimer } from '../../types'
|
||||||
|
import { parseActionTimerSnapshot } from './actionTimerSnapshot'
|
||||||
|
|
||||||
|
export interface ParsedRoomStateSnapshot {
|
||||||
|
roomId: string
|
||||||
|
nextPlayers: Record<string, PlayerState>
|
||||||
|
phase: string
|
||||||
|
wallCount: number | null
|
||||||
|
currentTurnPlayerId: string
|
||||||
|
currentTurn: number | null
|
||||||
|
needDraw: boolean
|
||||||
|
pendingClaim?: PendingClaimState
|
||||||
|
scores?: Record<string, number>
|
||||||
|
winners: string[]
|
||||||
|
currentRound: number | null
|
||||||
|
totalRounds: number | null
|
||||||
|
settlementDeadlineMs: number | null
|
||||||
|
actionTimer: PlayerActionTimer | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParseRoomStateSnapshotOptions {
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
roomId: string
|
||||||
|
loggedInUserId: string
|
||||||
|
previousPlayers: Record<string, PlayerState>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRoomStateSnapshot(
|
||||||
|
options: ParseRoomStateSnapshotOptions,
|
||||||
|
): ParsedRoomStateSnapshot {
|
||||||
|
const { payload, previousPlayers } = options
|
||||||
|
const nextPlayers: Record<string, PlayerState> = {}
|
||||||
|
const gamePlayers = Array.isArray(payload.players) ? payload.players : []
|
||||||
|
|
||||||
|
gamePlayers.forEach((item, fallbackIndex) => {
|
||||||
|
const player = asRecord(item)
|
||||||
|
if (!player) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerId = readString(player, 'player_id', 'PlayerID')
|
||||||
|
if (!playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = previousPlayers[playerId]
|
||||||
|
const seatIndex = previous?.seatIndex ?? fallbackIndex
|
||||||
|
const handCount = readNumber(player, 'hand_count', 'handCount') ?? previous?.handCount ?? 0
|
||||||
|
const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles)
|
||||||
|
const melds = normalizeMelds(player.melds ?? player.exposed_melds ?? player.exposedMelds ?? player.claims)
|
||||||
|
const hasHu = Boolean(player.has_hu ?? player.hasHu)
|
||||||
|
const dingQue = readMissingSuitWithPresence(player)
|
||||||
|
const scores = asRecord(payload.scores)
|
||||||
|
const score = scores?.[playerId]
|
||||||
|
|
||||||
|
nextPlayers[playerId] = {
|
||||||
|
playerId,
|
||||||
|
seatIndex,
|
||||||
|
displayName: previous?.displayName ?? playerId,
|
||||||
|
avatarURL: previous?.avatarURL,
|
||||||
|
isTrustee: previous?.isTrustee ?? false,
|
||||||
|
missingSuit: dingQue.present ? dingQue.value : (previous?.missingSuit ?? null),
|
||||||
|
handTiles: previous?.handTiles ?? [],
|
||||||
|
handCount,
|
||||||
|
melds,
|
||||||
|
discardTiles: outTiles,
|
||||||
|
hasHu,
|
||||||
|
score: typeof score === 'number' ? score : previous?.score ?? 0,
|
||||||
|
isReady: previous?.isReady ?? false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const rawPendingClaim = asRecord(payload.pending_claim ?? payload.pendingClaim)
|
||||||
|
const hasPendingClaimWindow = Boolean(rawPendingClaim && Object.keys(rawPendingClaim).length > 0)
|
||||||
|
const phase =
|
||||||
|
hasPendingClaimWindow ? 'action' : readString(payload, 'phase') || readString(payload, 'status') || 'waiting'
|
||||||
|
const wallCount = readNumber(payload, 'wall_count', 'wallCount')
|
||||||
|
const currentTurnSeat = readNumber(payload, 'current_turn', 'currentTurn')
|
||||||
|
const currentTurnPlayerId = readString(payload, 'current_turn_player', 'currentTurnPlayer') || ''
|
||||||
|
const currentTurn =
|
||||||
|
currentTurnSeat ??
|
||||||
|
(currentTurnPlayerId && nextPlayers[currentTurnPlayerId] ? nextPlayers[currentTurnPlayerId].seatIndex : null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
roomId: options.roomId,
|
||||||
|
nextPlayers,
|
||||||
|
phase,
|
||||||
|
wallCount,
|
||||||
|
currentTurnPlayerId,
|
||||||
|
currentTurn,
|
||||||
|
needDraw: readBoolean(payload, 'need_draw', 'needDraw') ?? false,
|
||||||
|
pendingClaim: normalizePendingClaim(payload, options.loggedInUserId),
|
||||||
|
scores: asRecord(payload.scores)
|
||||||
|
? (Object.fromEntries(
|
||||||
|
Object.entries(asRecord(payload.scores) ?? {}).filter(([, value]) => typeof value === 'number'),
|
||||||
|
) as Record<string, number>)
|
||||||
|
: undefined,
|
||||||
|
winners: readStringArray(payload, 'winners'),
|
||||||
|
currentRound: readNumber(payload, 'current_round', 'currentRound'),
|
||||||
|
totalRounds: readNumber(payload, 'total_rounds', 'totalRounds'),
|
||||||
|
settlementDeadlineMs: readNumber(payload, 'settlement_deadline_ms', 'settlementDeadlineMs'),
|
||||||
|
actionTimer: parseActionTimerSnapshot(payload.action_timer ?? payload.actionTimer),
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/views/chengdu/socket/parsers/socketEnvelope.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { asRecord, normalizeWsType } from '../../../../game/chengdu/messageNormalizers'
|
||||||
|
|
||||||
|
export interface SocketEnvelope {
|
||||||
|
raw: unknown
|
||||||
|
source: Record<string, unknown>
|
||||||
|
normalizedType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSocketEnvelope(message: unknown): SocketEnvelope | null {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: message,
|
||||||
|
source,
|
||||||
|
normalizedType: normalizeWsType(source.type),
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/views/chengdu/socket/room/roomSnapshotSync.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { clearRoomMetaSnapshotState, setRoomMetaSnapshot } from '../../../../store'
|
||||||
|
import { wsClient } from '../../../../ws/client'
|
||||||
|
import { readNumber, readString } from '../../../../game/chengdu/messageNormalizers'
|
||||||
|
import type { SocketHandlerContext } from '../types'
|
||||||
|
|
||||||
|
interface SyncRoomStateSnapshotOptions {
|
||||||
|
roomId: string
|
||||||
|
phase: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncRoomInfoSnapshotOptions {
|
||||||
|
roomId: string
|
||||||
|
room: Record<string, unknown> | null
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRoomAndRedirect(context: SocketHandlerContext): void {
|
||||||
|
clearRoomMetaSnapshotState()
|
||||||
|
context.gameStore.resetGame()
|
||||||
|
wsClient.close()
|
||||||
|
void context.router.push('/hall')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncActiveRoomFromRoomState(
|
||||||
|
context: SocketHandlerContext,
|
||||||
|
options: SyncRoomStateSnapshotOptions,
|
||||||
|
): void {
|
||||||
|
const previousRoom = context.roomMeta.value
|
||||||
|
|
||||||
|
setRoomMetaSnapshot({
|
||||||
|
roomId: options.roomId,
|
||||||
|
roomName: previousRoom?.roomName || context.roomName.value,
|
||||||
|
gameType: previousRoom?.gameType || 'chengdu',
|
||||||
|
ownerId: previousRoom?.ownerId || '',
|
||||||
|
maxPlayers: previousRoom?.maxPlayers ?? 4,
|
||||||
|
playerCount: previousRoom?.playerCount ?? Object.keys(context.gameStore.players).length,
|
||||||
|
status:
|
||||||
|
options.phase === 'settlement' ? 'finished' : options.phase === 'waiting' ? 'waiting' : 'playing',
|
||||||
|
createdAt: previousRoom?.createdAt || '',
|
||||||
|
updatedAt: previousRoom?.updatedAt || '',
|
||||||
|
players: previousRoom?.players ?? [],
|
||||||
|
myHand: previousRoom?.myHand ?? [],
|
||||||
|
game: previousRoom?.game,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncActiveRoomFromRoomInfo(
|
||||||
|
context: SocketHandlerContext,
|
||||||
|
options: SyncRoomInfoSnapshotOptions,
|
||||||
|
): void {
|
||||||
|
const previousRoom = context.roomMeta.value
|
||||||
|
setRoomMetaSnapshot({
|
||||||
|
roomId: options.roomId,
|
||||||
|
roomName:
|
||||||
|
readString(options.room ?? {}, 'name', 'room_name') ||
|
||||||
|
previousRoom?.roomName ||
|
||||||
|
context.roomName.value,
|
||||||
|
gameType: readString(options.room ?? {}, 'game_type') || previousRoom?.gameType || 'chengdu',
|
||||||
|
ownerId: readString(options.room ?? {}, 'owner_id') || previousRoom?.ownerId || '',
|
||||||
|
maxPlayers: readNumber(options.room ?? {}, 'max_players') ?? previousRoom?.maxPlayers ?? 4,
|
||||||
|
playerCount: readNumber(options.room ?? {}, 'player_count') ?? previousRoom?.playerCount ?? 0,
|
||||||
|
status: options.status,
|
||||||
|
createdAt: readString(options.room ?? {}, 'created_at') || previousRoom?.createdAt || '',
|
||||||
|
updatedAt: readString(options.room ?? {}, 'updated_at') || previousRoom?.updatedAt || '',
|
||||||
|
players: previousRoom?.players ?? [],
|
||||||
|
myHand: previousRoom?.myHand ?? [],
|
||||||
|
game: previousRoom?.game,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hydrateGameStoreFromActiveRoom(context: SocketHandlerContext, routeRoomId: string): void {
|
||||||
|
const room = context.roomMeta.value
|
||||||
|
if (!room) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRoomId = routeRoomId || room.roomId
|
||||||
|
if (!targetRoomId || room.roomId !== targetRoomId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.gameStore.roomId = room.roomId
|
||||||
|
}
|
||||||
40
src/views/chengdu/socket/router/socketMessageRouter.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { PlayerHandlerApi, RoomHandlerApi, StatusHandlerApi, TurnHandlerApi } from '../types'
|
||||||
|
|
||||||
|
interface SocketMessageRouterDeps {
|
||||||
|
roomHandlers: RoomHandlerApi
|
||||||
|
playerHandlers: PlayerHandlerApi
|
||||||
|
turnHandlers: TurnHandlerApi
|
||||||
|
statusHandlers: StatusHandlerApi
|
||||||
|
}
|
||||||
|
|
||||||
|
type SocketRouteHandler = (message: unknown) => void
|
||||||
|
|
||||||
|
export function createSocketMessageRouter(deps: SocketMessageRouterDeps) {
|
||||||
|
const routes = new Map<string, SocketRouteHandler[]>([
|
||||||
|
['GET_ROOM_INFO', [deps.roomHandlers.handleRoomInfoResponse]],
|
||||||
|
['ROOM_INFO', [deps.roomHandlers.handleRoomInfoResponse]],
|
||||||
|
['ROOM_STATE', [deps.roomHandlers.handleRoomStateResponse]],
|
||||||
|
['PLAYER_HAND', [deps.playerHandlers.handlePlayerHandResponse]],
|
||||||
|
['PLAYER_ALLOW_ACTION', [deps.turnHandlers.handlePlayerAllowAction]],
|
||||||
|
['PLAYER_TURN', [deps.turnHandlers.handlePlayerTurn]],
|
||||||
|
['NEXT_TURN', [deps.turnHandlers.handlePlayerTurn]],
|
||||||
|
['ACTION_ACK', [deps.statusHandlers.handleActionAck]],
|
||||||
|
['ACTION_ERROR', [deps.statusHandlers.handleActionError]],
|
||||||
|
['DING_QUE_COUNTDOWN', [deps.turnHandlers.handleDingQueCountdown]],
|
||||||
|
['PLAYER_READY', [deps.playerHandlers.handleReadyStateResponse]],
|
||||||
|
['PLAYER_DING_QUE', [deps.playerHandlers.handlePlayerDingQueResponse]],
|
||||||
|
])
|
||||||
|
|
||||||
|
function route(normalizedType: string, message: unknown): void {
|
||||||
|
const handlers = routes.get(normalizedType)
|
||||||
|
if (!handlers) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.forEach((handler) => handler(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
route,
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/views/chengdu/socket/session/gameActionEffects.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { GameAction, RoomPlayerUpdatePayload, RoomTrusteePayload } from '../../../../game/actions'
|
||||||
|
import { readPlayerTurnPlayerId } from '../../../../game/chengdu/messageNormalizers'
|
||||||
|
import {
|
||||||
|
clearClaimAndTurnPending,
|
||||||
|
clearReadyTogglePending,
|
||||||
|
clearRoomCountdown,
|
||||||
|
clearSelectedDiscard,
|
||||||
|
clearStartGamePending,
|
||||||
|
completeDiscard,
|
||||||
|
} from './sessionStateAdapter'
|
||||||
|
import type { SocketHandlerContext } from '../types'
|
||||||
|
|
||||||
|
export function applyGameActionSessionEffects(
|
||||||
|
context: SocketHandlerContext,
|
||||||
|
gameAction: GameAction,
|
||||||
|
onRoomPlayerUpdate: (payload: RoomPlayerUpdatePayload) => void,
|
||||||
|
onRoomTrustee: (payload: RoomTrusteePayload) => void,
|
||||||
|
): void {
|
||||||
|
if (gameAction.type === 'GAME_START') {
|
||||||
|
clearStartGamePending(context.session)
|
||||||
|
clearRoomCountdown(context.session)
|
||||||
|
}
|
||||||
|
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === context.session.loggedInUserId.value) {
|
||||||
|
completeDiscard(context.session)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
gameAction.type === 'PLAY_TILE' ||
|
||||||
|
gameAction.type === 'PENDING_CLAIM' ||
|
||||||
|
gameAction.type === 'CLAIM_RESOLVED'
|
||||||
|
) {
|
||||||
|
clearRoomCountdown(context.session)
|
||||||
|
}
|
||||||
|
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
|
||||||
|
onRoomPlayerUpdate(gameAction.payload)
|
||||||
|
clearReadyTogglePending(context.session)
|
||||||
|
}
|
||||||
|
if (gameAction.type === 'CLAIM_RESOLVED') {
|
||||||
|
clearClaimAndTurnPending(context.session)
|
||||||
|
}
|
||||||
|
if (gameAction.type === 'ROOM_TRUSTEE') {
|
||||||
|
onRoomTrustee(gameAction.payload)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
gameAction.type === 'PLAYER_TURN' &&
|
||||||
|
readPlayerTurnPlayerId(gameAction.payload as Record<string, unknown>) !== context.session.loggedInUserId.value
|
||||||
|
) {
|
||||||
|
clearSelectedDiscard(context.session)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/views/chengdu/socket/session/sessionStateAdapter.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { PlayerActionTimer } from '../../types'
|
||||||
|
import type { useChengduGameSession } from '../../composables/useChengduGameSession'
|
||||||
|
|
||||||
|
type ChengduGameSession = ReturnType<typeof useChengduGameSession>
|
||||||
|
|
||||||
|
export function pushWsMessage(session: ChengduGameSession, message: string): void {
|
||||||
|
session.wsMessages.value.push(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWsError(session: ChengduGameSession, message: string): void {
|
||||||
|
session.wsError.value = message
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncCurrentUserId(session: ChengduGameSession, userId: string): void {
|
||||||
|
session.syncCurrentUserID(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearClaimAndTurnPending(session: ChengduGameSession): void {
|
||||||
|
session.claimActionPending.value = false
|
||||||
|
session.clearTurnActionPending()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTurnPending(session: ChengduGameSession): void {
|
||||||
|
session.clearTurnActionPending()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearReadyTogglePending(session: ChengduGameSession): void {
|
||||||
|
session.readyTogglePending.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStartGamePending(session: ChengduGameSession): void {
|
||||||
|
session.startGamePending.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDingQuePending(session: ChengduGameSession): void {
|
||||||
|
session.dingQuePending.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearNextRoundPending(session: ChengduGameSession): void {
|
||||||
|
session.nextRoundPending.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSettlementOverlayState(session: ChengduGameSession): void {
|
||||||
|
session.nextRoundPending.value = false
|
||||||
|
session.settlementOverlayDismissed.value = false
|
||||||
|
session.settlementDeadlineMs.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSettlementDeadline(session: ChengduGameSession, deadlineMs: number | null): void {
|
||||||
|
session.settlementDeadlineMs.value = deadlineMs
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRoomCountdown(session: ChengduGameSession): void {
|
||||||
|
session.roomCountdown.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRoomCountdown(session: ChengduGameSession, countdown: PlayerActionTimer): void {
|
||||||
|
session.roomCountdown.value = countdown
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeDiscard(session: ChengduGameSession): void {
|
||||||
|
session.markDiscardCompleted()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSelectedDiscard(session: ChengduGameSession): void {
|
||||||
|
session.selectedDiscardTileId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSelfTurnAllowActions(session: ChengduGameSession, actions: string[]): void {
|
||||||
|
session.selfTurnAllowActions.value = actions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSelfTurnAllowActions(session: ChengduGameSession): void {
|
||||||
|
session.selfTurnAllowActions.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTrustMode(session: ChengduGameSession, enabled: boolean): void {
|
||||||
|
session.isTrustMode.value = enabled
|
||||||
|
}
|
||||||
109
src/views/chengdu/socket/store/gameStoreAdapter.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { PendingClaimState, PlayerState, Tile } from '../../../../types/state'
|
||||||
|
import type { ChengduSocketGameStore } from '../types'
|
||||||
|
|
||||||
|
interface ApplyRoomSnapshotOptions {
|
||||||
|
roomId: string
|
||||||
|
players?: Record<string, PlayerState>
|
||||||
|
phase: string
|
||||||
|
wallCount?: number | null
|
||||||
|
dealerIndex?: number | null
|
||||||
|
currentTurn?: number | null
|
||||||
|
needDraw: boolean
|
||||||
|
pendingClaim?: PendingClaimState
|
||||||
|
scores?: Record<string, number>
|
||||||
|
winners?: string[]
|
||||||
|
currentRound?: number | null
|
||||||
|
totalRounds?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseMap: Record<string, ChengduSocketGameStore['phase']> = {
|
||||||
|
waiting: 'waiting',
|
||||||
|
dealing: 'dealing',
|
||||||
|
ding_que: 'dealing',
|
||||||
|
playing: 'playing',
|
||||||
|
action: 'action',
|
||||||
|
settlement: 'settlement',
|
||||||
|
finished: 'settlement',
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePhase(phase: string, fallback: ChengduSocketGameStore['phase']): ChengduSocketGameStore['phase'] {
|
||||||
|
return phaseMap[phase] ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyRoomSnapshot(gameStore: ChengduSocketGameStore, options: ApplyRoomSnapshotOptions): void {
|
||||||
|
gameStore.roomId = options.roomId
|
||||||
|
if (options.players && Object.keys(options.players).length > 0) {
|
||||||
|
gameStore.players = options.players
|
||||||
|
}
|
||||||
|
|
||||||
|
gameStore.phase = normalizePhase(options.phase, gameStore.phase)
|
||||||
|
|
||||||
|
if (typeof options.wallCount === 'number') {
|
||||||
|
gameStore.remainingTiles = options.wallCount
|
||||||
|
}
|
||||||
|
if (typeof options.dealerIndex === 'number') {
|
||||||
|
gameStore.dealerIndex = options.dealerIndex
|
||||||
|
}
|
||||||
|
if (typeof options.currentTurn === 'number') {
|
||||||
|
gameStore.currentTurn = options.currentTurn
|
||||||
|
}
|
||||||
|
|
||||||
|
gameStore.needDraw = options.needDraw
|
||||||
|
gameStore.pendingClaim = options.pendingClaim
|
||||||
|
gameStore.scores = options.scores ?? {}
|
||||||
|
gameStore.winners = options.winners ?? []
|
||||||
|
|
||||||
|
if (typeof options.currentRound === 'number') {
|
||||||
|
gameStore.currentRound = options.currentRound
|
||||||
|
}
|
||||||
|
if (typeof options.totalRounds === 'number') {
|
||||||
|
gameStore.totalRounds = options.totalRounds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPlayerReadyState(gameStore: ChengduSocketGameStore, playerId: string, ready: boolean): void {
|
||||||
|
const player = gameStore.players[playerId]
|
||||||
|
if (player) {
|
||||||
|
player.isReady = ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPlayerHandState(
|
||||||
|
gameStore: ChengduSocketGameStore,
|
||||||
|
playerId: string,
|
||||||
|
handTiles: Tile[],
|
||||||
|
): void {
|
||||||
|
const player = gameStore.players[playerId]
|
||||||
|
if (player) {
|
||||||
|
player.handTiles = handTiles
|
||||||
|
player.handCount = handTiles.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPlayerMissingSuit(
|
||||||
|
gameStore: ChengduSocketGameStore,
|
||||||
|
playerId: string,
|
||||||
|
missingSuit: string | null,
|
||||||
|
): void {
|
||||||
|
const player = gameStore.players[playerId]
|
||||||
|
if (player) {
|
||||||
|
player.missingSuit = missingSuit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPlayerTrusteeState(gameStore: ChengduSocketGameStore, playerId: string, trustee: boolean): void {
|
||||||
|
const player = gameStore.players[playerId]
|
||||||
|
if (player) {
|
||||||
|
player.isTrustee = trustee
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetRoundResolutionState(gameStore: ChengduSocketGameStore): void {
|
||||||
|
gameStore.pendingClaim = undefined
|
||||||
|
gameStore.winners = []
|
||||||
|
|
||||||
|
Object.values(gameStore.players).forEach((player) => {
|
||||||
|
player.missingSuit = null
|
||||||
|
player.hasHu = false
|
||||||
|
})
|
||||||
|
}
|
||||||