feat: 实现最小正式版过水不胡规则并完善前端动作面板
- 后端实现最小正式版过水不胡规则:玩家在响应窗口选择PASS后,直到下次摸牌前不能响应胡 - 完善GameSeat状态管理,新增passedHuBlocked字段及相关方法 - 在ResponseActionWindowBuilder和GameActionProcessor中增加过水不胡校验 - 前端重构动作面板,区分回合动作和响应动作,支持多用户视角切换 - 优化公共事件处理逻辑,自动清理失效的私有动作面板 - 更新相关文档说明当前实现的规则范围和工程取舍 - 补充测试用例验证过水不胡规则的正确性
This commit is contained in:
Binary file not shown.
@@ -1,58 +1,16 @@
|
||||
# 正式血战计分 V1(2026-03-20)
|
||||
- 已从工程占位分切换到“最小可扩展正式版”计分骨架。
|
||||
- 新增后端规则服务:`backend/src/main/java/com/xuezhanmaster/game/service/BloodBattleScoringService.java`
|
||||
- `SettlementResult` 已扩展 `settlementDetail`,结算事件 `SETTLEMENT_APPLIED` 现在会携带:
|
||||
- `baseScore`
|
||||
- `totalFan`
|
||||
- `paymentScore`
|
||||
- `fans`(番型明细)
|
||||
- 当前 V1 已支持的基础番型/加番:
|
||||
- `七对`:2 番
|
||||
- `对对胡`:1 番
|
||||
- `金钩钓`:1 番
|
||||
- `清一色`:2 番
|
||||
- `根`:每个 1 番
|
||||
- `抢杠胡`:1 番
|
||||
- `杠上花`:1 番
|
||||
- `杠上炮`:1 番
|
||||
- `海底捞月`:1 番
|
||||
- `海底炮`:1 番
|
||||
- 当前胡牌计分口径:`paymentScore = 1 << totalFan`
|
||||
- 点炮胡:放炮者单独支付 `paymentScore`
|
||||
- 自摸胡:所有未胡玩家各支付 `paymentScore`
|
||||
- 抢杠胡:按胡牌番型 + `抢杠胡` 1 番,由补杠方单独支付
|
||||
- 杠上花:在自摸胡基础上额外加 1 番
|
||||
- 杠上炮:在点炮胡基础上额外加 1 番
|
||||
- 海底捞月:自摸胡时若牌墙已空,额外加 1 番
|
||||
- 海底炮:点炮胡时若牌墙已空,额外加 1 番
|
||||
- 当前杠分口径:
|
||||
- `明杠/点杠`:放杠者单独支付 2 分
|
||||
- `补杠`:所有未胡玩家各支付 1 分
|
||||
- `暗杠`:所有未胡玩家各支付 2 分
|
||||
- `GameSession` 已新增:
|
||||
- `PostGangContext`:只记录“杠后补摸到下一次自摸/弃牌裁决”这段窗口,用于判断 `杠上花/杠上炮`
|
||||
- `settlementHistory`:记录已应用结算结果,供局终 `退税/查叫` 直接复用
|
||||
- 海底相关不新增额外状态对象,直接复用“胡牌时牌墙已空”这一现有事实,保持 `KISS`。
|
||||
- 查叫/退税已接入局终处理:
|
||||
- 触发时机:牌墙耗尽且仍有多家未胡时
|
||||
- 处理顺序:先 `退税`,再 `查叫`
|
||||
- `退税`:未听牌玩家若此前有杠分收入,需要按原支付关系逐笔退还
|
||||
- `查叫`:未听牌玩家向仍在场且已听牌玩家,按对方理论最大可胡牌型的点炮赔分支付
|
||||
- 已新增 `SettlementType.TUI_SHUI` 与 `SettlementType.CHA_JIAO`
|
||||
- 已新增 `ReadyHandOption`,用于查叫时记录最优听牌目标牌与对应结算明细
|
||||
- 一炮多响已接入响应裁决:
|
||||
- `HU` 现在支持多赢家同窗裁决,只要响应窗口内有多个 `HU` 声明,就会一起结算
|
||||
- `PENG/GANG` 仍保持单赢家,顺位规则不变
|
||||
- 抢杠胡窗口也会复用这套多赢家 `HU` 裁决路径
|
||||
- 已新增 `ResponseActionResolutionBatch` 承载多赢家响应结果
|
||||
- `HuEvaluator` 已补 `七对` 胡牌判定,并暴露 `isSevenPairs` / `isPengPengHu` 给计分层复用。
|
||||
- 当前仍未实现:
|
||||
- 自摸加番/加底地方变体
|
||||
- 天胡、地胡
|
||||
- 过水不胡
|
||||
- 文档约定已加强到 `docs/DEVELOPMENT_PLAN.md`:
|
||||
- 后端复杂规则、状态切换、结算口径和跨阶段流程,必须补适量中文注释
|
||||
- 前端复杂交互、实时消息消费、动作面板联动和视图状态切换,必须补适量中文注释
|
||||
- 数据库表结构、迁移脚本、初始化 SQL、索引和存储过程,必须补中文注释说明业务含义、约束原因、字段口径和回滚要点
|
||||
- 麻将规则判断、结算分摊、响应裁决、实时消息边界、局终处理、数据迁移与回滚脚本,默认视为中文注释必需区域
|
||||
- 最小验证:`cd backend && mvn clean test`,当前 51 个测试通过。
|
||||
- 当前已支持的规则主线包括:七对、对对胡、金钩钓、清一色、根、抢杠胡、杠上花、杠上炮、海底捞月、海底炮、明杠/补杠/暗杠、退税、查叫、一炮多响、最小正式版过水不胡。
|
||||
- 当前胡牌计分口径:`paymentScore = 1 << totalFan`。
|
||||
- 一炮多响:只对 `HU` 开放多赢家同窗裁决,`PENG/GANG` 仍单赢家。
|
||||
- 过水不胡:玩家在响应窗口里本可 `HU` 但选择 `PASS` 后,直到自己下一次真正摸牌前,不能再做响应胡;不影响碰、杠、自摸胡。
|
||||
- 后端最小验证:`cd backend && mvn test` 通过,当前 53 个测试通过。
|
||||
- 前端 H5 对局页已经完成两轮联调:
|
||||
- 已区分“当前回合动作”和“响应动作”两种动作面板。
|
||||
- `DISCARD` 继续通过点击手牌执行,`GANG/HU` 等额外动作走统一面板。
|
||||
- 响应动作面板会展示 `sourceSeatNo`、`triggerTile`、`triggerEventType`、`windowId`。
|
||||
- 已支持在对局页切换玩家视角,并自动刷新对应 `GameStateResponse` 与重连该玩家私有 WebSocket 主题。
|
||||
- 公共事件接收已改为统一入口处理,会在 `RESPONSE_WINDOW_CLOSED`、`TURN_SWITCHED`、`TILE_DISCARDED`、`GAME_PHASE_CHANGED` 等场景下清理已失效的私有动作面板,避免旧窗口残留。
|
||||
- 公共事件时间线已支持中文摘要文案、时间展示和原始载荷折叠查看,便于联调时同时看“业务含义”和“真实 payload”。
|
||||
- 注释约定继续有效:后端复杂规则、前端复杂交互和后续数据库脚本都要补适当偏多的中文注释。
|
||||
- 前端验证:`cd frontend && npm run build` 通过。
|
||||
@@ -16,6 +16,8 @@ public class GameSeat {
|
||||
private TileSuit lackSuit;
|
||||
private boolean won;
|
||||
private int score;
|
||||
// 过水不胡最小版:只限制“响应胡”,直到该玩家下一次摸牌后解除;不影响自摸胡。
|
||||
private boolean passedHuBlocked;
|
||||
|
||||
public GameSeat(int seatNo, boolean ai, String nickname) {
|
||||
this(seatNo, ai, UUID.randomUUID().toString(), nickname);
|
||||
@@ -76,10 +78,22 @@ public class GameSeat {
|
||||
return score;
|
||||
}
|
||||
|
||||
public boolean isPassedHuBlocked() {
|
||||
return passedHuBlocked;
|
||||
}
|
||||
|
||||
public void addScore(int delta) {
|
||||
this.score += delta;
|
||||
}
|
||||
|
||||
public void markPassedHuBlocked() {
|
||||
this.passedHuBlocked = true;
|
||||
}
|
||||
|
||||
public void clearPassedHuBlocked() {
|
||||
this.passedHuBlocked = false;
|
||||
}
|
||||
|
||||
public void receiveTile(Tile tile) {
|
||||
handTiles.add(tile);
|
||||
}
|
||||
|
||||
@@ -287,6 +287,14 @@ public class GameActionProcessor {
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前玩家不在" + actionName + "响应候选中"));
|
||||
|
||||
if ("胡".equals(actionName) && actorSeat.isPassedHuBlocked()) {
|
||||
boolean hasHuOption = seatCandidate.options().stream()
|
||||
.anyMatch(option -> option.actionType() == ActionType.HU);
|
||||
if (!hasHuOption) {
|
||||
throw new BusinessException("GAME_ACTION_WINDOW_INVALID", "当前处于过水不胡限制中,需待下次摸牌后才能再胡");
|
||||
}
|
||||
}
|
||||
|
||||
boolean matched = seatCandidate.options().stream()
|
||||
.anyMatch(option -> option.actionType().name().equals(toActionTypeName(actionName))
|
||||
&& (tileDisplayName == null || tileDisplayName.equals(option.tile())));
|
||||
|
||||
@@ -180,6 +180,8 @@ public class GameSessionService {
|
||||
GameSeat nextSeat = nextSeatOptional.get();
|
||||
Tile drawnTile = table.getWallTiles().remove(0);
|
||||
nextSeat.receiveTile(drawnTile);
|
||||
// 玩家真正完成下一次摸牌后,才解除此前由“过水不胡”带来的响应胡限制。
|
||||
nextSeat.clearPassedHuBlocked();
|
||||
table.setCurrentSeatNo(nextSeat.getSeatNo());
|
||||
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(session.getGameId(), nextSeat.getSeatNo(), table.getWallTiles().size()));
|
||||
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(session.getGameId(), nextSeat.getSeatNo()));
|
||||
@@ -366,6 +368,7 @@ public class GameSessionService {
|
||||
private void handlePassPostAction(GameSession session, GameActionRequest request) {
|
||||
ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session);
|
||||
GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId());
|
||||
markPassedHuBlockedIfNeeded(responseActionWindow, actorSeat);
|
||||
session.getResponseActionSelections().put(actorSeat.getSeatNo(), ActionType.PASS);
|
||||
tryResolveResponseWindow(session, responseActionWindow);
|
||||
}
|
||||
@@ -413,6 +416,7 @@ public class GameSessionService {
|
||||
for (ResponseActionSeatCandidate seatCandidate : responseActionWindow.seatCandidates()) {
|
||||
GameSeat seat = session.getTable().getSeats().get(seatCandidate.seatNo());
|
||||
if (seat.isAi()) {
|
||||
markPassedHuBlockedIfNeeded(responseActionWindow, seat);
|
||||
session.getResponseActionSelections().put(seatCandidate.seatNo(), ActionType.PASS);
|
||||
}
|
||||
}
|
||||
@@ -590,6 +594,7 @@ public class GameSessionService {
|
||||
|
||||
Tile drawnTile = table.getWallTiles().remove(0);
|
||||
winnerSeat.receiveTile(drawnTile);
|
||||
winnerSeat.clearPassedHuBlocked();
|
||||
markPostGangContext(session, winnerSeat.getSeatNo(), claimedTile.getDisplayName());
|
||||
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
|
||||
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
|
||||
@@ -726,6 +731,7 @@ public class GameSessionService {
|
||||
|
||||
Tile drawnTile = table.getWallTiles().remove(0);
|
||||
winnerSeat.receiveTile(drawnTile);
|
||||
winnerSeat.clearPassedHuBlocked();
|
||||
markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName());
|
||||
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
|
||||
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
|
||||
@@ -786,12 +792,21 @@ public class GameSessionService {
|
||||
|
||||
Tile drawnTile = table.getWallTiles().remove(0);
|
||||
winnerSeat.receiveTile(drawnTile);
|
||||
winnerSeat.clearPassedHuBlocked();
|
||||
markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName());
|
||||
appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
|
||||
appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
|
||||
continueFromResolvedActionTurn(session, winnerSeat);
|
||||
}
|
||||
|
||||
private void markPassedHuBlockedIfNeeded(ResponseActionWindow responseActionWindow, GameSeat seat) {
|
||||
responseActionWindow.seatCandidates().stream()
|
||||
.filter(candidate -> candidate.seatNo() == seat.getSeatNo())
|
||||
.findFirst()
|
||||
.filter(candidate -> candidate.options().stream().anyMatch(option -> option.actionType() == ActionType.HU))
|
||||
.ifPresent(candidate -> seat.markPassedHuBlocked());
|
||||
}
|
||||
|
||||
private ActionType parseActionType(String actionType) {
|
||||
try {
|
||||
return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT));
|
||||
|
||||
@@ -63,7 +63,8 @@ public class ResponseActionWindowBuilder {
|
||||
if (seat.getSeatNo() == sourceSeatNo || seat.isWon()) {
|
||||
continue;
|
||||
}
|
||||
if (!huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), gangTile)) {
|
||||
// 抢杠胡也属于“响应胡”,因此同样受过水不胡限制。
|
||||
if (!canHuByResponse(seat, gangTile)) {
|
||||
continue;
|
||||
}
|
||||
seatCandidates.add(new ResponseActionSeatCandidate(
|
||||
@@ -91,7 +92,8 @@ public class ResponseActionWindowBuilder {
|
||||
|
||||
private List<ResponseActionOption> buildSeatOptions(GameSeat seat, Tile discardedTile) {
|
||||
int sameTileCount = countSameTileInHand(seat, discardedTile);
|
||||
boolean canHu = huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), discardedTile);
|
||||
// 过水不胡只屏蔽响应胡候选,不影响同窗口内仍可用的碰、杠。
|
||||
boolean canHu = canHuByResponse(seat, discardedTile);
|
||||
if (!canHu && sameTileCount < 2) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -114,6 +116,13 @@ public class ResponseActionWindowBuilder {
|
||||
return options;
|
||||
}
|
||||
|
||||
private boolean canHuByResponse(GameSeat seat, Tile triggerTile) {
|
||||
if (seat.isPassedHuBlocked()) {
|
||||
return false;
|
||||
}
|
||||
return huEvaluator.canHuWithClaimedTile(seat.getHandTiles(), triggerTile);
|
||||
}
|
||||
|
||||
private int countSameTileInHand(GameSeat seat, Tile discardedTile) {
|
||||
int count = 0;
|
||||
for (Tile tile : seat.getHandTiles()) {
|
||||
|
||||
@@ -764,6 +764,125 @@ class GameSessionServiceTest {
|
||||
assertThat(settlementResultsOfType(session, SettlementType.DIAN_PAO_HU)).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBlockResponseHuBeforeSeatDrawsAgainAfterPassingWinningWindow() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
|
||||
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-3", "玩家三"));
|
||||
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-4", "玩家四"));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-3", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-4", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
|
||||
gameSessionService.selectLackSuit(started.gameId(), "player-2", TileSuit.TONG.name());
|
||||
gameSessionService.selectLackSuit(started.gameId(), "player-3", TileSuit.TIAO.name());
|
||||
gameSessionService.selectLackSuit(started.gameId(), "player-4", TileSuit.WAN.name());
|
||||
|
||||
GameSession session = getSession(started.gameId());
|
||||
GameSeat winnerSeat = session.getTable().getSeats().get(1);
|
||||
prepareWinningHand(winnerSeat);
|
||||
session.getTable().setCurrentSeatNo(2);
|
||||
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(2), "9筒", 1);
|
||||
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(3), "9筒", 1);
|
||||
removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2, 3);
|
||||
|
||||
gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("player-3", "DISCARD", "9筒", null)
|
||||
);
|
||||
GameStateResponse afterPass = gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("player-2", "PASS", null, 2)
|
||||
);
|
||||
|
||||
assertThat(winnerSeat.isPassedHuBlocked()).isTrue();
|
||||
assertThat(afterPass.currentSeatNo()).isEqualTo(3);
|
||||
|
||||
GameStateResponse afterSecondDiscard = gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("player-4", "DISCARD", "9筒", null)
|
||||
);
|
||||
|
||||
assertThat(session.getPendingResponseActionWindow()).isNull();
|
||||
assertThat(afterSecondDiscard.currentSeatNo()).isEqualTo(0);
|
||||
assertThat(winnerSeat.isPassedHuBlocked()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClearPassedHuRestrictionAfterSeatDrawsAgain() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
|
||||
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-3", "玩家三"));
|
||||
roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-4", "玩家四"));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-3", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-4", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
|
||||
gameSessionService.selectLackSuit(started.gameId(), "player-2", TileSuit.TONG.name());
|
||||
gameSessionService.selectLackSuit(started.gameId(), "player-3", TileSuit.TIAO.name());
|
||||
gameSessionService.selectLackSuit(started.gameId(), "player-4", TileSuit.WAN.name());
|
||||
|
||||
GameSession session = getSession(started.gameId());
|
||||
GameSeat winnerSeat = session.getTable().getSeats().get(1);
|
||||
prepareWinningHand(winnerSeat);
|
||||
session.getTable().setCurrentSeatNo(2);
|
||||
ensureSeatHasMatchingTiles(session.getTable().getSeats().get(2), "9筒", 2);
|
||||
removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2);
|
||||
|
||||
gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("player-3", "DISCARD", "9筒", null)
|
||||
);
|
||||
gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("player-2", "PASS", null, 2)
|
||||
);
|
||||
|
||||
assertThat(winnerSeat.isPassedHuBlocked()).isTrue();
|
||||
|
||||
String seatThreeSafeDiscard = session.getTable().getSeats().get(3).getHandTiles().get(0).getDisplayName();
|
||||
removeMatchingTilesFromOtherSeats(session, seatThreeSafeDiscard, 3);
|
||||
gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("player-4", "DISCARD", seatThreeSafeDiscard, null)
|
||||
);
|
||||
|
||||
String seatZeroSafeDiscard = session.getTable().getSeats().get(0).getHandTiles().get(0).getDisplayName();
|
||||
removeMatchingTilesFromOtherSeats(session, seatZeroSafeDiscard, 0);
|
||||
setNextWallTile(session, "8万");
|
||||
GameStateResponse afterSeatZeroDiscard = gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("host-1", "DISCARD", seatZeroSafeDiscard, null)
|
||||
);
|
||||
|
||||
assertThat(afterSeatZeroDiscard.currentSeatNo()).isEqualTo(1);
|
||||
assertThat(winnerSeat.isPassedHuBlocked()).isFalse();
|
||||
removeMatchingTilesFromOtherSeats(session, "8万", 1);
|
||||
|
||||
gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("player-2", "DISCARD", "8万", null)
|
||||
);
|
||||
GameStateResponse afterHu = gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("player-3", "DISCARD", "9筒", null)
|
||||
);
|
||||
afterHu = gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("player-2", "HU", "9筒", 2)
|
||||
);
|
||||
|
||||
assertThat(afterHu.selfSeat().won()).isTrue();
|
||||
assertThat(afterHu.selfSeat().score()).isEqualTo(1);
|
||||
assertThat(afterHu.currentSeatNo()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectSelfDrawHuWhenHandIsNotWinning() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
|
||||
@@ -350,12 +350,10 @@ ws WebSocket 配置与消息发布
|
||||
|
||||
### 7.5 当前尚未完成
|
||||
|
||||
- `PENG`
|
||||
- `GANG`
|
||||
- `HU`
|
||||
- `PASS`
|
||||
- 响应动作优先级裁决
|
||||
- 胡牌判定与结算
|
||||
- 自摸加番 / 加底等地方变体
|
||||
- 天胡、地胡
|
||||
- 更完整的地方化 `过水不胡`
|
||||
- 前端正式动作面板与规则联调
|
||||
- 教学开关接口
|
||||
- 局后个人复盘
|
||||
- 数据库持久化
|
||||
@@ -393,6 +391,10 @@ ws WebSocket 配置与消息发布
|
||||
- 当前支持:
|
||||
- `SELECT_LACK_SUIT`
|
||||
- `DISCARD`
|
||||
- `PENG`
|
||||
- `GANG`
|
||||
- `HU`
|
||||
- `PASS`
|
||||
|
||||
- `POST /api/games/{gameId}/lack`
|
||||
- 兼容接口
|
||||
@@ -645,7 +647,7 @@ AI 不是单一模块,而是三层能力:
|
||||
|
||||
当前状态:
|
||||
|
||||
- 进行中
|
||||
- 已基本完成
|
||||
|
||||
### M3 规则与结算
|
||||
|
||||
@@ -668,7 +670,7 @@ AI 不是单一模块,而是三层能力:
|
||||
|
||||
当前状态:
|
||||
|
||||
- 待做
|
||||
- 进行中
|
||||
|
||||
### M4 H5 正式对局体验
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# XueZhanMaster 阶段任务看板
|
||||
|
||||
本文档把主计划拆成可以直接推进的阶段任务卡。
|
||||
本文档把主计划拆成可以直接推进的阶段任务卡。\
|
||||
当前状态快照日期:`2026-03-20`
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
## 1. 使用说明
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
- 验收标准
|
||||
- 风险提示
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
## 2. 待做
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
- 每局结束后可获得个人复盘
|
||||
- 可沉淀到错题本并二次查看
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
## 3. 进行中
|
||||
|
||||
@@ -252,7 +252,7 @@
|
||||
- 验收标准:
|
||||
- 页面拆分方案可直接指导下一轮前端编码
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
## 4. 已完成
|
||||
|
||||
@@ -359,3 +359,4 @@
|
||||
- `vite.config.ts` 已代理 `/api` 和 `/ws`
|
||||
- 验收结果:
|
||||
- H5 原型页面可走通当前最小链路
|
||||
|
||||
|
||||
@@ -22,19 +22,22 @@
|
||||
- AI 与真人使用同一套优先级规则
|
||||
- 同优先级冲突时,按出牌者之后的最近顺位优先
|
||||
- `PASS` 仅表示放弃当前窗口,不等于永久放弃后续所有同类机会
|
||||
- `过水不胡` 作为后续增强规则,不在当前 `V1` 强制启用
|
||||
- `一炮多响` 在项目 `V1` 中暂不实现,采用“单窗口单胜出动作”的工程裁决
|
||||
- `过水不胡` 已在项目 `V1` 中启用“最小正式版”
|
||||
- 玩家在响应窗口里本可 `HU` 但选择 `PASS` 后,直到自己下一次摸牌前,不能再做响应胡
|
||||
- 该限制只作用于响应胡,不影响 `PENG / GANG`,也不影响自摸胡
|
||||
- `一炮多响` 已在项目 `V1` 中实现,但只对 `HU` 开放多赢家同窗裁决
|
||||
- `PENG / GANG` 仍保持单赢家裁决,顺位规则不变
|
||||
|
||||
### 1.2 为什么这样定
|
||||
|
||||
- 这样能和当前后端结构最自然衔接
|
||||
- 可以先把响应窗口停顿与恢复流程做稳
|
||||
- 避免在 `HU` 判定、多人同时胡牌、结算分摊都未稳定时,提前引入高复杂度冲突逻辑
|
||||
- 可以先把响应窗口停顿、恢复和结算主链做稳
|
||||
- 即使已支持一炮多响与最小版过水不胡,也仍把复杂度限制在当前统一动作入口和统一响应窗口内
|
||||
|
||||
这符合:
|
||||
|
||||
- `KISS`:先做单窗口单胜出
|
||||
- `YAGNI`:当前不抢做一炮多响和完整过手胡体系
|
||||
- `KISS`:一炮多响只对 `HU` 放开,多赢家共用一张牌源,避免把 `PENG / GANG` 也做成多胜出分支
|
||||
- `YAGNI`:当前不抢做完整地方化 `过水不胡` 体系,也不引入可配置规则模式层
|
||||
- `SOLID`:先把规则澄清文档与裁决器边界固定
|
||||
- `DRY`:所有动作冲突统一走一套优先级裁决
|
||||
|
||||
@@ -75,11 +78,10 @@
|
||||
|
||||
- 某玩家打出一张牌后
|
||||
- 其他仍在牌局中的玩家基于这张弃牌触发响应窗口
|
||||
- 补杠声明后的抢杠胡响应窗口
|
||||
|
||||
`V1` 暂不实现:
|
||||
|
||||
- 抢杠胡窗口
|
||||
- 补杠后二次响应窗口
|
||||
- 最后一张牌必须胡的特殊强制窗口
|
||||
- 多轮连续嵌套响应窗口
|
||||
|
||||
@@ -89,14 +91,13 @@
|
||||
|
||||
- `PENG`
|
||||
- `GANG`
|
||||
- `HU`
|
||||
- `PASS`
|
||||
|
||||
`HU` 的动作入口和事件枚举虽然已预留,但真正候选生成依赖胡牌判定完成后再接入。
|
||||
其中:
|
||||
|
||||
因此要区分两个状态:
|
||||
|
||||
1. 接口和模型层:`HU` 已被保留
|
||||
2. 当前实际候选生成层:以 `PENG / GANG / PASS` 为主
|
||||
1. 弃牌响应窗口支持 `HU / PENG / GANG / PASS`
|
||||
2. 补杠抢杠胡窗口支持 `HU / PASS`
|
||||
|
||||
---
|
||||
|
||||
@@ -135,27 +136,30 @@
|
||||
- `seat 1` 和 `seat 3` 同时都能 `PENG`
|
||||
- 则 `seat 1` 获胜
|
||||
|
||||
### 4.3 为什么不在 V1 支持一炮多响
|
||||
### 4.3 为什么在 V1 只对 `HU` 支持一炮多响
|
||||
|
||||
这是一个项目级工程取舍,不是宣称外部规则世界只有这一种。
|
||||
这也是一个项目级工程取舍,不是宣称外部规则世界只有这一种。
|
||||
|
||||
原因:
|
||||
|
||||
- 一炮多响会直接放大以下复杂度:
|
||||
- 多赢家结算
|
||||
- 胡牌先后次序
|
||||
- 胡后谁退出牌局、谁继续
|
||||
- 多人同时胡后下一个行动座位怎么定
|
||||
- 当前项目还未完成:
|
||||
- `HU` 多赢家是血战到底中较常见、且用户感知较强的规则
|
||||
- 当前后端已经具备:
|
||||
- 胡牌判定
|
||||
- 基础结算
|
||||
- 胡后继续行牌完整链路
|
||||
- 胡后继续行牌主链
|
||||
- 结算历史沉淀
|
||||
- 但如果把 `PENG / GANG` 也做成多赢家分支,会明显抬高后续行牌复杂度
|
||||
|
||||
所以 `V1` 先采用:
|
||||
所以 `V1` 采用:
|
||||
|
||||
- `单窗口单胜出动作`
|
||||
- `HU` 可多赢家同窗裁决
|
||||
- `PENG / GANG` 仍然单赢家裁决
|
||||
|
||||
后续若要升级到一炮多响,应作为 `V2` 明确需求,不应在当前实现里偷偷混入。
|
||||
这样做的好处是:
|
||||
|
||||
- 保住高价值规则一致性
|
||||
- 又不破坏现有统一动作入口、统一响应窗口和统一结算出口
|
||||
- 仍符合 `KISS`
|
||||
|
||||
---
|
||||
|
||||
@@ -190,16 +194,21 @@
|
||||
|
||||
#### V1
|
||||
|
||||
- 不实现完整 `过水不胡`
|
||||
- 只保证“同一响应窗口内,PASS 后不能反悔”
|
||||
- 不实现完整地方化 `过水不胡`
|
||||
- 当前只实现“最小正式版”:
|
||||
- 玩家在响应窗口里本可 `HU` 但选择 `PASS` 时,记录一次响应禁胡状态
|
||||
- 在该玩家下一次真正摸牌前,后续弃牌胡与抢杠胡都不再给出 `HU` 候选
|
||||
- 下一次真正摸牌后解除限制
|
||||
- 不影响 `PENG / GANG`
|
||||
- 不影响自摸胡
|
||||
|
||||
#### V2
|
||||
|
||||
- 评估是否加入:
|
||||
- 玩家错过可胡后,直到自己下一次摸牌或动牌前不能再胡
|
||||
- 是否仅限制同张牌
|
||||
- 是否限制同一路听牌
|
||||
- 是否允许加番后破除限制
|
||||
- 是否需要把“解除时机”细化为摸牌、碰杠、换巡或加番后解除
|
||||
|
||||
---
|
||||
|
||||
@@ -323,20 +332,25 @@
|
||||
- 新动作基础校验
|
||||
- 新动作事件模型
|
||||
- 响应候选模型
|
||||
- 响应窗口停顿与关闭
|
||||
- 基于窗口的多人响应收集
|
||||
- `HU` 候选生成与胡牌判定
|
||||
- `HU` 的一炮多响裁决
|
||||
- 抢杠胡窗口
|
||||
- 最小正式版 `过水不胡`
|
||||
- 结构化私有动作消息
|
||||
- H5 原型页候选消息展示占位
|
||||
|
||||
当前尚未完成:
|
||||
|
||||
- 真正的响应窗口停顿
|
||||
- 基于窗口的多人响应收集
|
||||
- 优先级裁决器
|
||||
- `HU` 候选生成与胡牌判定
|
||||
- 前端正式动作面板联调
|
||||
- 更完整的地方化 `过水不胡`
|
||||
- 规则模式配置层
|
||||
|
||||
因此本文件的直接用途是:
|
||||
因此本文件当前的直接用途是:
|
||||
|
||||
- 让下一步裁决实现有明确规则依据
|
||||
- 降低“每轮新对话都重新讨论优先级”的沟通成本
|
||||
- 让后续前后端联调和规则扩展有明确规则依据
|
||||
- 降低“每轮新对话都重新讨论响应口径”的沟通成本
|
||||
|
||||
---
|
||||
|
||||
@@ -346,12 +360,12 @@
|
||||
|
||||
1. 血战到底按成都麻将大框架实现
|
||||
2. 不可吃,只处理 `碰 / 杠 / 胡 / 过`
|
||||
3. 当前第一阶段只围绕“弃牌后响应”建窗口
|
||||
3. 当前响应窗口覆盖“弃牌后响应”与“补杠后的抢杠胡响应”
|
||||
4. 优先级固定为 `HU > GANG > PENG > PASS`
|
||||
5. 同优先级按出牌者之后最近顺位优先
|
||||
5. 同优先级按出牌者之后最近顺位优先,且 `HU` 支持一炮多响
|
||||
6. `PASS` 仅放弃当前窗口
|
||||
7. 当前不实现完整 `过水不胡`
|
||||
8. 当前不实现 `一炮多响`
|
||||
7. 当前实现最小正式版 `过水不胡`,解除时机为该玩家下一次真正摸牌
|
||||
8. 当前只对 `HU` 实现一炮多响,`PENG / GANG` 仍保持单赢家裁决
|
||||
|
||||
如果后续要改这些口径,必须先更新本文件,再改代码。
|
||||
|
||||
|
||||
@@ -303,8 +303,8 @@ Sprint 目标:
|
||||
- 明确了本项目 `V1` 的同优先级裁决:
|
||||
- 按出牌者之后最近顺位优先
|
||||
- 明确了本项目 `V1` 的工程取舍:
|
||||
- 当前不实现完整 `过水不胡`
|
||||
- 当前不实现 `一炮多响`
|
||||
- 当前只实现最小正式版 `过水不胡`
|
||||
- 当前只对 `HU` 实现 `一炮多响`
|
||||
- 明确了公共消息与私有消息的职责边界
|
||||
- 明确了后续真实响应窗口接入主流程的推荐顺序
|
||||
|
||||
|
||||
@@ -95,9 +95,15 @@ type PrivateTeachingMessage = {
|
||||
explanation: string
|
||||
}
|
||||
|
||||
type ViewUserOption = {
|
||||
userId: string
|
||||
label: string
|
||||
seatNo: number
|
||||
}
|
||||
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const info = ref('H5 房间流原型已就位,现在会在进入对局后自动订阅 WebSocket 公共事件和私有消息。')
|
||||
const info = ref('H5 对局原型已接入公共事件、私有动作与私有教学消息,当前开始补正式动作面板。')
|
||||
|
||||
const ownerId = ref('host-1')
|
||||
const ownerName = ref('房主')
|
||||
@@ -129,6 +135,38 @@ const actionScopeLabelMap: Record<string, string> = {
|
||||
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 canDiscard = computed(
|
||||
() =>
|
||||
@@ -137,14 +175,117 @@ const canDiscard = computed(
|
||||
game.value.selfSeat.playerId === currentUserId.value &&
|
||||
game.value.currentSeatNo === game.value.selfSeat.seatNo
|
||||
)
|
||||
|
||||
const publicSeats = computed(() => game.value?.seats ?? [])
|
||||
|
||||
const privateActionCandidates = computed(() => privateAction.value?.candidates ?? [])
|
||||
|
||||
const privateActionSummary = computed(() => {
|
||||
if (!privateAction.value) {
|
||||
return ''
|
||||
}
|
||||
const scopeLabel = actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope
|
||||
return `${scopeLabel}:${privateAction.value.availableActions.join(' / ')}`
|
||||
const readableActions = privateAction.value.availableActions.map(toActionLabel)
|
||||
return `${scopeLabel}:${readableActions.join(' / ')}`
|
||||
})
|
||||
|
||||
const turnActionCandidates = computed(() => {
|
||||
if (privateAction.value?.actionScope !== 'TURN') {
|
||||
return []
|
||||
}
|
||||
// 当前回合的出牌依然直接通过点击手牌触发,动作面板只承载额外动作,避免一个动作出现两套入口。
|
||||
return privateAction.value.candidates.filter((candidate) => candidate.actionType !== 'DISCARD')
|
||||
})
|
||||
|
||||
const responseActionCandidates = computed(() => {
|
||||
if (privateAction.value?.actionScope !== 'RESPONSE') {
|
||||
return []
|
||||
}
|
||||
return privateAction.value.candidates
|
||||
})
|
||||
|
||||
const responseContextSummary = computed(() => {
|
||||
if (!privateAction.value || privateAction.value.actionScope !== 'RESPONSE') {
|
||||
return ''
|
||||
}
|
||||
const triggerLabel = privateAction.value.triggerEventType
|
||||
? triggerEventTypeLabelMap[privateAction.value.triggerEventType] ?? privateAction.value.triggerEventType
|
||||
: '响应窗口'
|
||||
const sourceSeatLabel =
|
||||
privateAction.value.sourceSeatNo !== null ? `来源座位 ${privateAction.value.sourceSeatNo}` : '来源座位未知'
|
||||
const triggerTileLabel = privateAction.value.triggerTile ? `目标牌 ${privateAction.value.triggerTile}` : '未携带目标牌'
|
||||
return `${triggerLabel},${sourceSeatLabel},${triggerTileLabel}`
|
||||
})
|
||||
|
||||
const actionPanelHint = computed(() => {
|
||||
if (!privateAction.value) {
|
||||
return '等待下一条私有动作消息。收到回合动作或响应动作后,这里会自动切换到对应面板。'
|
||||
}
|
||||
if (privateAction.value.actionScope === 'TURN') {
|
||||
return '出牌通过上方手牌直接执行;若当前可自摸胡或可杠,则在这里统一提交。'
|
||||
}
|
||||
return `${responseContextSummary.value}。请在当前响应窗口关闭前完成选择。`
|
||||
})
|
||||
|
||||
const currentViewLabel = computed(() => {
|
||||
if (!game.value) {
|
||||
return currentUserId.value
|
||||
}
|
||||
return `${game.value.selfSeat.nickname} · 座位 ${game.value.selfSeat.seatNo}`
|
||||
})
|
||||
|
||||
const viewUserOptions = computed<ViewUserOption[]>(() => {
|
||||
if (game.value) {
|
||||
const options = new Map<string, ViewUserOption>()
|
||||
options.set(game.value.selfSeat.playerId, {
|
||||
userId: game.value.selfSeat.playerId,
|
||||
label: `${game.value.selfSeat.nickname} · 座位 ${game.value.selfSeat.seatNo}`,
|
||||
seatNo: game.value.selfSeat.seatNo
|
||||
})
|
||||
for (const seat of game.value.seats) {
|
||||
options.set(seat.playerId, {
|
||||
userId: seat.playerId,
|
||||
label: `${seat.nickname} · 座位 ${seat.seatNo}`,
|
||||
seatNo: seat.seatNo
|
||||
})
|
||||
}
|
||||
return [...options.values()].sort((left, right) => left.seatNo - right.seatNo)
|
||||
}
|
||||
|
||||
return [
|
||||
{ userId: ownerId.value, label: `${ownerName.value} · 房主`, seatNo: 0 },
|
||||
{ userId: joinUserId.value, label: `${joinUserName.value} · 玩家二`, seatNo: 1 }
|
||||
]
|
||||
})
|
||||
|
||||
const actionDiagnosticItems = computed(() => {
|
||||
if (!privateAction.value) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: 'scope',
|
||||
label: '作用域',
|
||||
value: actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope
|
||||
},
|
||||
{
|
||||
key: 'window',
|
||||
label: '窗口 ID',
|
||||
value: privateAction.value.windowId ? privateAction.value.windowId.slice(0, 8) : '当前回合'
|
||||
},
|
||||
{
|
||||
key: 'event',
|
||||
label: '触发来源',
|
||||
value: privateAction.value.triggerEventType
|
||||
? triggerEventTypeLabelMap[privateAction.value.triggerEventType] ?? privateAction.value.triggerEventType
|
||||
: '当前回合'
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: '来源座位',
|
||||
value: privateAction.value.sourceSeatNo !== null ? `座位 ${privateAction.value.sourceSeatNo}` : '无'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
@@ -208,9 +349,10 @@ function connectWs(gameId: string, userId: string) {
|
||||
wsStatus.value = 'connected'
|
||||
client.subscribe(`/topic/games/${gameId}/events`, (message: IMessage) => {
|
||||
const payload = JSON.parse(message.body) as PublicGameMessage
|
||||
publicEvents.value = [payload, ...publicEvents.value].slice(0, 16)
|
||||
handlePublicEvent(payload)
|
||||
})
|
||||
client.subscribe(`/topic/users/${userId}/actions`, (message: IMessage) => {
|
||||
// 私有动作消息是前端动作面板的唯一事实来源,后续所有按钮启用状态都以这里为准。
|
||||
privateAction.value = JSON.parse(message.body) as PrivateActionMessage
|
||||
})
|
||||
client.subscribe(`/topic/users/${userId}/teaching`, (message: IMessage) => {
|
||||
@@ -236,6 +378,7 @@ function connectWs(gameId: string, userId: string) {
|
||||
watch(
|
||||
() => [game.value?.gameId, currentUserId.value] as const,
|
||||
([gameId, userId]) => {
|
||||
// 视角一旦切换,必须重新订阅对应用户的私有主题,避免沿用上一个玩家的动作/教学消息。
|
||||
if (gameId) {
|
||||
connectWs(gameId, userId)
|
||||
} else {
|
||||
@@ -340,6 +483,27 @@ async function refreshGameState() {
|
||||
})
|
||||
}
|
||||
|
||||
async function switchUserView(userId: string) {
|
||||
if (currentUserId.value === userId) {
|
||||
return
|
||||
}
|
||||
|
||||
currentUserId.value = userId
|
||||
|
||||
if (!game.value) {
|
||||
info.value = `已切换到 ${userId} 视角。`
|
||||
return
|
||||
}
|
||||
|
||||
const targetGameId = game.value.gameId
|
||||
await runTask(async () => {
|
||||
game.value = await requestJson<GameStateResponse>(
|
||||
`/api/games/${targetGameId}/state?userId=${encodeURIComponent(userId)}`
|
||||
)
|
||||
info.value = `已切换到 ${userId} 视角,并同步最新对局状态。`
|
||||
})
|
||||
}
|
||||
|
||||
async function submitAction(actionType: string, tile?: string, sourceSeatNo?: number | null) {
|
||||
if (!game.value) {
|
||||
return
|
||||
@@ -355,7 +519,17 @@ async function submitAction(actionType: string, tile?: string, sourceSeatNo?: nu
|
||||
sourceSeatNo: sourceSeatNo ?? null
|
||||
})
|
||||
})
|
||||
info.value = actionType === 'DISCARD' ? `已打出 ${tile}。` : `已提交动作 ${actionType}。`
|
||||
|
||||
const readableAction = toActionLabel(actionType)
|
||||
if (actionType === 'DISCARD' && tile) {
|
||||
info.value = `已打出 ${tile}。`
|
||||
return
|
||||
}
|
||||
if (tile) {
|
||||
info.value = `已提交 ${readableAction} ${tile}。`
|
||||
return
|
||||
}
|
||||
info.value = `已提交动作 ${readableAction}。`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -371,6 +545,153 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
const sourceSeatNo = privateAction.value?.sourceSeatNo ?? null
|
||||
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) {
|
||||
// 公共事件除了进入时间线,也负责驱动前端把已经失效的私有动作面板收起来。
|
||||
if (shouldClearPrivateActionByEvent(event)) {
|
||||
privateAction.value = null
|
||||
}
|
||||
publicEvents.value = [event, ...publicEvents.value].slice(0, 16)
|
||||
}
|
||||
|
||||
function shouldClearPrivateActionByEvent(event: PublicGameMessage) {
|
||||
if (!privateAction.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentAction = privateAction.value
|
||||
const selfSeatNo = game.value?.selfSeat.seatNo ?? null
|
||||
|
||||
if (event.eventType === 'RESPONSE_WINDOW_CLOSED') {
|
||||
const sourceSeatNo = readNumber(event.payload.sourceSeatNo)
|
||||
return currentAction.actionScope === 'RESPONSE' && sourceSeatNo === currentAction.sourceSeatNo
|
||||
}
|
||||
|
||||
if (event.eventType === 'GAME_PHASE_CHANGED') {
|
||||
return readString(event.payload.phase) !== 'PLAYING'
|
||||
}
|
||||
|
||||
if (event.eventType === 'TILE_DISCARDED') {
|
||||
return currentAction.actionScope === 'TURN' && selfSeatNo !== null && event.seatNo === selfSeatNo
|
||||
}
|
||||
|
||||
if (event.eventType === 'TURN_SWITCHED') {
|
||||
const nextSeatNo = readNumber(event.payload.currentSeatNo)
|
||||
return currentAction.actionScope === 'TURN' && selfSeatNo !== null && nextSeatNo !== selfSeatNo
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -378,9 +699,9 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
<section class="hero-panel">
|
||||
<div>
|
||||
<p class="eyebrow">XueZhanMaster H5</p>
|
||||
<h1>血战大师移动端房间流原型</h1>
|
||||
<h1>血战大师移动端对局台</h1>
|
||||
<p class="intro">
|
||||
当前页面已切成 H5 操作台,并接入 WebSocket 骨架。目标是让后续手机端新对话也能快速恢复上下文:房间流、对局流、消息流都能看见。
|
||||
当前页面不再只做房间流演示,而是开始承担正式联调职责:公共事件、私有动作、私有教学已经连通,下面的动作面板会按“当前回合动作”和“响应动作”自动切换。
|
||||
</p>
|
||||
</div>
|
||||
<div class="signal-card">
|
||||
@@ -420,8 +741,8 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
</label>
|
||||
<div class="btn-row">
|
||||
<button class="secondary-btn" @click="loadRoom">刷新房间</button>
|
||||
<button class="secondary-btn" @click="currentUserId = ownerId">切到房主视角</button>
|
||||
<button class="secondary-btn" @click="currentUserId = joinUserId">切到玩家二视角</button>
|
||||
<button class="secondary-btn" @click="switchUserView(ownerId)">切到房主视角</button>
|
||||
<button class="secondary-btn" @click="switchUserView(joinUserId)">切到玩家二视角</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -487,7 +808,7 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section class="play-grid" v-if="game">
|
||||
<section v-if="game" class="play-grid">
|
||||
<article class="panel game-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
@@ -504,7 +825,7 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
</div>
|
||||
<div>
|
||||
<span class="meta-label">当前视角</span>
|
||||
<strong>{{ currentUserId }}</strong>
|
||||
<strong>{{ currentViewLabel }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="meta-label">剩余牌墙</span>
|
||||
@@ -512,6 +833,24 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view-switch-card">
|
||||
<div class="section-title">
|
||||
<strong>视角切换</strong>
|
||||
<span class="mini-pill">点击即刷新该玩家视图</span>
|
||||
</div>
|
||||
<div class="view-switch-list">
|
||||
<button
|
||||
v-for="option in viewUserOptions"
|
||||
:key="option.userId"
|
||||
class="view-switch-chip"
|
||||
:class="{ active: option.userId === currentUserId }"
|
||||
@click="switchUserView(option.userId)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row vertical-on-mobile">
|
||||
<button class="secondary-btn" @click="refreshGameState">刷新对局</button>
|
||||
<label class="field compact-field">
|
||||
@@ -546,11 +885,72 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
</button>
|
||||
</div>
|
||||
<div class="discard-row" v-if="game.selfSeat.melds.length > 0">
|
||||
<span v-for="(meld, index) in game.selfSeat.melds" :key="`self-meld-${meld}-${index}`" class="discard-chip">
|
||||
<span
|
||||
v-for="(meld, index) in game.selfSeat.melds"
|
||||
:key="`self-meld-${meld}-${index}`"
|
||||
class="discard-chip"
|
||||
>
|
||||
{{ meld }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-dock">
|
||||
<div class="section-title">
|
||||
<strong>动作面板</strong>
|
||||
<span class="mini-pill">{{ privateActionSummary || '等待动作' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="privateAction" class="action-panel" :class="{ 'response-panel': privateAction.actionScope === 'RESPONSE' }">
|
||||
<div class="mini-tags action-meta-row">
|
||||
<span class="mini-tag">当前操作座位 {{ privateAction.currentSeatNo }}</span>
|
||||
<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 class="panel board-panel">
|
||||
@@ -567,29 +967,17 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
<span class="meta-label">私有动作消息</span>
|
||||
<template v-if="privateAction">
|
||||
<strong>{{ privateActionSummary }}</strong>
|
||||
<div class="mini-tags action-meta-row">
|
||||
<span class="mini-tag">座位 {{ privateAction.currentSeatNo }}</span>
|
||||
<span class="mini-tag">{{ actionScopeLabelMap[privateAction.actionScope] ?? privateAction.actionScope }}</span>
|
||||
<span v-if="privateAction.triggerTile" class="mini-tag">目标牌 {{ privateAction.triggerTile }}</span>
|
||||
<span v-if="privateAction.sourceSeatNo !== null" class="mini-tag">来源座位 {{ privateAction.sourceSeatNo }}</span>
|
||||
<div class="message-grid">
|
||||
<div v-for="item in actionDiagnosticItems" :key="item.key" class="metric-cell">
|
||||
<span class="meta-label">{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
<div class="candidate-list" v-if="privateActionCandidates.length > 0">
|
||||
<button
|
||||
v-for="(candidate, index) in privateActionCandidates"
|
||||
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
|
||||
class="candidate-chip"
|
||||
type="button"
|
||||
@click="submitCandidateAction(candidate.actionType, candidate.tile)"
|
||||
>
|
||||
{{ candidate.actionType }}<template v-if="candidate.tile"> · {{ candidate.tile }}</template>
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="privateAction.actionScope === 'RESPONSE'" class="message-copy">
|
||||
当前原型页已能识别响应候选消息,后续会继续补正式动作面板和真实响应流程。
|
||||
</span>
|
||||
<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>
|
||||
@@ -635,10 +1023,18 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
<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>{{ event.eventType }}</strong>
|
||||
<span class="mini-pill">{{ event.seatNo ?? '-' }}</span>
|
||||
<strong>{{ formatPublicEventTitle(event) }}</strong>
|
||||
<span class="mini-pill">{{ formatEventTime(event.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="message-copy">{{ JSON.stringify(event.payload) }}</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>
|
||||
|
||||
@@ -169,7 +169,9 @@ h2 {
|
||||
.form-card,
|
||||
.self-card,
|
||||
.placeholder-card,
|
||||
.seat-card {
|
||||
.seat-card,
|
||||
.view-switch-card,
|
||||
.action-dock {
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
background: var(--paper-strong);
|
||||
@@ -178,6 +180,8 @@ h2 {
|
||||
|
||||
.form-card + .form-card,
|
||||
.self-card,
|
||||
.view-switch-card,
|
||||
.action-dock,
|
||||
.room-meta,
|
||||
.seat-list,
|
||||
.tile-grid {
|
||||
@@ -263,6 +267,29 @@ h2 {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.view-switch-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.view-switch-chip {
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(113, 82, 47, 0.18);
|
||||
background: rgba(182, 69, 31, 0.08);
|
||||
color: var(--accent-deep);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.view-switch-chip.active {
|
||||
background: linear-gradient(180deg, #cc5a31 0%, #b6451f 100%);
|
||||
color: #fffaf2;
|
||||
box-shadow: 0 14px 28px rgba(182, 69, 31, 0.18);
|
||||
}
|
||||
|
||||
.seat-top,
|
||||
.section-title {
|
||||
display: flex;
|
||||
@@ -326,6 +353,13 @@ h2 {
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.timeline-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.message-copy {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
@@ -333,6 +367,45 @@ h2 {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.payload-details {
|
||||
margin-top: 12px;
|
||||
border-top: 1px dashed rgba(113, 82, 47, 0.18);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.payload-details summary {
|
||||
cursor: pointer;
|
||||
color: var(--accent-deep);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.payload-code {
|
||||
margin: 10px 0 0;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(35, 23, 15, 0.04);
|
||||
color: var(--muted);
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.action-panel {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(255, 251, 244, 0.98) 0%, rgba(246, 236, 220, 0.92) 100%);
|
||||
border: 1px solid rgba(113, 82, 47, 0.16);
|
||||
}
|
||||
|
||||
.response-panel {
|
||||
background: linear-gradient(180deg, rgba(255, 244, 234, 0.98) 0%, rgba(249, 227, 205, 0.95) 100%);
|
||||
border-color: rgba(182, 69, 31, 0.2);
|
||||
}
|
||||
|
||||
.action-meta-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -348,8 +421,8 @@ h2 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(113, 82, 47, 0.18);
|
||||
background: linear-gradient(180deg, rgba(255, 247, 235, 0.98) 0%, rgba(241, 224, 196, 0.92) 100%);
|
||||
@@ -358,6 +431,77 @@ h2 {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.action-call-chip {
|
||||
box-shadow: 0 10px 22px rgba(113, 82, 47, 0.08);
|
||||
}
|
||||
|
||||
.helper-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.helper-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(107, 89, 73, 0.08);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.response-banner {
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(182, 69, 31, 0.08);
|
||||
border: 1px solid rgba(182, 69, 31, 0.16);
|
||||
}
|
||||
|
||||
.response-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.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.response-panel-body .candidate-list {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.action-placeholder {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.message-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.metric-cell {
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(182, 69, 31, 0.06);
|
||||
border: 1px solid rgba(113, 82, 47, 0.12);
|
||||
}
|
||||
|
||||
.metric-cell strong {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.compact-field {
|
||||
flex: 1;
|
||||
min-width: 110px;
|
||||
@@ -395,4 +539,8 @@ h2 {
|
||||
.vertical-on-mobile {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user