feat(教学): 扩展教学建议链路以支持候选牌列表
扩展教学建议链路,在 PrivateTeachingMessage 中增加 candidates 字段,支持前端展示候选牌、评分和原因标签。同时优化前端组件结构,抽离共享类型和工具函数,为后续页面拆分做准备。 - 后端:在 GameSessionService 和 GameMessagePublisher 中透传候选牌列表 - 前端:新增 GameMessageStack 组件展示教学候选,优化手牌区推荐牌高亮 - 测试:补充 GameMessagePublisherTest 验证候选牌消息结构 - 文档:更新 DEVELOPMENT_PLAN 和 H5_GAME_PAGE_ARCHITECTURE 说明当前前端结构
This commit is contained in:
Binary file not shown.
@@ -4,13 +4,14 @@
|
|||||||
- 当前胡牌计分口径:`paymentScore = 1 << totalFan`。
|
- 当前胡牌计分口径:`paymentScore = 1 << totalFan`。
|
||||||
- 一炮多响:只对 `HU` 开放多赢家同窗裁决,`PENG / GANG` 仍单赢家。
|
- 一炮多响:只对 `HU` 开放多赢家同窗裁决,`PENG / GANG` 仍单赢家。
|
||||||
- 过水不胡:玩家在响应窗口里本可 `HU` 但选择 `PASS` 后,直到自己下一次真正摸牌前,不能再做响应胡;不影响碰、杠、自摸胡。
|
- 过水不胡:玩家在响应窗口里本可 `HU` 但选择 `PASS` 后,直到自己下一次真正摸牌前,不能再做响应胡;不影响碰、杠、自摸胡。
|
||||||
- 后端最小验证:`cd backend && mvn test` 通过,当前 53 个测试通过。
|
- 教学建议链路已扩展为:`recommendedAction`、`explanation`、`candidates`,其中 `candidates` 透传到私有 WebSocket 教学消息,前端已支持展示候选牌、评分和原因标签中文映射。
|
||||||
- 前端 H5 对局页已经完成两轮联调:
|
- 前端 H5 对局页已完成正式动作面板、响应动作面板、玩家视角切换、公共事件时间线、最近结算卡片、教学推荐高亮等联调。
|
||||||
- 已区分“当前回合动作”和“响应动作”两种动作面板。
|
- 2026-03-20 当日新增一轮前端结构收口:
|
||||||
- `DISCARD` 继续通过点击手牌执行,`GANG/HU` 等额外动作走统一面板。
|
- 新增 `docs/H5_GAME_PAGE_ARCHITECTURE.md`,完成 `S1-08` 页面信息架构与拆分方案。
|
||||||
- 响应动作面板会展示 `sourceSeatNo`、`triggerTile`、`triggerEventType`、`windowId`。
|
- 前端共享类型已抽到 `frontend/src/types/game.ts`。
|
||||||
- 已支持在对局页切换玩家视角,并自动刷新对应 `GameStateResponse` 与重连该玩家私有 WebSocket 主题。
|
- UI 标签映射与事件/结算格式化函数已抽到 `frontend/src/utils/gameUi.ts`。
|
||||||
- 公共事件接收已改为统一入口处理,会在 `RESPONSE_WINDOW_CLOSED`、`TURN_SWITCHED`、`TILE_DISCARDED`、`GAME_PHASE_CHANGED` 等场景下清理已失效的私有动作面板,避免旧窗口残留。
|
- 已拆出展示组件:`GameActionDock.vue`、`GameMessageStack.vue`、`PublicEventTimeline.vue`。
|
||||||
- 公共事件时间线已支持中文摘要文案、时间展示和原始载荷折叠查看,便于联调时同时看“业务含义”和“真实 payload”。
|
- `App.vue` 当前定位已经收敛为“页面容器 + 请求/订阅协调层”,便于下一轮继续拆 `RoomPage / GamePage / ReviewPage`。
|
||||||
- 注释约定继续有效:后端复杂规则、前端复杂交互和后续数据库脚本都要补适当偏多的中文注释。
|
- 2026-03-20 当日还修复了 `GameSessionServiceTest` 中两条“过水不胡”测试的脆弱构造,改为显式控制响应胡候选与后续安全弃牌,避免依赖随机初始牌与中间牌路。
|
||||||
- 前端验证:`cd frontend && npm run build` 通过。
|
- 当前验证状态:`cd backend && mvn test` 通过;`cd frontend && npm run build` 通过。
|
||||||
|
- 注释约定继续有效:后端复杂规则、前端复杂交互、后续数据库脚本和迁移都要补适当偏多的中文注释,尤其说明规则判断、状态切换、消息边界与字段语义。
|
||||||
@@ -301,7 +301,8 @@ public class GameSessionService {
|
|||||||
currentSeat.getPlayerId(),
|
currentSeat.getPlayerId(),
|
||||||
advice.teachingMode(),
|
advice.teachingMode(),
|
||||||
advice.recommendedAction(),
|
advice.recommendedAction(),
|
||||||
advice.explanation()
|
advice.explanation(),
|
||||||
|
advice.candidates()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package com.xuezhanmaster.ws.dto;
|
package com.xuezhanmaster.ws.dto;
|
||||||
|
|
||||||
|
import com.xuezhanmaster.teaching.dto.CandidateAdviceItem;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public record PrivateTeachingMessage(
|
public record PrivateTeachingMessage(
|
||||||
String gameId,
|
String gameId,
|
||||||
String userId,
|
String userId,
|
||||||
String teachingMode,
|
String teachingMode,
|
||||||
String recommendedAction,
|
String recommendedAction,
|
||||||
String explanation
|
String explanation,
|
||||||
|
List<CandidateAdviceItem> candidates
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.xuezhanmaster.ws.service;
|
|||||||
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
|
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
|
||||||
import com.xuezhanmaster.game.domain.ResponseActionWindow;
|
import com.xuezhanmaster.game.domain.ResponseActionWindow;
|
||||||
import com.xuezhanmaster.game.event.GameEvent;
|
import com.xuezhanmaster.game.event.GameEvent;
|
||||||
|
import com.xuezhanmaster.teaching.dto.CandidateAdviceItem;
|
||||||
import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
|
import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
|
||||||
import com.xuezhanmaster.ws.dto.PrivateActionMessage;
|
import com.xuezhanmaster.ws.dto.PrivateActionMessage;
|
||||||
import com.xuezhanmaster.ws.dto.PrivateTeachingMessage;
|
import com.xuezhanmaster.ws.dto.PrivateTeachingMessage;
|
||||||
@@ -97,10 +98,18 @@ public class GameMessagePublisher {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void publishPrivateTeaching(String gameId, String userId, String teachingMode, String recommendedAction, String explanation) {
|
public void publishPrivateTeaching(
|
||||||
|
String gameId,
|
||||||
|
String userId,
|
||||||
|
String teachingMode,
|
||||||
|
String recommendedAction,
|
||||||
|
String explanation,
|
||||||
|
List<CandidateAdviceItem> candidates
|
||||||
|
) {
|
||||||
messagingTemplate.convertAndSend(
|
messagingTemplate.convertAndSend(
|
||||||
"/topic/users/" + userId + "/teaching",
|
"/topic/users/" + userId + "/teaching",
|
||||||
new PrivateTeachingMessage(gameId, userId, teachingMode, recommendedAction, explanation)
|
// 教学消息需要同时携带推荐结果与备选列表,前端才能把“为什么是这张牌”解释完整。
|
||||||
|
new PrivateTeachingMessage(gameId, userId, teachingMode, recommendedAction, explanation, candidates)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -786,8 +786,9 @@ class GameSessionServiceTest {
|
|||||||
prepareWinningHand(winnerSeat);
|
prepareWinningHand(winnerSeat);
|
||||||
session.getTable().setCurrentSeatNo(2);
|
session.getTable().setCurrentSeatNo(2);
|
||||||
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(2), "9筒", 1);
|
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(2), "9筒", 1);
|
||||||
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(3), "9筒", 1);
|
// 这里只需要保证 seat 1 能对 seat 2 的 9筒形成“可胡后选择 PASS”的场景。
|
||||||
removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2, 3);
|
// 不再额外给 seat 3 补 9筒,避免它也意外成为同一响应窗里的第二个胡牌候选,导致测试依赖随机手牌分布。
|
||||||
|
removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2);
|
||||||
|
|
||||||
gameSessionService.performAction(
|
gameSessionService.performAction(
|
||||||
started.gameId(),
|
started.gameId(),
|
||||||
@@ -801,9 +802,12 @@ class GameSessionServiceTest {
|
|||||||
assertThat(winnerSeat.isPassedHuBlocked()).isTrue();
|
assertThat(winnerSeat.isPassedHuBlocked()).isTrue();
|
||||||
assertThat(afterPass.currentSeatNo()).isEqualTo(3);
|
assertThat(afterPass.currentSeatNo()).isEqualTo(3);
|
||||||
|
|
||||||
|
// 这里取 seat 3 当前手里的安全牌继续推进牌局,只验证“未摸到自己下一张牌前仍然不能响应胡”。
|
||||||
|
String seatThreeSafeDiscard = session.getTable().getSeats().get(3).getHandTiles().get(0).getDisplayName();
|
||||||
|
removeMatchingTilesFromOtherSeats(session, seatThreeSafeDiscard, 3);
|
||||||
GameStateResponse afterSecondDiscard = gameSessionService.performAction(
|
GameStateResponse afterSecondDiscard = gameSessionService.performAction(
|
||||||
started.gameId(),
|
started.gameId(),
|
||||||
new GameActionRequest("player-4", "DISCARD", "9筒", null)
|
new GameActionRequest("player-4", "DISCARD", seatThreeSafeDiscard, null)
|
||||||
);
|
);
|
||||||
|
|
||||||
assertThat(session.getPendingResponseActionWindow()).isNull();
|
assertThat(session.getPendingResponseActionWindow()).isNull();
|
||||||
@@ -863,12 +867,15 @@ class GameSessionServiceTest {
|
|||||||
|
|
||||||
assertThat(afterSeatZeroDiscard.currentSeatNo()).isEqualTo(1);
|
assertThat(afterSeatZeroDiscard.currentSeatNo()).isEqualTo(1);
|
||||||
assertThat(winnerSeat.isPassedHuBlocked()).isFalse();
|
assertThat(winnerSeat.isPassedHuBlocked()).isFalse();
|
||||||
removeMatchingTilesFromOtherSeats(session, "8万", 1);
|
// 解锁完成后,重新稳定构造一个“seat 2 打出 9筒,seat 1 可以响应胡”的窗口,
|
||||||
|
// 避免继续依赖中间多轮摸打与随机初始牌造成的测试波动。
|
||||||
|
prepareWinningHand(winnerSeat);
|
||||||
|
GameSeat sourceSeat = session.getTable().getSeats().get(2);
|
||||||
|
sourceSeat.getHandTiles().clear();
|
||||||
|
sourceSeat.receiveTile(parseTile("9筒"));
|
||||||
|
session.getTable().setCurrentSeatNo(2);
|
||||||
|
removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2);
|
||||||
|
|
||||||
gameSessionService.performAction(
|
|
||||||
started.gameId(),
|
|
||||||
new GameActionRequest("player-2", "DISCARD", "8万", null)
|
|
||||||
);
|
|
||||||
GameStateResponse afterHu = gameSessionService.performAction(
|
GameStateResponse afterHu = gameSessionService.performAction(
|
||||||
started.gameId(),
|
started.gameId(),
|
||||||
new GameActionRequest("player-3", "DISCARD", "9筒", null)
|
new GameActionRequest("player-3", "DISCARD", "9筒", null)
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import com.xuezhanmaster.game.domain.ActionType;
|
|||||||
import com.xuezhanmaster.game.domain.ResponseActionOption;
|
import com.xuezhanmaster.game.domain.ResponseActionOption;
|
||||||
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
|
import com.xuezhanmaster.game.domain.ResponseActionSeatCandidate;
|
||||||
import com.xuezhanmaster.game.domain.ResponseActionWindow;
|
import com.xuezhanmaster.game.domain.ResponseActionWindow;
|
||||||
|
import com.xuezhanmaster.teaching.dto.CandidateAdviceItem;
|
||||||
import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
|
import com.xuezhanmaster.ws.dto.PrivateActionCandidate;
|
||||||
import com.xuezhanmaster.ws.dto.PrivateActionMessage;
|
import com.xuezhanmaster.ws.dto.PrivateActionMessage;
|
||||||
|
import com.xuezhanmaster.ws.dto.PrivateTeachingMessage;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
@@ -84,4 +86,29 @@ class GameMessagePublisherTest {
|
|||||||
.extracting(candidate -> candidate.actionType())
|
.extracting(candidate -> candidate.actionType())
|
||||||
.containsExactly("PENG", "PASS");
|
.containsExactly("PENG", "PASS");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldPublishStructuredTeachingMessageWithCandidateList() {
|
||||||
|
gameMessagePublisher.publishPrivateTeaching(
|
||||||
|
"game-1",
|
||||||
|
"user-1",
|
||||||
|
"BRIEF",
|
||||||
|
"9筒",
|
||||||
|
"建议先打孤张。",
|
||||||
|
List.of(
|
||||||
|
new CandidateAdviceItem("9筒", 90, List.of("ISOLATED_TILE")),
|
||||||
|
new CandidateAdviceItem("1万", 70, List.of("EDGE_TILE"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
ArgumentCaptor<PrivateTeachingMessage> captor = ArgumentCaptor.forClass(PrivateTeachingMessage.class);
|
||||||
|
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.eq("/topic/users/user-1/teaching"), captor.capture());
|
||||||
|
|
||||||
|
PrivateTeachingMessage message = captor.getValue();
|
||||||
|
assertThat(message.teachingMode()).isEqualTo("BRIEF");
|
||||||
|
assertThat(message.recommendedAction()).isEqualTo("9筒");
|
||||||
|
assertThat(message.candidates()).hasSize(2);
|
||||||
|
assertThat(message.candidates().get(0).tile()).isEqualTo("9筒");
|
||||||
|
assertThat(message.candidates().get(0).reasonTags()).containsExactly("ISOLATED_TILE");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,12 +278,21 @@ ws WebSocket 配置与消息发布
|
|||||||
|
|
||||||
### 6.2 当前前端结构
|
### 6.2 当前前端结构
|
||||||
|
|
||||||
当前前端仍以单页面原型为主,但已经承担三类职责:
|
当前前端仍以 `App.vue` 作为页面容器,但已经从“单文件原型”开始向“可继续拆页的正式骨架”演进,当前承担三类职责:
|
||||||
|
|
||||||
- H5 房间流操作
|
- H5 房间流操作
|
||||||
- 对局状态展示
|
- 对局状态展示
|
||||||
- WebSocket 消息接收与展示
|
- WebSocket 消息接收与展示
|
||||||
|
|
||||||
|
同时,已完成第一轮最小拆分准备:
|
||||||
|
|
||||||
|
- 共享类型:`frontend/src/types/game.ts`
|
||||||
|
- UI 格式化工具:`frontend/src/utils/gameUi.ts`
|
||||||
|
- 展示组件:
|
||||||
|
- `frontend/src/components/GameActionDock.vue`
|
||||||
|
- `frontend/src/components/GameMessageStack.vue`
|
||||||
|
- `frontend/src/components/PublicEventTimeline.vue`
|
||||||
|
|
||||||
后续建议按页面拆分为:
|
后续建议按页面拆分为:
|
||||||
|
|
||||||
- `HomePage`
|
- `HomePage`
|
||||||
@@ -337,11 +346,15 @@ ws WebSocket 配置与消息发布
|
|||||||
- WebSocket 私有动作订阅
|
- WebSocket 私有动作订阅
|
||||||
- WebSocket 私有教学订阅
|
- WebSocket 私有教学订阅
|
||||||
- `/api` 与 `/ws` 代理配置
|
- `/api` 与 `/ws` 代理配置
|
||||||
|
- 正式动作面板与响应动作面板联调
|
||||||
|
- 公共事件时间线与最近结算卡片
|
||||||
|
- 教学推荐高亮与候选建议列表
|
||||||
|
- 对局页信息架构文档与最小组件拆分基础
|
||||||
|
|
||||||
### 7.3 当前进行中
|
### 7.3 当前进行中
|
||||||
|
|
||||||
- 动作系统从“定缺 + 出牌”扩展到真正的麻将动作系统
|
- 动作系统从“定缺 + 出牌”扩展到真正的麻将动作系统
|
||||||
- H5 原型页向正式对局页演进的规划和拆解
|
- H5 页面容器继续向 `RoomPage / GamePage / ReviewPage` 拆分
|
||||||
|
|
||||||
### 7.4 当前已完成的文档治理
|
### 7.4 当前已完成的文档治理
|
||||||
|
|
||||||
|
|||||||
321
docs/H5_GAME_PAGE_ARCHITECTURE.md
Normal file
321
docs/H5_GAME_PAGE_ARCHITECTURE.md
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
# H5 对局页信息架构与页面拆分方案
|
||||||
|
|
||||||
|
当前状态快照日期:`2026-03-20`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 文档目标
|
||||||
|
|
||||||
|
这份文档用于落实 `S1-08`,为 H5 原型页向正式页面演进提供可以直接执行的拆分输入。
|
||||||
|
|
||||||
|
它要解决的不是“最终视觉长什么样”,而是先把下面几件事固定下来:
|
||||||
|
|
||||||
|
- 页面边界怎么切
|
||||||
|
- 正式对局页内部区域怎么分
|
||||||
|
- 公共消息和私有消息分别落在哪个区
|
||||||
|
- 哪些状态应该留在页面容器,哪些应该沉到子组件
|
||||||
|
- 下一轮前端继续拆页时,如何避免再次把逻辑堆回 `App.vue`
|
||||||
|
|
||||||
|
这符合:
|
||||||
|
|
||||||
|
- `KISS`:先拆职责边界,不先追求完整视觉重写
|
||||||
|
- `YAGNI`:当前不引入路由级复杂壳层和状态库
|
||||||
|
- `SOLID`:页面容器、消息面板、动作面板、公共时间线分层明确
|
||||||
|
- `DRY`:共享类型与格式化函数抽离,避免多组件重复维护
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 当前现状判断
|
||||||
|
|
||||||
|
当前 `frontend/src/App.vue` 已经承担了四类职责:
|
||||||
|
|
||||||
|
1. 房间流操作
|
||||||
|
2. 对局页状态与动作提交
|
||||||
|
3. WebSocket 订阅与消息清理
|
||||||
|
4. 消息卡片、结算卡片、时间线等展示
|
||||||
|
|
||||||
|
这说明它已经不再是“临时原型脚本”,而是一个事实上的页面容器。
|
||||||
|
|
||||||
|
当前如果继续在 `App.vue` 里叠加:
|
||||||
|
|
||||||
|
- 断线重连提示
|
||||||
|
- 教学开关
|
||||||
|
- 复盘入口
|
||||||
|
- 页面级切换
|
||||||
|
- 更多正式规则结算展示
|
||||||
|
|
||||||
|
后续维护成本会快速上升。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 正式页面拆分建议
|
||||||
|
|
||||||
|
### 3.1 页面层级
|
||||||
|
|
||||||
|
建议前端逐步演进为以下页面:
|
||||||
|
|
||||||
|
- `HomePage`
|
||||||
|
- 负责产品入口、快速建房、邀请码输入、最近房间入口
|
||||||
|
- `RoomPage`
|
||||||
|
- 负责建房后、开局前的房间管理与玩家准备
|
||||||
|
- `GamePage`
|
||||||
|
- 负责正式对局中的牌桌、动作、教学、公共事件
|
||||||
|
- `ReviewPage`
|
||||||
|
- 负责局后个人复盘、关键失误与建议回看
|
||||||
|
|
||||||
|
当前阶段不要求一次性引入完整路由,但组件组织和文档命名要按这个终态准备。
|
||||||
|
|
||||||
|
### 3.2 当前这一轮的落地点
|
||||||
|
|
||||||
|
本轮先聚焦 `GamePage`。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 房间流已经基本够用
|
||||||
|
- 当前复杂度主要集中在对局页
|
||||||
|
- 动作消息、教学消息、结算时间线都已经真实接入
|
||||||
|
- 正式血战计分规则后续迭代也主要会继续压在对局页
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. GamePage 信息分区
|
||||||
|
|
||||||
|
### 4.1 顶部概览区
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 显示对局阶段
|
||||||
|
- 显示当前视角
|
||||||
|
- 显示剩余牌墙
|
||||||
|
- 显示当前系统提示
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 顶部概览区只做“当前局势总览”
|
||||||
|
- 不承载具体动作按钮
|
||||||
|
- 不放长文教学解释,避免遮挡主桌面
|
||||||
|
|
||||||
|
### 4.2 自己手牌与当前动作区
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 展示自己的手牌与副露
|
||||||
|
- 承接定缺、主动回合动作
|
||||||
|
- 处理“点击手牌出牌”的高频交互
|
||||||
|
- 在手牌区局部高亮教学推荐牌
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 出牌仍保留“点击手牌直接触发”
|
||||||
|
- 杠、自摸胡等低频动作通过统一动作面板承接
|
||||||
|
- 这是为了保留 H5 的高频单手操作效率
|
||||||
|
|
||||||
|
### 4.3 响应动作区
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 响应窗口出现时展示 `PENG / GANG / HU / PASS`
|
||||||
|
- 清晰显示来源座位、目标牌、触发事件类型
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 该区域与“当前回合动作区”共用一个动作容器
|
||||||
|
- 但视觉上必须区分当前回合与响应窗口
|
||||||
|
- 当前项目已经实现统一动作消息结构,因此前端只需按 `actionScope` 切换视图
|
||||||
|
|
||||||
|
### 4.4 私有教学区
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 展示推荐动作
|
||||||
|
- 展示解释文案
|
||||||
|
- 展示候选牌、评分、原因标签
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 私有教学区必须与公共区域显著分离
|
||||||
|
- 教学候选列表属于私有信息,不得混入公共事件流
|
||||||
|
- 移动端下建议紧贴动作区,但与牌桌保持卡片边界
|
||||||
|
|
||||||
|
### 4.5 公共桌面区
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 展示其他座位的公开信息
|
||||||
|
- 展示弃牌、副露、积分、是否已胡
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 公共桌面区不显示其他玩家私有教学
|
||||||
|
- 这是消息边界在 UI 层的直接体现
|
||||||
|
|
||||||
|
### 4.6 公共事件与最近结算区
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 展示公共事件时间线
|
||||||
|
- 展示最近一次结算卡片
|
||||||
|
- 展示最近结算对应的分数变化
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 公共事件是调试和正式观感都需要保留的区域
|
||||||
|
- 但在正式页面中,它应当是“次主区”,不是最上方首屏主区
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 组件拆分建议
|
||||||
|
|
||||||
|
### 5.1 页面容器组件
|
||||||
|
|
||||||
|
- `GamePageContainer`
|
||||||
|
- 负责请求、WebSocket、页面级状态聚合
|
||||||
|
- 负责“谁是当前视角用户”和“当前牌局状态”这两个页面主状态
|
||||||
|
|
||||||
|
当前阶段由 `App.vue` 暂代这个角色。
|
||||||
|
|
||||||
|
### 5.2 已建议先拆出的展示组件
|
||||||
|
|
||||||
|
本轮已经先按最小风险拆出三块:
|
||||||
|
|
||||||
|
- `GameActionDock.vue`
|
||||||
|
- 只负责主动动作 / 响应动作展示与按钮提交
|
||||||
|
- `GameMessageStack.vue`
|
||||||
|
- 只负责私有动作摘要卡与私有教学卡
|
||||||
|
- `PublicEventTimeline.vue`
|
||||||
|
- 只负责最近结算、分数变化与公共事件时间线
|
||||||
|
|
||||||
|
这样做的目的不是“拆得越碎越好”,而是先把最容易继续膨胀的三个展示区从页面容器中拔出来。
|
||||||
|
|
||||||
|
### 5.3 下一轮建议继续拆的组件
|
||||||
|
|
||||||
|
- `RoomControlPanel`
|
||||||
|
- 建房、入房、准备、开局入口
|
||||||
|
- `SelfHandPanel`
|
||||||
|
- 手牌、副露、推荐牌高亮
|
||||||
|
- `PublicSeatBoard`
|
||||||
|
- 其他玩家公开桌面
|
||||||
|
- `ViewSwitchPanel`
|
||||||
|
- 多玩家视角切换
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 状态归属建议
|
||||||
|
|
||||||
|
### 6.1 必须保留在页面容器的状态
|
||||||
|
|
||||||
|
- `room`
|
||||||
|
- `game`
|
||||||
|
- `currentUserId`
|
||||||
|
- `publicEvents`
|
||||||
|
- `privateAction`
|
||||||
|
- `privateTeaching`
|
||||||
|
- `wsStatus`
|
||||||
|
- `busy`
|
||||||
|
- `error`
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 它们同时被多个区域消费
|
||||||
|
- 其中 `privateAction` 与公共事件清理之间有联动
|
||||||
|
- `currentUserId` 变化会触发重拉状态与重连私有 WebSocket
|
||||||
|
|
||||||
|
### 6.2 可下沉为共享工具的内容
|
||||||
|
|
||||||
|
- 接口响应类型
|
||||||
|
- 私有/公共消息 DTO
|
||||||
|
- UI 标签映射
|
||||||
|
- 事件摘要格式化函数
|
||||||
|
- 结算详情解析函数
|
||||||
|
|
||||||
|
本轮已经落地为:
|
||||||
|
|
||||||
|
- `frontend/src/types/game.ts`
|
||||||
|
- `frontend/src/utils/gameUi.ts`
|
||||||
|
|
||||||
|
### 6.3 当前不建议引入的东西
|
||||||
|
|
||||||
|
- Pinia 等全局状态库
|
||||||
|
- 过早的页面级缓存层
|
||||||
|
- 复杂消息归档服务
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 当前页面仍是单条主链,复杂状态库会提升理解成本
|
||||||
|
- 现阶段优先保证规则联调和 H5 可操作性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 移动端布局建议
|
||||||
|
|
||||||
|
### 7.1 布局优先级
|
||||||
|
|
||||||
|
移动端从上到下建议为:
|
||||||
|
|
||||||
|
1. 顶部提示与对局摘要
|
||||||
|
2. 自己手牌区
|
||||||
|
3. 动作区
|
||||||
|
4. 私有教学区
|
||||||
|
5. 公共桌面区
|
||||||
|
6. 公共事件时间线
|
||||||
|
|
||||||
|
### 7.2 关键原因
|
||||||
|
|
||||||
|
- 手牌和动作区必须靠前,保证高频操作路径最短
|
||||||
|
- 私有教学紧跟动作区,减少视线切换
|
||||||
|
- 公共事件时间线放在靠后位置,避免压缩手牌点击区域
|
||||||
|
|
||||||
|
### 7.3 H5 交互要求回落
|
||||||
|
|
||||||
|
- 关键按钮保持大点击热区
|
||||||
|
- 不依赖 hover
|
||||||
|
- 响应窗口出现时动作按钮必须明显聚焦
|
||||||
|
- 私有教学不能遮住手牌
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 本轮已经落地的最小结构调整
|
||||||
|
|
||||||
|
为降低后续继续拆页的成本,本轮已经完成以下基础整理:
|
||||||
|
|
||||||
|
- 抽离共享类型文件,减少组件间类型重复
|
||||||
|
- 抽离 UI 格式化工具,减少多处重复的事件/结算渲染逻辑
|
||||||
|
- 抽离动作面板组件
|
||||||
|
- 抽离私有消息栈组件
|
||||||
|
- 抽离公共事件时间线组件
|
||||||
|
|
||||||
|
这一步仍然保持:
|
||||||
|
|
||||||
|
- `App.vue` 作为页面容器
|
||||||
|
- 行为逻辑不变
|
||||||
|
- WebSocket 订阅逻辑不变
|
||||||
|
- 动作提交链路不变
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 下一轮前端任务建议
|
||||||
|
|
||||||
|
建议按以下顺序继续:
|
||||||
|
|
||||||
|
1. 把 `App.vue` 演进为 `GamePageContainer`
|
||||||
|
2. 把房间流区域抽成 `RoomPage` 或 `RoomControlPanel`
|
||||||
|
3. 把手牌区和公共桌面区拆成独立组件
|
||||||
|
4. 再决定是否引入路由和页面级导航
|
||||||
|
|
||||||
|
如果下一轮要优先做体验而不是结构,建议优先加:
|
||||||
|
|
||||||
|
- 教学开关入口
|
||||||
|
- WebSocket 断线提示
|
||||||
|
- 对局结束后的复盘入口占位
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 验收结论
|
||||||
|
|
||||||
|
`S1-08` 完成的标准不是“页面完全重写”,而是:
|
||||||
|
|
||||||
|
- 正式对局页的职责边界已经明确
|
||||||
|
- 私有区、公共区、动作区层级已经固定
|
||||||
|
- 下一轮前端可以按文档和当前代码骨架继续拆,不必重新讨论页面结构
|
||||||
|
|
||||||
|
当前结论:已满足。
|
||||||
@@ -44,52 +44,7 @@ Sprint 目标:
|
|||||||
|
|
||||||
## 2. 待做
|
## 2. 待做
|
||||||
|
|
||||||
### S1-08 [H5] 对局页信息架构与页面拆分方案
|
当前无新增待做项。下一轮若继续前端方向,建议从 `GamePageContainer / RoomPage` 拆分继续推进。
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
当前 `App.vue` 是原型操作台,已经能跑主流程,但信息和状态都堆在单页里。后续如果不先定义页面结构,动作系统一扩展,前端会迅速失控。
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
输出 H5 正式对局页拆分方案,明确:
|
|
||||||
|
|
||||||
- 房间页、对局页、复盘页的职责
|
|
||||||
- 对局页内的信息分区
|
|
||||||
- 私有教学面板和动作面板层级
|
|
||||||
- 公共事件区与私有区边界
|
|
||||||
|
|
||||||
## 范围
|
|
||||||
|
|
||||||
- 页面职责定义
|
|
||||||
- 组件拆分建议
|
|
||||||
- 状态归属建议
|
|
||||||
- 移动端布局区块说明
|
|
||||||
|
|
||||||
## 非范围
|
|
||||||
|
|
||||||
- 不在本任务里实现完整 UI
|
|
||||||
- 不在本任务里处理所有视觉细节
|
|
||||||
|
|
||||||
## 依赖
|
|
||||||
|
|
||||||
- `S1-05`
|
|
||||||
|
|
||||||
## 产出物
|
|
||||||
|
|
||||||
- 页面拆分文档
|
|
||||||
- 信息架构草图说明
|
|
||||||
- 下一轮前端任务拆分建议
|
|
||||||
|
|
||||||
## 验收标准
|
|
||||||
|
|
||||||
- 下一轮前端改造可以按本文档直接开始
|
|
||||||
- 公共区、私有区、动作区职责清楚
|
|
||||||
|
|
||||||
## 验证方式
|
|
||||||
|
|
||||||
- 文档评审
|
|
||||||
- 与当前 H5 要求、周计划和阶段看板一致
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -293,6 +248,35 @@ Sprint 目标:
|
|||||||
- 当前定缺和出牌流程未被破坏
|
- 当前定缺和出牌流程未被破坏
|
||||||
- `npm run build` 已通过
|
- `npm run build` 已通过
|
||||||
|
|
||||||
|
### S1-08 [H5] 对局页信息架构与页面拆分方案
|
||||||
|
|
||||||
|
## 已完成内容
|
||||||
|
|
||||||
|
- 新增文档 `docs/H5_GAME_PAGE_ARCHITECTURE.md`
|
||||||
|
- 明确了 `HomePage / RoomPage / GamePage / ReviewPage` 的职责边界
|
||||||
|
- 明确了正式对局页内的六个信息分区:
|
||||||
|
- 顶部概览区
|
||||||
|
- 自己手牌与当前动作区
|
||||||
|
- 响应动作区
|
||||||
|
- 私有教学区
|
||||||
|
- 公共桌面区
|
||||||
|
- 公共事件与最近结算区
|
||||||
|
- 明确了页面容器与子组件的状态归属边界
|
||||||
|
- 前端已完成最小拆分准备:
|
||||||
|
- 共享类型抽到 `frontend/src/types/game.ts`
|
||||||
|
- UI 格式化工具抽到 `frontend/src/utils/gameUi.ts`
|
||||||
|
- 展示组件拆出:
|
||||||
|
- `GameActionDock.vue`
|
||||||
|
- `GameMessageStack.vue`
|
||||||
|
- `PublicEventTimeline.vue`
|
||||||
|
- 当前重构保持原有动作提交流程、WebSocket 订阅逻辑和 H5 操作路径不变
|
||||||
|
|
||||||
|
## 验收结果
|
||||||
|
|
||||||
|
- 下一轮前端可以按文档与当前骨架继续拆页
|
||||||
|
- 公共区、私有区、动作区职责已经固定
|
||||||
|
- `npm run build` 已通过
|
||||||
|
|
||||||
### S1-06 [研究] 响应优先级裁决规则澄清
|
### S1-06 [研究] 响应优先级裁决规则澄清
|
||||||
|
|
||||||
## 已完成内容
|
## 已完成内容
|
||||||
|
|||||||
@@ -1,105 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Client, type IFrame, type IMessage } from '@stomp/stompjs'
|
import { Client, type IFrame, type IMessage } from '@stomp/stompjs'
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import GameActionDock from './components/GameActionDock.vue'
|
||||||
type ApiResponse<T> = {
|
import GameMessageStack from './components/GameMessageStack.vue'
|
||||||
success: boolean
|
import PublicEventTimeline from './components/PublicEventTimeline.vue'
|
||||||
code: string
|
import type {
|
||||||
message: string
|
ApiResponse,
|
||||||
data: T
|
DiagnosticItem,
|
||||||
}
|
GameStateResponse,
|
||||||
|
PrivateActionMessage,
|
||||||
type RoomSeatView = {
|
PrivateTeachingMessage,
|
||||||
seatNo: number
|
PublicGameMessage,
|
||||||
participantType: string
|
RoomSummaryResponse,
|
||||||
displayName: string
|
ScoreChangeCardView,
|
||||||
botLevel: string | null
|
SettlementCardView,
|
||||||
readyStatus: string
|
ViewUserOption
|
||||||
teachingEnabled: boolean
|
} from './types/game'
|
||||||
}
|
import {
|
||||||
|
actionScopeLabelMap,
|
||||||
type RoomSummaryResponse = {
|
formatSettlementType,
|
||||||
roomId: string
|
readNumber,
|
||||||
inviteCode: string
|
readString,
|
||||||
status: string
|
phaseLabelMap,
|
||||||
allowBotFill: boolean
|
toActionLabel,
|
||||||
seats: RoomSeatView[]
|
toSettlementDetailView,
|
||||||
}
|
triggerEventTypeLabelMap
|
||||||
|
} from './utils/gameUi'
|
||||||
type SelfSeatView = {
|
|
||||||
seatNo: number
|
|
||||||
playerId: string
|
|
||||||
nickname: string
|
|
||||||
won: boolean
|
|
||||||
lackSuit: string | null
|
|
||||||
score: number
|
|
||||||
handTiles: string[]
|
|
||||||
discardTiles: string[]
|
|
||||||
melds: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type PublicSeatView = {
|
|
||||||
seatNo: number
|
|
||||||
playerId: string
|
|
||||||
nickname: string
|
|
||||||
ai: boolean
|
|
||||||
won: boolean
|
|
||||||
lackSuit: string | null
|
|
||||||
score: number
|
|
||||||
handCount: number
|
|
||||||
discardTiles: string[]
|
|
||||||
melds: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameStateResponse = {
|
|
||||||
gameId: string
|
|
||||||
phase: string
|
|
||||||
dealerSeatNo: number
|
|
||||||
currentSeatNo: number
|
|
||||||
remainingWallCount: number
|
|
||||||
selfSeat: SelfSeatView
|
|
||||||
seats: PublicSeatView[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type PublicGameMessage = {
|
|
||||||
gameId: string
|
|
||||||
eventType: string
|
|
||||||
seatNo: number | null
|
|
||||||
payload: Record<string, unknown>
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type PrivateActionCandidate = {
|
|
||||||
actionType: string
|
|
||||||
tile: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type PrivateActionMessage = {
|
|
||||||
gameId: string
|
|
||||||
userId: string
|
|
||||||
actionScope: string
|
|
||||||
availableActions: string[]
|
|
||||||
currentSeatNo: number
|
|
||||||
windowId: string | null
|
|
||||||
triggerEventType: string | null
|
|
||||||
sourceSeatNo: number | null
|
|
||||||
triggerTile: string | null
|
|
||||||
candidates: PrivateActionCandidate[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type PrivateTeachingMessage = {
|
|
||||||
gameId: string
|
|
||||||
userId: string
|
|
||||||
teachingMode: string
|
|
||||||
recommendedAction: string
|
|
||||||
explanation: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ViewUserOption = {
|
|
||||||
userId: string
|
|
||||||
label: string
|
|
||||||
seatNo: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -122,51 +48,6 @@ const privateAction = ref<PrivateActionMessage | null>(null)
|
|||||||
const privateTeaching = ref<PrivateTeachingMessage | null>(null)
|
const privateTeaching = ref<PrivateTeachingMessage | null>(null)
|
||||||
let stompClient: Client | null = null
|
let stompClient: Client | null = null
|
||||||
|
|
||||||
const phaseLabelMap: Record<string, string> = {
|
|
||||||
WAITING: '等待中',
|
|
||||||
READY: '全部就绪',
|
|
||||||
PLAYING: '对局中',
|
|
||||||
FINISHED: '已结束',
|
|
||||||
LACK_SELECTION: '定缺阶段'
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionScopeLabelMap: Record<string, string> = {
|
|
||||||
TURN: '当前回合动作',
|
|
||||||
RESPONSE: '响应候选动作'
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionTypeLabelMap: Record<string, string> = {
|
|
||||||
SELECT_LACK_SUIT: '定缺',
|
|
||||||
DISCARD: '出牌',
|
|
||||||
PENG: '碰',
|
|
||||||
GANG: '杠',
|
|
||||||
HU: '胡',
|
|
||||||
PASS: '过'
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerEventTypeLabelMap: Record<string, string> = {
|
|
||||||
TILE_DISCARDED: '弃牌后响应',
|
|
||||||
SUPPLEMENTAL_GANG_DECLARED: '补杠后抢杠胡'
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicEventLabelMap: Record<string, string> = {
|
|
||||||
GAME_STARTED: '对局开始',
|
|
||||||
LACK_SELECTED: '定缺完成',
|
|
||||||
GAME_PHASE_CHANGED: '阶段切换',
|
|
||||||
TILE_DISCARDED: '出牌',
|
|
||||||
TILE_DRAWN: '摸牌',
|
|
||||||
TURN_SWITCHED: '轮转',
|
|
||||||
RESPONSE_WINDOW_OPENED: '响应窗口开启',
|
|
||||||
RESPONSE_WINDOW_CLOSED: '响应窗口关闭',
|
|
||||||
PENG_DECLARED: '碰牌宣告',
|
|
||||||
GANG_DECLARED: '杠牌宣告',
|
|
||||||
HU_DECLARED: '胡牌宣告',
|
|
||||||
PASS_DECLARED: '过牌宣告',
|
|
||||||
SETTLEMENT_APPLIED: '结算应用',
|
|
||||||
SCORE_CHANGED: '分数变化',
|
|
||||||
ACTION_REQUIRED: '动作提醒'
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION')
|
const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION')
|
||||||
const canDiscard = computed(
|
const canDiscard = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -178,8 +59,6 @@ const canDiscard = computed(
|
|||||||
|
|
||||||
const publicSeats = computed(() => game.value?.seats ?? [])
|
const publicSeats = computed(() => game.value?.seats ?? [])
|
||||||
|
|
||||||
const privateActionCandidates = computed(() => privateAction.value?.candidates ?? [])
|
|
||||||
|
|
||||||
const privateActionSummary = computed(() => {
|
const privateActionSummary = computed(() => {
|
||||||
if (!privateAction.value) {
|
if (!privateAction.value) {
|
||||||
return ''
|
return ''
|
||||||
@@ -227,6 +106,29 @@ const actionPanelHint = computed(() => {
|
|||||||
return `${responseContextSummary.value}。请在当前响应窗口关闭前完成选择。`
|
return `${responseContextSummary.value}。请在当前响应窗口关闭前完成选择。`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const recommendedDiscardTile = computed(() => {
|
||||||
|
// 当前教学服务只对出牌建议生效,因此 recommendedAction 在现阶段可直接视为“建议打出的牌名”。
|
||||||
|
if (!privateTeaching.value?.recommendedAction) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!game.value?.selfSeat.handTiles.includes(privateTeaching.value.recommendedAction)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return privateTeaching.value.recommendedAction
|
||||||
|
})
|
||||||
|
|
||||||
|
const teachingHint = computed(() => {
|
||||||
|
if (!privateTeaching.value) {
|
||||||
|
return '尚未收到当前视角的教学建议。'
|
||||||
|
}
|
||||||
|
if (recommendedDiscardTile.value) {
|
||||||
|
return `教学建议优先打出 ${recommendedDiscardTile.value}。`
|
||||||
|
}
|
||||||
|
return privateTeaching.value.explanation
|
||||||
|
})
|
||||||
|
|
||||||
|
const teachingCandidates = computed(() => privateTeaching.value?.candidates ?? [])
|
||||||
|
|
||||||
const currentViewLabel = computed(() => {
|
const currentViewLabel = computed(() => {
|
||||||
if (!game.value) {
|
if (!game.value) {
|
||||||
return currentUserId.value
|
return currentUserId.value
|
||||||
@@ -258,7 +160,7 @@ const viewUserOptions = computed<ViewUserOption[]>(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
const actionDiagnosticItems = computed(() => {
|
const actionDiagnosticItems = computed<DiagnosticItem[]>(() => {
|
||||||
if (!privateAction.value) {
|
if (!privateAction.value) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -288,6 +190,41 @@ const actionDiagnosticItems = computed(() => {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const latestSettlementCard = computed<SettlementCardView | null>(() => {
|
||||||
|
const settlementEvent = publicEvents.value.find((event) => event.eventType === 'SETTLEMENT_APPLIED')
|
||||||
|
if (!settlementEvent) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
settlementType: formatSettlementType(readString(settlementEvent.payload.settlementType)),
|
||||||
|
actionType: toActionLabel(readString(settlementEvent.payload.actionType) ?? '-'),
|
||||||
|
actorSeatNo: readNumber(settlementEvent.payload.actorSeatNo),
|
||||||
|
sourceSeatNo: readNumber(settlementEvent.payload.sourceSeatNo),
|
||||||
|
triggerTile: readString(settlementEvent.payload.triggerTile),
|
||||||
|
detail: toSettlementDetailView(settlementEvent.payload.settlementDetail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestSettlementScoreChanges = computed<ScoreChangeCardView[]>(() => {
|
||||||
|
const settlementEvent = publicEvents.value.find((event) => event.eventType === 'SETTLEMENT_APPLIED')
|
||||||
|
if (!settlementEvent) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestSettlementType = readString(settlementEvent.payload.settlementType)
|
||||||
|
return publicEvents.value
|
||||||
|
.filter((event) => event.eventType === 'SCORE_CHANGED')
|
||||||
|
.filter((event) => readString(event.payload.settlementType) === latestSettlementType)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((event) => ({
|
||||||
|
seatNo: event.seatNo ?? -1,
|
||||||
|
delta: readNumber(event.payload.delta) ?? 0,
|
||||||
|
score: readNumber(event.payload.score),
|
||||||
|
settlementType: readString(event.payload.settlementType)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
|
async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -546,18 +483,6 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
|||||||
return submitAction(actionType, tile ?? undefined, sourceSeatNo)
|
return submitAction(actionType, tile ?? undefined, sourceSeatNo)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toActionLabel(actionType: string) {
|
|
||||||
return actionTypeLabelMap[actionType] ?? actionType
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCandidateLabel(candidate: PrivateActionCandidate) {
|
|
||||||
const actionLabel = toActionLabel(candidate.actionType)
|
|
||||||
if (!candidate.tile) {
|
|
||||||
return actionLabel
|
|
||||||
}
|
|
||||||
return `${actionLabel} · ${candidate.tile}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePublicEvent(event: PublicGameMessage) {
|
function handlePublicEvent(event: PublicGameMessage) {
|
||||||
// 公共事件除了进入时间线,也负责驱动前端把已经失效的私有动作面板收起来。
|
// 公共事件除了进入时间线,也负责驱动前端把已经失效的私有动作面板收起来。
|
||||||
if (shouldClearPrivateActionByEvent(event)) {
|
if (shouldClearPrivateActionByEvent(event)) {
|
||||||
@@ -594,104 +519,6 @@ function shouldClearPrivateActionByEvent(event: PublicGameMessage) {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function readString(value: unknown) {
|
|
||||||
return typeof value === 'string' ? value : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function readNumber(value: unknown) {
|
|
||||||
return typeof value === 'number' ? value : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSeatLabel(seatNo: number | null | undefined) {
|
|
||||||
if (seatNo === null || seatNo === undefined) {
|
|
||||||
return '未知座位'
|
|
||||||
}
|
|
||||||
return `座位 ${seatNo}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPhaseLabel(phase: string | null) {
|
|
||||||
if (!phase) {
|
|
||||||
return '未知阶段'
|
|
||||||
}
|
|
||||||
return phaseLabelMap[phase] ?? phase
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPublicEventTitle(event: PublicGameMessage) {
|
|
||||||
return publicEventLabelMap[event.eventType] ?? event.eventType
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPublicEventSummary(event: PublicGameMessage) {
|
|
||||||
switch (event.eventType) {
|
|
||||||
case 'GAME_STARTED':
|
|
||||||
return `房间 ${readString(event.payload.roomId) ?? '-'} 已开局。`
|
|
||||||
case 'LACK_SELECTED':
|
|
||||||
return `${formatSeatLabel(event.seatNo)} 已完成定缺 ${readString(event.payload.lackSuit) ?? '-' }。`
|
|
||||||
case 'GAME_PHASE_CHANGED':
|
|
||||||
return `阶段切换为 ${formatPhaseLabel(readString(event.payload.phase))}。`
|
|
||||||
case 'TILE_DISCARDED':
|
|
||||||
return `${formatSeatLabel(event.seatNo)} 打出 ${readString(event.payload.tile) ?? '-'}。`
|
|
||||||
case 'TILE_DRAWN':
|
|
||||||
return `${formatSeatLabel(event.seatNo)} 完成摸牌,牌墙剩余 ${readNumber(event.payload.remainingWallCount) ?? '-'} 张。`
|
|
||||||
case 'TURN_SWITCHED':
|
|
||||||
return `当前轮到 ${formatSeatLabel(readNumber(event.payload.currentSeatNo) ?? event.seatNo)}。`
|
|
||||||
case 'RESPONSE_WINDOW_OPENED':
|
|
||||||
return `因 ${formatSeatLabel(readNumber(event.payload.sourceSeatNo))} 的 ${readString(event.payload.tile) ?? '-'} 打开响应窗口。`
|
|
||||||
case 'RESPONSE_WINDOW_CLOSED':
|
|
||||||
return `响应窗口已关闭,最终裁决 ${toActionLabel(readString(event.payload.resolvedActionType) ?? '-')}。`
|
|
||||||
case 'PENG_DECLARED':
|
|
||||||
case 'GANG_DECLARED':
|
|
||||||
case 'HU_DECLARED':
|
|
||||||
case 'PASS_DECLARED':
|
|
||||||
return `${formatSeatLabel(event.seatNo)} 执行 ${toActionLabel(readString(event.payload.actionType) ?? event.eventType.replace('_DECLARED', ''))}${readString(event.payload.tile) ? ` · ${readString(event.payload.tile)}` : ''}。`
|
|
||||||
case 'SETTLEMENT_APPLIED':
|
|
||||||
return formatSettlementSummary(event)
|
|
||||||
case 'SCORE_CHANGED':
|
|
||||||
return `${formatSeatLabel(event.seatNo)} 分数变化 ${formatScoreDelta(readNumber(event.payload.delta))},当前 ${readNumber(event.payload.score) ?? '-'}。`
|
|
||||||
default:
|
|
||||||
return JSON.stringify(event.payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSettlementSummary(event: PublicGameMessage) {
|
|
||||||
const settlementType = readString(event.payload.settlementType) ?? '未知结算'
|
|
||||||
const triggerTile = readString(event.payload.triggerTile)
|
|
||||||
const detail = asRecord(event.payload.settlementDetail)
|
|
||||||
const paymentScore = readNumber(detail?.paymentScore)
|
|
||||||
const totalFan = readNumber(detail?.totalFan)
|
|
||||||
return `${settlementType}${triggerTile ? ` · ${triggerTile}` : ''},${paymentScore ?? '-'} 分,${totalFan ?? 0} 番。`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatScoreDelta(delta: number | null) {
|
|
||||||
if (delta === null) {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
return delta > 0 ? `+${delta}` : `${delta}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRecord(value: unknown) {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return value as Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatEventPayload(event: PublicGameMessage) {
|
|
||||||
return JSON.stringify(event.payload, null, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatEventTime(createdAt: string) {
|
|
||||||
const date = new Date(createdAt)
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return createdAt
|
|
||||||
}
|
|
||||||
return date.toLocaleTimeString('zh-CN', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -873,15 +700,22 @@ function formatEventTime(createdAt: string) {
|
|||||||
<span class="mini-tag">{{ game.selfSeat.won ? '已胡' : '未胡' }}</span>
|
<span class="mini-tag">{{ game.selfSeat.won ? '已胡' : '未胡' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="recommendedDiscardTile" class="teaching-strip">
|
||||||
|
<span class="teaching-kicker">教学建议</span>
|
||||||
|
<strong>建议先打 {{ recommendedDiscardTile }}</strong>
|
||||||
|
<span class="message-copy">{{ privateTeaching?.explanation }}</span>
|
||||||
|
</div>
|
||||||
<div class="tile-grid">
|
<div class="tile-grid">
|
||||||
<button
|
<button
|
||||||
v-for="(tile, index) in game.selfSeat.handTiles"
|
v-for="(tile, index) in game.selfSeat.handTiles"
|
||||||
:key="`${tile}-${index}`"
|
:key="`${tile}-${index}`"
|
||||||
class="tile-chip"
|
class="tile-chip"
|
||||||
|
:class="{ recommended: tile === recommendedDiscardTile }"
|
||||||
:disabled="!canDiscard"
|
:disabled="!canDiscard"
|
||||||
@click="discard(tile)"
|
@click="discard(tile)"
|
||||||
>
|
>
|
||||||
{{ tile }}
|
{{ tile }}
|
||||||
|
<span v-if="tile === recommendedDiscardTile" class="tile-tip">荐</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="discard-row" v-if="game.selfSeat.melds.length > 0">
|
<div class="discard-row" v-if="game.selfSeat.melds.length > 0">
|
||||||
@@ -895,62 +729,16 @@ function formatEventTime(createdAt: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-dock">
|
<GameActionDock
|
||||||
<div class="section-title">
|
:private-action="privateAction"
|
||||||
<strong>动作面板</strong>
|
:private-action-summary="privateActionSummary"
|
||||||
<span class="mini-pill">{{ privateActionSummary || '等待动作' }}</span>
|
:action-panel-hint="actionPanelHint"
|
||||||
</div>
|
:turn-action-candidates="turnActionCandidates"
|
||||||
|
:response-action-candidates="responseActionCandidates"
|
||||||
<div v-if="privateAction" class="action-panel" :class="{ 'response-panel': privateAction.actionScope === 'RESPONSE' }">
|
:recommended-discard-tile="recommendedDiscardTile"
|
||||||
<div class="mini-tags action-meta-row">
|
:response-context-summary="responseContextSummary"
|
||||||
<span class="mini-tag">当前操作座位 {{ privateAction.currentSeatNo }}</span>
|
@submit="submitCandidateAction"
|
||||||
<span class="mini-tag">{{ actionScopeLabelMap[privateAction.actionScope] ?? privateAction.actionScope }}</span>
|
/>
|
||||||
<span v-if="privateAction.windowId" class="mini-tag">窗口 {{ privateAction.windowId.slice(0, 8) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="privateAction.actionScope === 'TURN'" class="turn-panel">
|
|
||||||
<p class="message-copy">{{ actionPanelHint }}</p>
|
|
||||||
<div class="candidate-list" v-if="turnActionCandidates.length > 0">
|
|
||||||
<button
|
|
||||||
v-for="(candidate, index) in turnActionCandidates"
|
|
||||||
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
|
|
||||||
class="candidate-chip action-call-chip"
|
|
||||||
type="button"
|
|
||||||
@click="submitCandidateAction(candidate.actionType, candidate.tile)"
|
|
||||||
>
|
|
||||||
{{ formatCandidateLabel(candidate) }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="helper-strip">
|
|
||||||
<span class="helper-chip">出牌:直接点击上方手牌</span>
|
|
||||||
<span class="helper-chip">杠 / 自摸胡:点击这里的动作按钮</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="response-panel-body">
|
|
||||||
<div class="response-banner">
|
|
||||||
<span class="response-kicker">响应窗口</span>
|
|
||||||
<strong>{{ responseContextSummary }}</strong>
|
|
||||||
<span class="message-copy">{{ actionPanelHint }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="candidate-list" v-if="responseActionCandidates.length > 0">
|
|
||||||
<button
|
|
||||||
v-for="(candidate, index) in responseActionCandidates"
|
|
||||||
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
|
|
||||||
class="candidate-chip action-call-chip"
|
|
||||||
type="button"
|
|
||||||
@click="submitCandidateAction(candidate.actionType, candidate.tile)"
|
|
||||||
>
|
|
||||||
{{ formatCandidateLabel(candidate) }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="placeholder-card action-placeholder">
|
|
||||||
当前还没有可执行动作。若轮到你出牌、可自摸胡、可杠,或遇到碰杠胡响应窗口,这里会自动出现对应按钮。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel board-panel">
|
<article class="panel board-panel">
|
||||||
@@ -962,29 +750,16 @@ function formatEventTime(createdAt: string) {
|
|||||||
<span class="status-pill">{{ wsStatus }}</span>
|
<span class="status-pill">{{ wsStatus }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="message-stack">
|
<GameMessageStack
|
||||||
<div class="message-card">
|
:private-action="privateAction"
|
||||||
<span class="meta-label">私有动作消息</span>
|
:private-action-summary="privateActionSummary"
|
||||||
<template v-if="privateAction">
|
:action-diagnostic-items="actionDiagnosticItems"
|
||||||
<strong>{{ privateActionSummary }}</strong>
|
:action-panel-hint="actionPanelHint"
|
||||||
<div class="message-grid">
|
:private-teaching="privateTeaching"
|
||||||
<div v-for="item in actionDiagnosticItems" :key="item.key" class="metric-cell">
|
:recommended-discard-tile="recommendedDiscardTile"
|
||||||
<span class="meta-label">{{ item.label }}</span>
|
:teaching-hint="teachingHint"
|
||||||
<strong>{{ item.value }}</strong>
|
:teaching-candidates="teachingCandidates"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<span class="message-copy">{{ actionPanelHint }}</span>
|
|
||||||
</template>
|
|
||||||
<span v-else class="empty-copy">尚未收到私有动作消息</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="message-card">
|
|
||||||
<span class="meta-label">私有教学消息</span>
|
|
||||||
<strong v-if="privateTeaching">{{ privateTeaching.recommendedAction }}</strong>
|
|
||||||
<span v-if="privateTeaching" class="message-copy">{{ privateTeaching.explanation }}</span>
|
|
||||||
<span v-else class="empty-copy">尚未收到私有教学消息</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="seat-list">
|
<div class="seat-list">
|
||||||
<article v-for="seat in publicSeats" :key="seat.seatNo" class="seat-card seat-card-wide">
|
<article v-for="seat in publicSeats" :key="seat.seatNo" class="seat-card seat-card-wide">
|
||||||
@@ -1014,30 +789,11 @@ function formatEventTime(createdAt: string) {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="event-timeline">
|
<PublicEventTimeline
|
||||||
<div class="section-title">
|
:public-events="publicEvents"
|
||||||
<strong>公共事件</strong>
|
:latest-settlement-card="latestSettlementCard"
|
||||||
<span class="mini-pill">{{ publicEvents.length }} 条</span>
|
:latest-settlement-score-changes="latestSettlementScoreChanges"
|
||||||
</div>
|
/>
|
||||||
<div v-if="publicEvents.length === 0" class="placeholder-card">还没有收到公共事件,开局或执行动作后会自动出现。</div>
|
|
||||||
<div v-else class="timeline-list">
|
|
||||||
<article v-for="(event, index) in publicEvents" :key="`${event.createdAt}-${index}`" class="timeline-item">
|
|
||||||
<div class="seat-top">
|
|
||||||
<strong>{{ formatPublicEventTitle(event) }}</strong>
|
|
||||||
<span class="mini-pill">{{ formatEventTime(event.createdAt) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="timeline-meta">
|
|
||||||
<span class="mini-tag">{{ formatSeatLabel(event.seatNo) }}</span>
|
|
||||||
<span class="mini-tag">{{ event.eventType }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="message-copy">{{ formatPublicEventSummary(event) }}</div>
|
|
||||||
<details class="payload-details">
|
|
||||||
<summary>查看原始载荷</summary>
|
|
||||||
<pre class="payload-code">{{ formatEventPayload(event) }}</pre>
|
|
||||||
</details>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
85
frontend/src/components/GameActionDock.vue
Normal file
85
frontend/src/components/GameActionDock.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrivateActionCandidate, PrivateActionMessage } from '../types/game'
|
||||||
|
import { actionScopeLabelMap, formatCandidateLabel } from '../utils/gameUi'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
privateAction: PrivateActionMessage | null
|
||||||
|
privateActionSummary: string
|
||||||
|
actionPanelHint: string
|
||||||
|
turnActionCandidates: PrivateActionCandidate[]
|
||||||
|
responseActionCandidates: PrivateActionCandidate[]
|
||||||
|
recommendedDiscardTile: string | null
|
||||||
|
responseContextSummary: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [actionType: string, tile: string | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function submitCandidate(candidate: PrivateActionCandidate) {
|
||||||
|
// 动作面板只负责把候选动作原样抛回页面容器,不在子组件里自行拼接来源座位或调用接口。
|
||||||
|
emit('submit', candidate.actionType, candidate.tile)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="action-dock">
|
||||||
|
<div class="section-title">
|
||||||
|
<strong>动作面板</strong>
|
||||||
|
<span class="mini-pill">{{ privateActionSummary || '等待动作' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.privateAction" class="action-panel" :class="{ 'response-panel': props.privateAction.actionScope === 'RESPONSE' }">
|
||||||
|
<div class="mini-tags action-meta-row">
|
||||||
|
<span class="mini-tag">当前操作座位 {{ props.privateAction.currentSeatNo }}</span>
|
||||||
|
<span class="mini-tag">{{ actionScopeLabelMap[props.privateAction.actionScope] ?? props.privateAction.actionScope }}</span>
|
||||||
|
<span v-if="props.privateAction.windowId" class="mini-tag">窗口 {{ props.privateAction.windowId.slice(0, 8) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.privateAction.actionScope === 'TURN'" class="turn-panel">
|
||||||
|
<p class="message-copy">{{ props.actionPanelHint }}</p>
|
||||||
|
<div class="candidate-list" v-if="props.turnActionCandidates.length > 0">
|
||||||
|
<button
|
||||||
|
v-for="(candidate, index) in props.turnActionCandidates"
|
||||||
|
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
|
||||||
|
class="candidate-chip action-call-chip"
|
||||||
|
type="button"
|
||||||
|
@click="submitCandidate(candidate)"
|
||||||
|
>
|
||||||
|
{{ formatCandidateLabel(candidate) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="helper-strip">
|
||||||
|
<span class="helper-chip">出牌:直接点击上方手牌</span>
|
||||||
|
<span class="helper-chip">杠 / 自摸胡:点击这里的动作按钮</span>
|
||||||
|
<span v-if="props.recommendedDiscardTile" class="helper-chip helper-chip-accent">
|
||||||
|
建议先打 {{ props.recommendedDiscardTile }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="response-panel-body">
|
||||||
|
<div class="response-banner">
|
||||||
|
<span class="response-kicker">响应窗口</span>
|
||||||
|
<strong>{{ props.responseContextSummary }}</strong>
|
||||||
|
<span class="message-copy">{{ props.actionPanelHint }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="candidate-list" v-if="props.responseActionCandidates.length > 0">
|
||||||
|
<button
|
||||||
|
v-for="(candidate, index) in props.responseActionCandidates"
|
||||||
|
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
|
||||||
|
class="candidate-chip action-call-chip"
|
||||||
|
type="button"
|
||||||
|
@click="submitCandidate(candidate)"
|
||||||
|
>
|
||||||
|
{{ formatCandidateLabel(candidate) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="placeholder-card action-placeholder">
|
||||||
|
当前还没有可执行动作。若轮到你出牌、可自摸胡、可杠,或遇到碰杠胡响应窗口,这里会自动出现对应按钮。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
70
frontend/src/components/GameMessageStack.vue
Normal file
70
frontend/src/components/GameMessageStack.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
CandidateAdviceItem,
|
||||||
|
DiagnosticItem,
|
||||||
|
PrivateActionMessage,
|
||||||
|
PrivateTeachingMessage
|
||||||
|
} from '../types/game'
|
||||||
|
import { formatReasonTag } from '../utils/gameUi'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
privateAction: PrivateActionMessage | null
|
||||||
|
privateActionSummary: string
|
||||||
|
actionDiagnosticItems: DiagnosticItem[]
|
||||||
|
actionPanelHint: string
|
||||||
|
privateTeaching: PrivateTeachingMessage | null
|
||||||
|
recommendedDiscardTile: string | null
|
||||||
|
teachingHint: string
|
||||||
|
teachingCandidates: CandidateAdviceItem[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 这个组件只承接“私有消息栈”展示,确保教学消息不会和公共事件时间线混到同一块区域里。
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="message-stack">
|
||||||
|
<div class="message-card">
|
||||||
|
<span class="meta-label">私有动作消息</span>
|
||||||
|
<template v-if="props.privateAction">
|
||||||
|
<strong>{{ props.privateActionSummary }}</strong>
|
||||||
|
<div class="message-grid">
|
||||||
|
<div v-for="item in props.actionDiagnosticItems" :key="item.key" class="metric-cell">
|
||||||
|
<span class="meta-label">{{ item.label }}</span>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="message-copy">{{ props.actionPanelHint }}</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="empty-copy">尚未收到私有动作消息</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message-card">
|
||||||
|
<span class="meta-label">私有教学消息</span>
|
||||||
|
<strong v-if="props.privateTeaching">{{ props.privateTeaching.recommendedAction }}</strong>
|
||||||
|
<div v-if="props.recommendedDiscardTile" class="teaching-pill-row">
|
||||||
|
<span class="fan-chip">当前推荐 {{ props.recommendedDiscardTile }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="props.privateTeaching" class="message-copy">{{ props.privateTeaching.explanation }}</span>
|
||||||
|
<span v-if="props.privateTeaching" class="message-copy">{{ props.teachingHint }}</span>
|
||||||
|
<div v-if="props.teachingCandidates.length > 0" class="teaching-candidate-list">
|
||||||
|
<article
|
||||||
|
v-for="candidate in props.teachingCandidates"
|
||||||
|
:key="`${candidate.tile}-${candidate.score}`"
|
||||||
|
class="teaching-candidate-card"
|
||||||
|
:class="{ active: candidate.tile === props.recommendedDiscardTile }"
|
||||||
|
>
|
||||||
|
<div class="seat-top">
|
||||||
|
<strong>{{ candidate.tile }}</strong>
|
||||||
|
<span class="mini-pill">评分 {{ candidate.score }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mini-tags">
|
||||||
|
<span v-for="reasonTag in candidate.reasonTags" :key="`${candidate.tile}-${reasonTag}`" class="mini-tag">
|
||||||
|
{{ formatReasonTag(reasonTag) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<span v-else class="empty-copy">尚未收到私有教学消息</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
101
frontend/src/components/PublicEventTimeline.vue
Normal file
101
frontend/src/components/PublicEventTimeline.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PublicGameMessage, ScoreChangeCardView, SettlementCardView } from '../types/game'
|
||||||
|
import {
|
||||||
|
formatEventPayload,
|
||||||
|
formatEventTime,
|
||||||
|
formatPublicEventSummary,
|
||||||
|
formatPublicEventTitle,
|
||||||
|
formatScoreDelta,
|
||||||
|
formatSeatLabel
|
||||||
|
} from '../utils/gameUi'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
publicEvents: PublicGameMessage[]
|
||||||
|
latestSettlementCard: SettlementCardView | null
|
||||||
|
latestSettlementScoreChanges: ScoreChangeCardView[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 公共事件时间线是“调试信息 + 正式公共桌面回放”的统一出口,
|
||||||
|
// 因此把最近结算卡和原始事件流放在同一组件中维护展示顺序。
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="event-timeline">
|
||||||
|
<div class="section-title">
|
||||||
|
<strong>公共事件</strong>
|
||||||
|
<span class="mini-pill">{{ props.publicEvents.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.latestSettlementCard" class="settlement-board">
|
||||||
|
<article class="settlement-card">
|
||||||
|
<div class="seat-top">
|
||||||
|
<strong>最近结算</strong>
|
||||||
|
<span class="mini-pill">{{ props.latestSettlementCard.settlementType }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-meta">
|
||||||
|
<span class="mini-tag">动作 {{ props.latestSettlementCard.actionType }}</span>
|
||||||
|
<span class="mini-tag">执行 {{ formatSeatLabel(props.latestSettlementCard.actorSeatNo) }}</span>
|
||||||
|
<span v-if="props.latestSettlementCard.sourceSeatNo !== null" class="mini-tag">
|
||||||
|
来源 {{ formatSeatLabel(props.latestSettlementCard.sourceSeatNo) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="props.latestSettlementCard.triggerTile" class="mini-tag">
|
||||||
|
目标牌 {{ props.latestSettlementCard.triggerTile }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.latestSettlementCard.detail" class="settlement-metrics">
|
||||||
|
<div class="metric-cell">
|
||||||
|
<span class="meta-label">基础分</span>
|
||||||
|
<strong>{{ props.latestSettlementCard.detail.baseScore }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-cell">
|
||||||
|
<span class="meta-label">总番数</span>
|
||||||
|
<strong>{{ props.latestSettlementCard.detail.totalFan }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric-cell">
|
||||||
|
<span class="meta-label">赔付分</span>
|
||||||
|
<strong>{{ props.latestSettlementCard.detail.paymentScore }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.latestSettlementCard.detail?.fans.length" class="fan-list">
|
||||||
|
<span v-for="fan in props.latestSettlementCard.detail.fans" :key="`${fan.code}-${fan.fan}`" class="fan-chip">
|
||||||
|
{{ fan.label }} · {{ fan.fan }} 番
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article v-if="props.latestSettlementScoreChanges.length > 0" class="settlement-card score-card">
|
||||||
|
<div class="seat-top">
|
||||||
|
<strong>对应分数变化</strong>
|
||||||
|
<span class="mini-pill">{{ props.latestSettlementScoreChanges.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="score-change-list">
|
||||||
|
<div
|
||||||
|
v-for="change in props.latestSettlementScoreChanges"
|
||||||
|
:key="`${change.seatNo}-${change.delta}-${change.score}`"
|
||||||
|
class="score-change-row"
|
||||||
|
>
|
||||||
|
<span class="mini-tag">{{ formatSeatLabel(change.seatNo) }}</span>
|
||||||
|
<strong :class="change.delta >= 0 ? 'score-up' : 'score-down'">{{ formatScoreDelta(change.delta) }}</strong>
|
||||||
|
<span class="meta-label">当前 {{ change.score ?? '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.publicEvents.length === 0" class="placeholder-card">还没有收到公共事件,开局或执行动作后会自动出现。</div>
|
||||||
|
<div v-else class="timeline-list">
|
||||||
|
<article v-for="(event, index) in props.publicEvents" :key="`${event.createdAt}-${index}`" class="timeline-item">
|
||||||
|
<div class="seat-top">
|
||||||
|
<strong>{{ formatPublicEventTitle(event) }}</strong>
|
||||||
|
<span class="mini-pill">{{ formatEventTime(event.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-meta">
|
||||||
|
<span class="mini-tag">{{ formatSeatLabel(event.seatNo) }}</span>
|
||||||
|
<span class="mini-tag">{{ event.eventType }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-copy">{{ formatPublicEventSummary(event) }}</div>
|
||||||
|
<details class="payload-details">
|
||||||
|
<summary>查看原始载荷</summary>
|
||||||
|
<pre class="payload-code">{{ formatEventPayload(event) }}</pre>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -320,6 +320,32 @@ h2 {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tile-chip {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-chip.recommended {
|
||||||
|
border-color: rgba(182, 69, 31, 0.42);
|
||||||
|
box-shadow: 0 14px 28px rgba(182, 69, 31, 0.18);
|
||||||
|
background: linear-gradient(180deg, #fff6e8 0%, #f6d9b7 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-tip {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #b6451f;
|
||||||
|
color: #fffaf2;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
.discard-chip {
|
.discard-chip {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -336,7 +362,8 @@ h2 {
|
|||||||
|
|
||||||
.message-stack,
|
.message-stack,
|
||||||
.event-timeline,
|
.event-timeline,
|
||||||
.timeline-list {
|
.timeline-list,
|
||||||
|
.settlement-board {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +387,98 @@ h2 {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settlement-board {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settlement-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 248, 237, 0.98) 0%, rgba(247, 236, 220, 0.94) 100%);
|
||||||
|
border: 1px solid rgba(113, 82, 47, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-card {
|
||||||
|
background: linear-gradient(180deg, rgba(255, 252, 246, 0.98) 0%, rgba(243, 235, 225, 0.96) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settlement-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(182, 69, 31, 0.1);
|
||||||
|
color: var(--accent-deep);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teaching-pill-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teaching-candidate-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teaching-candidate-card {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.54);
|
||||||
|
border: 1px solid rgba(113, 82, 47, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teaching-candidate-card.active {
|
||||||
|
border-color: rgba(182, 69, 31, 0.26);
|
||||||
|
box-shadow: 0 12px 24px rgba(182, 69, 31, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-change-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-change-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border: 1px solid rgba(113, 82, 47, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-up {
|
||||||
|
color: #0f7b45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-down {
|
||||||
|
color: #a33620;
|
||||||
|
}
|
||||||
|
|
||||||
.message-copy {
|
.message-copy {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -435,6 +554,28 @@ h2 {
|
|||||||
box-shadow: 0 10px 22px rgba(113, 82, 47, 0.08);
|
box-shadow: 0 10px 22px rgba(113, 82, 47, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teaching-strip {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(182, 69, 31, 0.08);
|
||||||
|
border: 1px solid rgba(182, 69, 31, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teaching-kicker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(182, 69, 31, 0.14);
|
||||||
|
color: var(--accent-deep);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.helper-strip {
|
.helper-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -454,6 +595,11 @@ h2 {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.helper-chip-accent {
|
||||||
|
background: rgba(182, 69, 31, 0.12);
|
||||||
|
color: var(--accent-deep);
|
||||||
|
}
|
||||||
|
|
||||||
.response-banner {
|
.response-banner {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -543,4 +689,8 @@ h2 {
|
|||||||
.message-grid {
|
.message-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settlement-metrics {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
143
frontend/src/types/game.ts
Normal file
143
frontend/src/types/game.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// 前端对局页当前仍处于“单页逐步拆分”阶段,因此先把跨组件共享的数据结构集中到这里,
|
||||||
|
// 避免后续拆页时在多个 SFC 中重复维护同一份消息和视图类型。
|
||||||
|
|
||||||
|
export type ApiResponse<T> = {
|
||||||
|
success: boolean
|
||||||
|
code: string
|
||||||
|
message: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoomSeatView = {
|
||||||
|
seatNo: number
|
||||||
|
participantType: string
|
||||||
|
displayName: string
|
||||||
|
botLevel: string | null
|
||||||
|
readyStatus: string
|
||||||
|
teachingEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoomSummaryResponse = {
|
||||||
|
roomId: string
|
||||||
|
inviteCode: string
|
||||||
|
status: string
|
||||||
|
allowBotFill: boolean
|
||||||
|
seats: RoomSeatView[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelfSeatView = {
|
||||||
|
seatNo: number
|
||||||
|
playerId: string
|
||||||
|
nickname: string
|
||||||
|
won: boolean
|
||||||
|
lackSuit: string | null
|
||||||
|
score: number
|
||||||
|
handTiles: string[]
|
||||||
|
discardTiles: string[]
|
||||||
|
melds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicSeatView = {
|
||||||
|
seatNo: number
|
||||||
|
playerId: string
|
||||||
|
nickname: string
|
||||||
|
ai: boolean
|
||||||
|
won: boolean
|
||||||
|
lackSuit: string | null
|
||||||
|
score: number
|
||||||
|
handCount: number
|
||||||
|
discardTiles: string[]
|
||||||
|
melds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameStateResponse = {
|
||||||
|
gameId: string
|
||||||
|
phase: string
|
||||||
|
dealerSeatNo: number
|
||||||
|
currentSeatNo: number
|
||||||
|
remainingWallCount: number
|
||||||
|
selfSeat: SelfSeatView
|
||||||
|
seats: PublicSeatView[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicGameMessage = {
|
||||||
|
gameId: string
|
||||||
|
eventType: string
|
||||||
|
seatNo: number | null
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PrivateActionCandidate = {
|
||||||
|
actionType: string
|
||||||
|
tile: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PrivateActionMessage = {
|
||||||
|
gameId: string
|
||||||
|
userId: string
|
||||||
|
actionScope: string
|
||||||
|
availableActions: string[]
|
||||||
|
currentSeatNo: number
|
||||||
|
windowId: string | null
|
||||||
|
triggerEventType: string | null
|
||||||
|
sourceSeatNo: number | null
|
||||||
|
triggerTile: string | null
|
||||||
|
candidates: PrivateActionCandidate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CandidateAdviceItem = {
|
||||||
|
tile: string
|
||||||
|
score: number
|
||||||
|
reasonTags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PrivateTeachingMessage = {
|
||||||
|
gameId: string
|
||||||
|
userId: string
|
||||||
|
teachingMode: string
|
||||||
|
recommendedAction: string
|
||||||
|
explanation: string
|
||||||
|
candidates: CandidateAdviceItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewUserOption = {
|
||||||
|
userId: string
|
||||||
|
label: string
|
||||||
|
seatNo: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettlementFanView = {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
fan: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettlementDetailView = {
|
||||||
|
baseScore: number
|
||||||
|
totalFan: number
|
||||||
|
paymentScore: number
|
||||||
|
fans: SettlementFanView[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettlementCardView = {
|
||||||
|
settlementType: string
|
||||||
|
actionType: string
|
||||||
|
actorSeatNo: number | null
|
||||||
|
sourceSeatNo: number | null
|
||||||
|
triggerTile: string | null
|
||||||
|
detail: SettlementDetailView | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScoreChangeCardView = {
|
||||||
|
seatNo: number
|
||||||
|
delta: number
|
||||||
|
score: number | null
|
||||||
|
settlementType: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiagnosticItem = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
220
frontend/src/utils/gameUi.ts
Normal file
220
frontend/src/utils/gameUi.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import type {
|
||||||
|
PrivateActionCandidate,
|
||||||
|
PublicGameMessage,
|
||||||
|
SettlementDetailView,
|
||||||
|
SettlementFanView
|
||||||
|
} from '../types/game'
|
||||||
|
|
||||||
|
export const phaseLabelMap: Record<string, string> = {
|
||||||
|
WAITING: '等待中',
|
||||||
|
READY: '全部就绪',
|
||||||
|
PLAYING: '对局中',
|
||||||
|
FINISHED: '已结束',
|
||||||
|
LACK_SELECTION: '定缺阶段'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionScopeLabelMap: Record<string, string> = {
|
||||||
|
TURN: '当前回合动作',
|
||||||
|
RESPONSE: '响应候选动作'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionTypeLabelMap: Record<string, string> = {
|
||||||
|
SELECT_LACK_SUIT: '定缺',
|
||||||
|
DISCARD: '出牌',
|
||||||
|
PENG: '碰',
|
||||||
|
GANG: '杠',
|
||||||
|
HU: '胡',
|
||||||
|
PASS: '过'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const triggerEventTypeLabelMap: Record<string, string> = {
|
||||||
|
TILE_DISCARDED: '弃牌后响应',
|
||||||
|
SUPPLEMENTAL_GANG_DECLARED: '补杠后抢杠胡'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicEventLabelMap: Record<string, string> = {
|
||||||
|
GAME_STARTED: '对局开始',
|
||||||
|
LACK_SELECTED: '定缺完成',
|
||||||
|
GAME_PHASE_CHANGED: '阶段切换',
|
||||||
|
TILE_DISCARDED: '出牌',
|
||||||
|
TILE_DRAWN: '摸牌',
|
||||||
|
TURN_SWITCHED: '轮转',
|
||||||
|
RESPONSE_WINDOW_OPENED: '响应窗口开启',
|
||||||
|
RESPONSE_WINDOW_CLOSED: '响应窗口关闭',
|
||||||
|
PENG_DECLARED: '碰牌宣告',
|
||||||
|
GANG_DECLARED: '杠牌宣告',
|
||||||
|
HU_DECLARED: '胡牌宣告',
|
||||||
|
PASS_DECLARED: '过牌宣告',
|
||||||
|
SETTLEMENT_APPLIED: '结算应用',
|
||||||
|
SCORE_CHANGED: '分数变化',
|
||||||
|
ACTION_REQUIRED: '动作提醒'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settlementTypeLabelMap: Record<string, string> = {
|
||||||
|
DIAN_PAO_HU: '点炮胡',
|
||||||
|
QIANG_GANG_HU: '抢杠胡',
|
||||||
|
ZI_MO_HU: '自摸胡',
|
||||||
|
EXPOSED_GANG: '明杠',
|
||||||
|
SUPPLEMENTAL_GANG: '补杠',
|
||||||
|
CONCEALED_GANG: '暗杠',
|
||||||
|
TUI_SHUI: '退税',
|
||||||
|
CHA_JIAO: '查叫'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reasonTagLabelMap: Record<string, string> = {
|
||||||
|
LACK_SUIT_PRIORITY: '优先处理定缺',
|
||||||
|
ISOLATED_TILE: '孤张优先打出',
|
||||||
|
KEEP_PAIR: '保留对子',
|
||||||
|
EDGE_TILE: '边张优先处理',
|
||||||
|
KEEP_SEQUENCE_POTENTIAL: '保留顺子潜力'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readString(value: unknown) {
|
||||||
|
return typeof value === 'string' ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readNumber(value: unknown) {
|
||||||
|
return typeof value === 'number' ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asRecord(value: unknown) {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toActionLabel(actionType: string) {
|
||||||
|
return actionTypeLabelMap[actionType] ?? actionType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCandidateLabel(candidate: PrivateActionCandidate) {
|
||||||
|
const actionLabel = toActionLabel(candidate.actionType)
|
||||||
|
if (!candidate.tile) {
|
||||||
|
return actionLabel
|
||||||
|
}
|
||||||
|
return `${actionLabel} · ${candidate.tile}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSeatLabel(seatNo: number | null | undefined) {
|
||||||
|
if (seatNo === null || seatNo === undefined) {
|
||||||
|
return '未知座位'
|
||||||
|
}
|
||||||
|
return `座位 ${seatNo}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPhaseLabel(phase: string | null) {
|
||||||
|
if (!phase) {
|
||||||
|
return '未知阶段'
|
||||||
|
}
|
||||||
|
return phaseLabelMap[phase] ?? phase
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPublicEventTitle(event: PublicGameMessage) {
|
||||||
|
return publicEventLabelMap[event.eventType] ?? event.eventType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSettlementType(settlementType: string | null) {
|
||||||
|
if (!settlementType) {
|
||||||
|
return '未知结算'
|
||||||
|
}
|
||||||
|
return settlementTypeLabelMap[settlementType] ?? settlementType
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatScoreDelta(delta: number | null) {
|
||||||
|
if (delta === null) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return delta > 0 ? `+${delta}` : `${delta}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSettlementDetailView(value: unknown): SettlementDetailView | null {
|
||||||
|
const detail = asRecord(value)
|
||||||
|
if (!detail) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseScore: readNumber(detail.baseScore) ?? 0,
|
||||||
|
totalFan: readNumber(detail.totalFan) ?? 0,
|
||||||
|
paymentScore: readNumber(detail.paymentScore) ?? 0,
|
||||||
|
fans: toSettlementFans(detail.fans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSettlementSummary(event: PublicGameMessage) {
|
||||||
|
const settlementType = formatSettlementType(readString(event.payload.settlementType))
|
||||||
|
const triggerTile = readString(event.payload.triggerTile)
|
||||||
|
const detail = asRecord(event.payload.settlementDetail)
|
||||||
|
const paymentScore = readNumber(detail?.paymentScore)
|
||||||
|
const totalFan = readNumber(detail?.totalFan)
|
||||||
|
return `${settlementType}${triggerTile ? ` · ${triggerTile}` : ''},${paymentScore ?? '-'} 分,${totalFan ?? 0} 番。`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPublicEventSummary(event: PublicGameMessage) {
|
||||||
|
switch (event.eventType) {
|
||||||
|
case 'GAME_STARTED':
|
||||||
|
return `房间 ${readString(event.payload.roomId) ?? '-'} 已开局。`
|
||||||
|
case 'LACK_SELECTED':
|
||||||
|
return `${formatSeatLabel(event.seatNo)} 已完成定缺 ${readString(event.payload.lackSuit) ?? '-'}。`
|
||||||
|
case 'GAME_PHASE_CHANGED':
|
||||||
|
return `阶段切换为 ${formatPhaseLabel(readString(event.payload.phase))}。`
|
||||||
|
case 'TILE_DISCARDED':
|
||||||
|
return `${formatSeatLabel(event.seatNo)} 打出 ${readString(event.payload.tile) ?? '-'}。`
|
||||||
|
case 'TILE_DRAWN':
|
||||||
|
return `${formatSeatLabel(event.seatNo)} 完成摸牌,牌墙剩余 ${readNumber(event.payload.remainingWallCount) ?? '-'} 张。`
|
||||||
|
case 'TURN_SWITCHED':
|
||||||
|
return `当前轮到 ${formatSeatLabel(readNumber(event.payload.currentSeatNo) ?? event.seatNo)}。`
|
||||||
|
case 'RESPONSE_WINDOW_OPENED':
|
||||||
|
return `因 ${formatSeatLabel(readNumber(event.payload.sourceSeatNo))} 的 ${readString(event.payload.tile) ?? '-'} 打开响应窗口。`
|
||||||
|
case 'RESPONSE_WINDOW_CLOSED':
|
||||||
|
return `响应窗口已关闭,最终裁决 ${toActionLabel(readString(event.payload.resolvedActionType) ?? '-')}。`
|
||||||
|
case 'PENG_DECLARED':
|
||||||
|
case 'GANG_DECLARED':
|
||||||
|
case 'HU_DECLARED':
|
||||||
|
case 'PASS_DECLARED':
|
||||||
|
return `${formatSeatLabel(event.seatNo)} 执行 ${toActionLabel(readString(event.payload.actionType) ?? event.eventType.replace('_DECLARED', ''))}${readString(event.payload.tile) ? ` · ${readString(event.payload.tile)}` : ''}。`
|
||||||
|
case 'SETTLEMENT_APPLIED':
|
||||||
|
return formatSettlementSummary(event)
|
||||||
|
case 'SCORE_CHANGED':
|
||||||
|
return `${formatSeatLabel(event.seatNo)} 分数变化 ${formatScoreDelta(readNumber(event.payload.delta))},当前 ${readNumber(event.payload.score) ?? '-'}。`
|
||||||
|
default:
|
||||||
|
return JSON.stringify(event.payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEventPayload(event: PublicGameMessage) {
|
||||||
|
return JSON.stringify(event.payload, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEventTime(createdAt: string) {
|
||||||
|
const date = new Date(createdAt)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return createdAt
|
||||||
|
}
|
||||||
|
return date.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReasonTag(reasonTag: string) {
|
||||||
|
return reasonTagLabelMap[reasonTag] ?? reasonTag
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSettlementFans(value: unknown): SettlementFanView[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => asRecord(item))
|
||||||
|
.filter((item): item is Record<string, unknown> => item !== null)
|
||||||
|
.map((item) => ({
|
||||||
|
code: readString(item.code) ?? '-',
|
||||||
|
label: readString(item.label) ?? readString(item.code) ?? '-',
|
||||||
|
fan: readNumber(item.fan) ?? 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user