Compare commits

...

16 Commits

Author SHA1 Message Date
e495dc6070 feat(chengdu): 更新结算界面添加准备状态和房间管理功能
- 添加玩家准备状态显示和切换功能
- 实现房主控制下一局开始的游戏流程
- 添加退出房间按钮和相关状态管理
- 集成准备状态的计算逻辑和UI展示
- 更新组件props传递准备和房间状态数据
- 重构结算界面按钮布局和交互逻辑
2026-04-07 13:36:28 +08:00
3c876c4c3d feat(ws): 添加WebSocket客户端及房间状态处理功能
- 实现WebSocket客户端类,支持连接、发送消息、状态监听和自动重连
- 添加房间信息和状态的消息处理器,用于同步游戏状态和玩家数据
- 实现房间快照解析功能,处理玩家信息、游戏阶段、计时器等数据结构
- 集成会话状态适配器,管理房间倒计时、结算截止时间等状态变更
- 添加单例WebSocket客户端实例,统一管理WebSocket连接生命周期
2026-04-06 15:13:43 +08:00
cfc65070ea feat(game): 添加成都麻将游戏核心功能实现
- 定义游戏动作类型和载荷接口,包括摸牌、出牌、碰杠胡等操作
- 创建成都麻将游戏页面组件,集成桌面视图和玩家交互界面
- 实现游戏动作消息解析器,处理WebSocket消息转换为游戏动作
- 构建游戏状态管理store,管理玩家信息、回合状态和游戏流程
- 开发房间信息快照解析器,同步房间状态和玩家数据
- 实现房间状态快照解析,处理游戏阶段转换和玩家操作
2026-04-03 22:41:58 +08:00
0bf68d4e49 feat(chengdu): 添加socket消息解析和路由功能
- 创建SocketEnvelope接口和parseSocketEnvelope函数用于解析socket消息
- 实现createSocketMessageRouter函数用于创建消息路由器
- 配置多种消息类型的路由映射关系
- 支持房间、玩家、回合和状态相关的消息处理
- 提供统一的消息分发机制给相应的处理器
2026-04-03 20:48:31 +08:00
e96c45739e feat(game): 添加成都麻将游戏页面和大厅功能
- 实现 ChengduGamePage.vue 组件,包含完整的麻将游戏界面
- 实现 HallPage.vue 组件,支持房间列表展示、创建和加入功能
- 添加 mahjong API 接口用于房间管理操作
- 集成 store 状态管理和本地存储功能
- 实现 ChengduBottomActions 等游戏控制组件
- 添加 websocket 连接和游戏会话管理逻辑
- 实现游戏倒计时、结算等功能模块
2026-04-03 20:46:50 +08:00
39d85f2998 fix(game): 修正成都游戏页面阶段映射错误
- 将 ding_que 阶段从 playing 更正为 dealing
- 统一了两处相同的 phaseMap 配置修正
2026-04-03 15:53:52 +08:00
e6cba75f9b feat(game): 添加游戏状态管理store并统一就绪状态字段
- 在actions.ts中添加is_ready字段支持多种就绪状态标识
- 在ChengduGamePage.vue中更新就绪状态判断逻辑,优先使用is_ready字段
- 修改WebSocket消息类型从SET_READY到PLAYER_READY以保持一致性
- 更新就绪载荷中的用户ID字段从user_id到player_id
- 创建新的gameStore.ts文件实现完整的麻将游戏状态管理
- 添加房间玩家更新、托管设置、玩家回合等核心游戏逻辑处理
- 实现摸牌、出牌、操作窗口等游戏状态变更功能
- 统一处理多种数据格式的兼容性问题
2026-04-03 15:24:46 +08:00
6c3fca3530 feat(game): 添加玩家回合动作权限控制功能
- 在actions.ts中增加available_actions相关字段支持
- 移除未使用的watch导入和相关逻辑
- 新增selfTurnAllowActions响应式变量存储当前回合可执行动作
- 实现settlementOverlayDismissed控制结算弹窗显示状态
- 修改showSettlementOverlay计算属性加入弹窗已关闭条件判断
- 使用canSelfGang计算属性替代原有的concealedGangCandidates逻辑
- 新增readPlayerTurnAllowActions函数解析玩家回合允许的动作列表
- 实现readMissingSuitWithPresence函数增强缺门花色字段检测逻辑
- 更新玩家数据处理逻辑以兼容新的字段结构变化
- 调整游戏阶段映射增加ding_que到playing的转换支持
- 实现resetRoundStateForNextTurn函数重置回合状态
- 更新handlePlayerTurn消息处理逻辑
- 优化nextRound函数逻辑并设置结算弹窗为已关闭状态
- 简化submitSelfGang函数移除传入参数依赖
- 调整UI渲染逻辑适配新的动作权限控制模式
2026-04-01 17:27:35 +08:00
9b3e3fdb90 feat(game): 结算倒计时展示+自动下一局
- 解析后端settlement_deadline_ms,展示5秒结算倒计时
- 下一局按钮显示倒计时秒数(如"下一局 (3s)")
- 倒计时归零自动发送next_round(可提前点击)
- 新局开始时清除结算状态

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 14:02:42 +08:00
e435fa9d96 feat(game): 添加局间结算界面,对接后端settlement状态
- GameState新增currentRound/totalRounds字段
- 解析后端返回的current_round和total_rounds
- 新增结算弹窗:展示每位玩家得分、胡牌标记和排名
- 状态面板显示当前局数信息
- 新增next_round动作,结算后点击"下一局"继续游戏
- 最后一局结算后显示"返回大厅"按钮

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 11:34:40 +08:00
941d878931 feat(game): 添加自摸胡牌和暗杠功能支持
- 添加了 turnActionPending 状态管理当前回合动作状态
- 新增 canSelfHu 计算属性用于判断是否可以自摸胡牌
- 实现 concealedGangCandidates 计算属性计算可暗杠的牌面选项
- 添加 tileFaceKey 工具函数用于生成牌面键值
- 实现 clearTurnActionPending 和 markTurnActionPending 动作状态管理函数
- 新增牌面解析和胡牌判断相关辅助函数
- 修改 meld 解析逻辑支持数组格式的碰杠数据
- 在游戏状态更新时清理回合动作状态
- 添加 ACTION_ERROR 消息处理器处理操作错误
- 扩展 PENG/GANG/HU/PASS 消息解析支持
- 实现 submitConcealedGang 提交暗杠功能
- 实现 submitSelfHu 提交自摸胡牌功能
- 在 UI 界面添加暗杠和自摸胡牌按钮组件
- 集成 WebSocket 错误处理和状态清理逻辑
2026-04-01 10:26:53 +08:00
100d950eb8 feat(game): 添加对多种数据类型的布尔值解析支持
- 实现了数字类型(1/0)到布尔值的转换逻辑
- 添加了字符串类型('true'/'false', '1'/'0')到布尔值的转换
- 在ChengduGamePage.vue中使用readBoolean函数处理就绪状态
- 更新了游戏存储中的就绪状态解析逻辑
- 在发送就绪状态时同时包含ready和isReady字段
- 统一了布尔值判断逻辑,提高代码健壮性
2026-03-31 17:26:54 +08:00
06b25bde62 feat(game): 添加当前回合座位高亮功能
- 计算当前回合座位标识符
- 将活动位置传递给风向指示器组件
- 实现回合座位视觉反馈机制
2026-03-30 17:26:54 +08:00
2625baf266 feat(game): 实现出牌选择与计时功能
- 添加 PlayerTurnPayload 接口定义和 PLAYER_TURN 动作类型
- 实现选牌、出牌确认逻辑和相关状态管理
- 添加客户端出牌限制检查和错误提示
- 集成 PLAYER_TURN WebSocket 消息处理
- 添加房间状态面板显示游戏信息
- 优化桌面背景图片和样式布局
- 添加马蹄形动画样式文件
- 配置 Vite 别名和端口设置
2026-03-30 17:23:43 +08:00
43439cb09d Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	src/api/mahjong.ts
2026-03-29 23:56:55 +08:00
be9bd8c76d feat(game): 添加成都麻将房间配置和桌面牌面显示功能
- 在房间创建接口中添加总回合数配置选项
- 实现桌面弃牌区域的可视化展示,区分各玩家的弃牌和组合
- 添加缺门标识显示,帮助玩家识别缺门牌组起始位置
- 优化牌面操作状态管理,增加弃牌等待状态和超时处理机制
- 更新样式布局适配新的桌面牌面区域,调整墙体和桌面对齐方式
- 修复多处牌面状态同步问题,确保游戏流程中的界面一致性
2026-03-29 23:56:32 +08:00
48 changed files with 5169 additions and 2815 deletions

View File

@@ -31,6 +31,7 @@ HTTP 接口:
{ {
"name": "房间名", "name": "房间名",
"game_type": "chengdu", "game_type": "chengdu",
"total_rounds": 8,
"max_players": 4 "max_players": 4
} }
``` ```

View File

@@ -1,78 +1,79 @@
import { authedRequest, type AuthSession } from './authed-request' import {authedRequest, type AuthSession} from './authed-request'
export interface RoomItem { export interface Room {
room_id: string room_id: string
name: string name: string
game_type: string game_type: string
owner_id: string owner_id: string
max_players: number max_players: number
player_count: number player_count: number
players?: Array<{ players?: Array<{
index: number index: number
player_id: string player_id: string
player_name?: string player_name?: string
PlayerName?: string PlayerName?: string
ready: boolean ready: boolean
}> }>
status: string status: string
created_at: string created_at: string
updated_at: string updated_at: string
} }
export interface RoomListResult { export interface RoomListResult {
items: RoomItem[] items: Room[]
page: number page: number
size: number size: number
total: number total: number
} }
const ROOM_CREATE_PATH = const ROOM_CREATE_PATH =
import.meta.env.VITE_ROOM_CREATE_PATH ?? '/api/v1/game/mahjong/room/create' import.meta.env.VITE_ROOM_CREATE_PATH ?? '/api/v1/game/mahjong/room/create'
const ROOM_LIST_PATH = import.meta.env.VITE_ROOM_LIST_PATH ?? '/api/v1/game/mahjong/room/list' const ROOM_LIST_PATH = import.meta.env.VITE_ROOM_LIST_PATH ?? '/api/v1/game/mahjong/room/list'
const ROOM_JOIN_PATH = import.meta.env.VITE_ROOM_JOIN_PATH ?? '/api/v1/game/mahjong/room/join' const ROOM_JOIN_PATH = import.meta.env.VITE_ROOM_JOIN_PATH ?? '/api/v1/game/mahjong/room/join'
export async function createRoom( export async function createRoom(
auth: AuthSession, auth: AuthSession,
input: { name: string; gameType: string; maxPlayers: number }, input: { name: string; gameType: string; totalRounds: number; maxPlayers: number },
onAuthUpdated?: (next: AuthSession) => void, onAuthUpdated?: (next: AuthSession) => void,
): Promise<RoomItem> { ): Promise<Room> {
return authedRequest<RoomItem>({ return authedRequest<Room>({
method: 'POST', method: 'POST',
path: ROOM_CREATE_PATH, path: ROOM_CREATE_PATH,
auth, auth,
onAuthUpdated, onAuthUpdated,
body: { body: {
name: input.name, name: input.name,
game_type: input.gameType, game_type: input.gameType,
max_players: input.maxPlayers, total_rounds: input.totalRounds,
}, max_players: input.maxPlayers,
}) },
})
} }
export async function listRooms( export async function listRooms(
auth: AuthSession, auth: AuthSession,
onAuthUpdated?: (next: AuthSession) => void, onAuthUpdated?: (next: AuthSession) => void,
): Promise<RoomListResult> { ): Promise<RoomListResult> {
return authedRequest<RoomListResult>({ return authedRequest<RoomListResult>({
method: 'GET', method: 'GET',
path: ROOM_LIST_PATH, path: ROOM_LIST_PATH,
auth, auth,
onAuthUpdated, onAuthUpdated,
}) })
} }
export async function joinRoom( export async function joinRoom(
auth: AuthSession, auth: AuthSession,
input: { roomId: string }, input: { roomId: string },
onAuthUpdated?: (next: AuthSession) => void, onAuthUpdated?: (next: AuthSession) => void,
): Promise<RoomItem> { ): Promise<Room> {
return authedRequest<RoomItem>({ return authedRequest<Room>({
method: 'POST', method: 'POST',
path: ROOM_JOIN_PATH, path: ROOM_JOIN_PATH,
auth, auth,
onAuthUpdated, onAuthUpdated,
body: { body: {
room_id: input.roomId, room_id: input.roomId,
}, },
}) })
} }

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

View File

@@ -1,8 +1,7 @@
.picture-scene { .picture-scene {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
padding: 18px; padding: 0;
background: background:
radial-gradient(circle at top, rgba(116, 58, 41, 0.28), transparent 20%), radial-gradient(circle at top, rgba(116, 58, 41, 0.28), transparent 20%),
linear-gradient(180deg, #3f2119 0%, #27140f 100%); linear-gradient(180deg, #3f2119 0%, #27140f 100%);
@@ -10,44 +9,51 @@
.picture-layout { .picture-layout {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 320px; grid-template-columns: minmax(0, 1fr);
gap: 18px; gap: 0;
align-items: stretch; align-items: stretch;
min-height: calc(100vh - 36px); min-height: 100vh;
min-height: 100dvh;
} }
.table-stage { .picture-scene .table-stage {
position: relative; position: relative;
display: grid; display: grid;
place-items: center; place-items: center;
align-content: start; align-content: stretch;
width: 100%; width: 100%;
min-height: calc(100vh - 36px); min-height: 100vh;
min-height: 100dvh;
overflow: hidden;
} }
.table-desk, .picture-scene .table-desk {
.table-felt { position: absolute;
width: 100%; inset: 0;
max-width: 100%;
max-height: calc(100dvh - 72px);
aspect-ratio: 16 / 9;
}
.table-desk {
grid-area: 1 / 1;
display: block; display: block;
margin-top: 18px; width: 100%;
border-radius: 26px; height: 100%;
object-fit: cover; object-fit: cover;
object-position: center;
border-radius: 0;
box-shadow: 0 24px 44px rgba(0, 0, 0, 0.34); box-shadow: 0 24px 44px rgba(0, 0, 0, 0.34);
} }
.table-felt { .picture-scene .table-felt {
grid-area: 1 / 1; position: absolute;
position: relative; inset: 0;
margin-top: 18px; width: 100%;
border-radius: 26px; height: 100%;
max-width: none;
max-height: none;
min-height: 100vh;
min-height: 100dvh;
aspect-ratio: auto;
margin-top: 0;
border-radius: 0;
overflow: hidden; overflow: hidden;
justify-self: stretch;
align-self: stretch;
} }
.table-surface { .table-surface {
@@ -312,10 +318,57 @@
z-index: 5; z-index: 5;
} }
.action-countdown { .room-status-panel {
position: absolute; position: absolute;
top: 92px; top: 92px;
right: 40px; right: 40px;
width: min(320px, calc(100% - 80px));
padding: 12px;
border: 1px solid rgba(255, 226, 175, 0.12);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(45, 24, 18, 0.82), rgba(26, 14, 11, 0.88)),
radial-gradient(circle at top, rgba(255, 219, 154, 0.05), transparent 44%);
box-shadow: 0 14px 26px rgba(0, 0, 0, 0.22);
z-index: 5;
}
.room-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.room-status-item {
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
}
.room-status-item span {
display: block;
font-size: 11px;
color: rgba(244, 233, 208, 0.62);
}
.room-status-item strong {
display: block;
margin-top: 6px;
color: #fff0c2;
font-size: 15px;
word-break: break-word;
}
.room-status-error {
margin-top: 10px;
color: #ffc1c1;
font-size: 13px;
}
.action-countdown {
position: absolute;
top: 210px;
right: 40px;
min-width: 188px; min-width: 188px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid rgba(255, 219, 131, 0.22); border: 1px solid rgba(255, 219, 131, 0.22);
@@ -625,12 +678,12 @@
} }
.wall-right { .wall-right {
right: 110px; right: 140px;
gap: 0; gap: 0;
} }
.wall-left { .wall-left {
left: 110px; left: 140px;
} }
.wall-left img, .wall-left img,
@@ -707,11 +760,32 @@
} }
.wall-live-tile-button { .wall-live-tile-button {
position: relative;
padding: 0; padding: 0;
border: 0; border: 0;
background: transparent; background: transparent;
appearance: none; appearance: none;
cursor: pointer; cursor: pointer;
transform: translateY(0);
transition: transform 150ms cubic-bezier(0.22, 0.82, 0.32, 1), filter 150ms ease-out;
}
.wall-live-tile-lack-tag {
position: absolute;
top: 21px;
left: 5px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 6px;
color: #fff8e8;
font-size: 10px;
line-height: 16px;
font-weight: 800;
background: linear-gradient(180deg, rgba(200, 56, 41, 0.95), rgba(137, 25, 14, 0.96));
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
z-index: 2;
pointer-events: none;
} }
.wall-live-tile-button:disabled { .wall-live-tile-button:disabled {
@@ -719,6 +793,21 @@
opacity: 1; opacity: 1;
} }
.wall-live-tile-button:not(:disabled):hover {
transform: translateY(-4px);
}
.wall-live-tile-button.is-selected {
transform: translateY(-18px);
z-index: 3;
}
.wall-live-tile-button.is-selected .wall-live-tile {
filter:
drop-shadow(0 14px 18px rgba(0, 0, 0, 0.24))
drop-shadow(0 0 10px rgba(255, 214, 111, 0.42));
}
.wall-live-tile-button:disabled .wall-live-tile { .wall-live-tile-button:disabled .wall-live-tile {
opacity: 1; opacity: 1;
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.18)); filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.18));
@@ -772,6 +861,116 @@
left: 110px; left: 110px;
} }
.desk-zone {
position: absolute;
display: flex;
gap: 0;
max-width: 280px;
max-height: 220px;
pointer-events: none;
z-index: 2;
filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.2));
}
.desk-zone-top,
.desk-zone-bottom {
left: 50%;
transform: translateX(-50%);
}
.desk-zone-top {
top: 208px;
}
.desk-zone-bottom {
bottom: 220px;
}
.desk-zone-left,
.desk-zone-right {
top: 50%;
transform: translateY(-50%);
flex-direction: column;
}
.desk-zone-left {
left: 240px;
}
.desk-zone-right {
right: 240px;
}
.desk-tile {
display: block;
object-fit: contain;
}
.desk-zone-top .desk-tile,
.desk-zone-bottom .desk-tile {
width: 30px;
height: 44px;
}
.desk-zone-left .desk-tile,
.desk-zone-right .desk-tile {
width: 44px;
height: 30px;
}
.desk-zone-top .desk-tile + .desk-tile {
margin-left: -0px;
}
.desk-zone-bottom .desk-tile + .desk-tile {
margin-left: -2px;
}
.desk-zone-left .desk-tile + .desk-tile,
.desk-zone-right .desk-tile + .desk-tile {
margin-top: -14px;
}
.desk-zone-top .desk-tile.is-group-start,
.desk-zone-bottom .desk-tile.is-group-start {
margin-left: 6px;
}
.desk-zone-bottom .desk-tile.is-group-start {
margin-left: 13px;
}
.desk-zone-left .desk-tile.is-group-start,
.desk-zone-right .desk-tile.is-group-start {
margin-top: 7px;
}
.desk-tile.is-covered {
opacity: 0.95;
}
.desk-hu-flag {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
margin-left: 6px;
padding: 0 7px;
border-radius: 999px;
color: #fff3da;
font-size: 12px;
font-weight: 800;
background: linear-gradient(180deg, rgba(219, 81, 56, 0.92), rgba(146, 32, 20, 0.96));
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.22);
}
.desk-zone-left .desk-hu-flag,
.desk-zone-right .desk-hu-flag {
margin-top: 6px;
margin-left: 0;
}
.center-wind-square { .center-wind-square {
position: absolute; position: absolute;
left: 50%; left: 50%;
@@ -1013,6 +1212,18 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.discard-confirm-button {
min-width: 168px;
background:
linear-gradient(180deg, rgba(110, 32, 20, 0.94), rgba(72, 16, 9, 0.98)),
radial-gradient(circle at 20% 24%, rgba(255, 214, 153, 0.14), transparent 34%);
border-color: rgba(255, 184, 112, 0.34);
box-shadow:
inset 0 1px 0 rgba(255, 232, 205, 0.14),
inset 0 -1px 0 rgba(0, 0, 0, 0.28),
0 12px 22px rgba(0, 0, 0, 0.26);
}
.hand-action-bar { .hand-action-bar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1228,126 +1439,9 @@
background: radial-gradient(circle at 35% 28%, #fff6c2 0%, #ffe16c 42%, #e3aa23 100%); background: radial-gradient(circle at 35% 28%, #fff6c2 0%, #ffe16c 42%, #e3aa23 100%);
} }
.ws-sidebar {
display: flex;
flex-direction: column;
height: calc(100vh - 36px);
min-height: calc(100vh - 36px);
padding: 16px;
border-radius: 18px;
border: 1px solid rgba(255, 226, 175, 0.12);
background:
linear-gradient(180deg, rgba(45, 24, 18, 0.94), rgba(26, 14, 11, 0.96)),
radial-gradient(circle at top, rgba(255, 219, 154, 0.06), transparent 40%);
box-shadow: 0 16px 28px rgba(0, 0, 0, 0.22);
}
.sidebar-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.sidebar-title {
font-size: 18px;
font-weight: 800;
color: #ffe2a0;
}
.sidebar-head small {
color: rgba(248, 233, 199, 0.68);
}
.sidebar-btn {
min-width: 76px;
height: 38px;
border: 1px solid rgba(255, 223, 164, 0.16);
border-radius: 999px;
color: #ffe9b7;
background: rgba(0, 0, 0, 0.18);
}
.sidebar-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 16px;
}
.sidebar-stat {
padding: 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
}
.sidebar-stat span {
display: block;
font-size: 11px;
color: rgba(244, 233, 208, 0.62);
}
.sidebar-stat strong {
display: block;
margin-top: 6px;
color: #fff0c2;
font-size: 15px;
word-break: break-word;
}
.sidebar-error {
margin-top: 14px;
color: #ffc1c1;
font-size: 13px;
}
.sidebar-log {
flex: 1 1 auto;
margin-top: 14px;
padding: 12px;
border-radius: 14px;
background: rgba(9, 12, 19, 0.34);
overflow: auto;
}
.sidebar-empty,
.sidebar-line {
font-size: 12px;
color: #e6eef8;
line-height: 1.5;
}
.sidebar-line + .sidebar-line {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
@media (max-width: 1280px) {
.picture-layout {
grid-template-columns: 1fr;
}
.table-desk,
.table-felt {
width: min(100%, calc((100dvh - 290px) * 16 / 9));
}
.ws-sidebar {
height: auto;
min-height: 240px;
}
}
@media (max-width: 980px) { @media (max-width: 980px) {
.picture-scene { .picture-scene {
padding: 10px; padding: 0;
}
.table-desk,
.table-felt {
width: 100%;
margin-top: 8px;
} }
.wall-right { .wall-right {
@@ -1358,6 +1452,22 @@
left: 88px; left: 88px;
} }
.desk-zone-top {
top: 196px;
}
.desk-zone-bottom {
bottom: 208px;
}
.desk-zone-left {
left: 186px;
}
.desk-zone-right {
right: 186px;
}
.inner-outline.mid { .inner-outline.mid {
inset: 70px 72px 120px; inset: 70px 72px 120px;
} }
@@ -1384,14 +1494,15 @@
right: 20px; right: 20px;
min-width: 164px; min-width: 164px;
} }
.room-status-panel {
top: 92px;
right: 20px;
width: min(300px, calc(100% - 40px));
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.table-desk,
.table-felt {
aspect-ratio: 9 / 16;
}
.inner-outline.mid { .inner-outline.mid {
inset: 92px 34px 190px; inset: 92px 34px 190px;
} }
@@ -1406,6 +1517,11 @@
display: none; display: none;
} }
.desk-zone-top,
.desk-zone-bottom {
display: none;
}
.wall-left { .wall-left {
left: 32px; left: 32px;
} }
@@ -1414,6 +1530,14 @@
right: 32px; right: 32px;
} }
.desk-zone-left {
left: 84px;
}
.desk-zone-right {
right: 84px;
}
.floating-status.left, .floating-status.left,
.floating-status.right { .floating-status.right {
display: none; display: none;
@@ -1438,8 +1562,23 @@
padding: 7px 10px; padding: 7px 10px;
} }
.room-status-panel {
top: 58px;
right: 16px;
width: calc(100% - 32px);
padding: 10px;
}
.room-status-grid {
gap: 8px;
}
.room-status-item {
padding: 8px 10px;
}
.action-countdown { .action-countdown {
top: 62px; top: 176px;
right: 16px; right: 16px;
min-width: 0; min-width: 0;
width: calc(100% - 32px); width: calc(100% - 32px);
@@ -1477,3 +1616,157 @@
font-size: 18px; font-size: 18px;
} }
} }
/* ── Settlement Overlay ── */
.settlement-overlay {
position: absolute;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
animation: settlement-fade-in 300ms ease-out;
}
@keyframes settlement-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.settlement-panel {
width: 380px;
max-width: 90vw;
padding: 28px 24px 20px;
border: 1px solid rgba(220, 191, 118, 0.3);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(14, 55, 40, 0.96), rgba(8, 36, 27, 0.98)),
radial-gradient(circle at 20% 24%, rgba(237, 214, 157, 0.06), transparent 34%);
box-shadow:
inset 0 1px 0 rgba(255, 244, 214, 0.08),
0 24px 48px rgba(0, 0, 0, 0.4);
animation: settlement-panel-pop 300ms ease-out;
}
@keyframes settlement-panel-pop {
from { opacity: 0; transform: scale(0.92) translateY(12px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.settlement-title {
margin: 0 0 4px;
color: #e5c472;
font-size: 20px;
font-weight: 800;
text-align: center;
letter-spacing: 1px;
text-shadow:
-1px 0 rgba(0, 0, 0, 0.38),
0 1px rgba(0, 0, 0, 0.38),
1px 0 rgba(0, 0, 0, 0.38),
0 -1px rgba(0, 0, 0, 0.38);
}
.settlement-round-info {
margin: 0 0 16px;
color: rgba(229, 196, 114, 0.6);
font-size: 13px;
text-align: center;
letter-spacing: 0.5px;
}
.settlement-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 20px;
}
.settlement-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
transition: background 150ms;
}
.settlement-row.is-winner {
background: rgba(229, 196, 114, 0.1);
border: 1px solid rgba(220, 191, 118, 0.2);
}
.settlement-row.is-self {
box-shadow: inset 0 0 0 1px rgba(229, 196, 114, 0.18);
}
.settlement-rank {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.settlement-row.is-winner .settlement-rank {
background: rgba(229, 196, 114, 0.2);
color: #e5c472;
}
.settlement-name {
flex: 1;
color: rgba(255, 255, 255, 0.88);
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settlement-winner-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(229, 196, 114, 0.22);
color: #e5c472;
font-size: 11px;
font-weight: 800;
vertical-align: middle;
}
.settlement-score {
font-size: 18px;
font-weight: 800;
color: rgba(255, 255, 255, 0.7);
flex-shrink: 0;
min-width: 48px;
text-align: right;
}
.settlement-score.is-positive {
color: #5dda6e;
}
.settlement-score.is-negative {
color: #e85d5d;
}
.settlement-actions {
display: flex;
justify-content: center;
gap: 12px;
}
.settlement-btn {
min-width: 160px;
}

View File

@@ -0,0 +1,151 @@
.wind-square {
position: relative;
width: 96px;
height: 96px;
border-radius: 22px;
overflow: hidden;
box-shadow: 0 10px 18px rgba(0, 0, 0, 0.28),
inset 0 0 0 1px rgba(255, 240, 196, 0.2);
}
.square-base {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: sepia(1) hue-rotate(92deg) saturate(3.3) brightness(0.22);
}
.wind-square::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 28% 22%, rgba(255, 238, 191, 0.08), transparent 42%),
linear-gradient(145deg, rgba(5, 33, 24, 0.34), rgba(0, 0, 0, 0.16));
pointer-events: none;
z-index: 0;
}
/* ===== 四个三角形区域 ===== */
.quadrant {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
z-index: 1;
transition: opacity 0.2s ease;
}
/* 上三角 */
.quadrant-top {
clip-path: polygon(50% 50%, 0 0, 100% 0);
background: radial-gradient(circle at 50% 38%, rgba(255, 225, 180, 0.30), transparent 68%),
linear-gradient(to bottom, rgba(180, 95, 55, 0.28), rgba(80, 35, 20, 0.12));
}
/* 右三角 */
.quadrant-right {
clip-path: polygon(50% 50%, 100% 0, 100% 100%);
background: radial-gradient(circle at 62% 50%, rgba(255, 225, 180, 0.30), transparent 68%),
linear-gradient(to left, rgba(180, 95, 55, 0.28), rgba(80, 35, 20, 0.12));
}
/* 下三角 */
.quadrant-bottom {
clip-path: polygon(50% 50%, 0 100%, 100% 100%);
background: radial-gradient(circle at 50% 62%, rgba(255, 225, 180, 0.30), transparent 68%),
linear-gradient(to top, rgba(180, 95, 55, 0.28), rgba(80, 35, 20, 0.12));
}
/* 左三角 */
.quadrant-left {
clip-path: polygon(50% 50%, 0 0, 0 100%);
background: radial-gradient(circle at 38% 50%, rgba(255, 225, 180, 0.30), transparent 68%),
linear-gradient(to right, rgba(180, 95, 55, 0.28), rgba(80, 35, 20, 0.12));
}
/* 激活时闪烁 */
.quadrant.active {
opacity: 1;
animation: quadrant-pulse 1.2s ease-in-out infinite;
}
@keyframes quadrant-pulse {
0% {
opacity: 0.22;
filter: brightness(0.95);
}
50% {
opacity: 0.72;
filter: brightness(1.18);
}
100% {
opacity: 0.22;
filter: brightness(0.95);
}
}
.diagonal {
position: absolute;
left: 50%;
top: 50%;
width: 160%;
height: 2px;
border-radius: 999px;
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0%,
rgba(80, 35, 20, 0.6) 25%,
rgba(160, 85, 50, 0.9) 50%,
rgba(80, 35, 20, 0.6) 75%,
rgba(0, 0, 0, 0) 100%
);
transform-origin: center;
z-index: 2;
}
.diagonal-a {
transform: translate(-50%, -50%) rotate(45deg);
}
.diagonal-b {
transform: translate(-50%, -50%) rotate(-45deg);
}
.wind-slot {
position: absolute;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 3;
}
.wind-icon {
width: 100%;
height: 100%;
object-fit: contain;
filter: brightness(0) invert(1) drop-shadow(0 0 2px rgba(255, 220, 180, 0.8)) drop-shadow(0 0 4px rgba(120, 60, 30, 0.6));
}
.wind-top {
top: 5px;
left: 34px;
}
.wind-right {
top: 34px;
right: 5px;
}
.wind-bottom {
bottom: 5px;
left: 34px;
}
.wind-left {
top: 34px;
left: 5px;
}

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { ClaimOptionState } from '../../types/state'
import type { Tile } from '../../types/tile'
defineProps<{
showDingQueChooser: boolean
showReadyToggle: boolean
showStartGameButton: boolean
selectedDiscardTile: Tile | null
dingQuePending: boolean
canConfirmDiscard: boolean
discardPending: boolean
confirmDiscardLabel: string
readyTogglePending: boolean
myReadyState: boolean
canDrawTile: boolean
canStartGame: boolean
isRoomOwner: boolean
canSelfGang: boolean
canSelfHu: boolean
showClaimActions: boolean
turnActionPending: boolean
visibleClaimOptions: ClaimOptionState[]
claimActionPending: boolean
showWaitingOwnerTip: boolean
}>()
const emit = defineEmits<{
chooseDingQue: [suit: Tile['suit']]
confirmDiscard: []
toggleReadyState: []
drawTile: []
startGame: []
submitSelfGang: []
submitSelfHu: []
submitClaim: [action: ClaimOptionState]
}>()
</script>
<template>
<div v-if="showWaitingOwnerTip" class="waiting-owner-tip">
<span>等待房主开始游戏</span>
</div>
<div class="bottom-control-panel">
<div v-if="showDingQueChooser || showReadyToggle || showStartGameButton || selectedDiscardTile" class="bottom-action-bar">
<div v-if="showDingQueChooser" class="ding-que-bar">
<button class="ding-que-button" data-testid="ding-que-w" type="button" :disabled="dingQuePending" @click="emit('chooseDingQue', 'W')"></button>
<button class="ding-que-button" data-testid="ding-que-t" type="button" :disabled="dingQuePending" @click="emit('chooseDingQue', 'T')"></button>
<button class="ding-que-button" data-testid="ding-que-b" type="button" :disabled="dingQuePending" @click="emit('chooseDingQue', 'B')"></button>
</div>
<button
v-if="selectedDiscardTile"
class="ready-toggle ready-toggle-inline discard-confirm-button"
data-testid="confirm-discard"
type="button"
:disabled="!canConfirmDiscard || discardPending"
@click="emit('confirmDiscard')"
>
<span class="ready-toggle-label">{{ confirmDiscardLabel }}</span>
</button>
<button
v-if="showReadyToggle"
class="ready-toggle ready-toggle-inline"
data-testid="ready-toggle"
type="button"
:disabled="readyTogglePending"
@click="emit('toggleReadyState')"
>
<span class="ready-toggle-label">{{ myReadyState ? '取 消' : '准 备' }}</span>
</button>
<button v-if="canDrawTile" class="ready-toggle ready-toggle-inline" data-testid="draw-tile" type="button" @click="emit('drawTile')">
<span class="ready-toggle-label">摸牌</span>
</button>
<button
v-if="showStartGameButton && isRoomOwner"
class="ready-toggle ready-toggle-inline"
data-testid="start-game"
type="button"
:disabled="!canStartGame"
@click="emit('startGame')"
>
<span class="ready-toggle-label">开始游戏</span>
</button>
</div>
<div v-if="canSelfGang" class="hand-action-bar">
<button class="hand-action-tile" data-testid="hand-gang" type="button" :disabled="turnActionPending" @click="emit('submitSelfGang')">
</button>
</div>
<div v-if="canSelfHu || showClaimActions" class="claim-action-bar" data-testid="claim-action-bar">
<button
v-if="canSelfHu"
class="ready-toggle ready-toggle-inline"
data-testid="claim-self-hu"
type="button"
:disabled="turnActionPending"
@click="emit('submitSelfHu')"
>
<span class="ready-toggle-label"></span>
</button>
<button
v-for="option in visibleClaimOptions"
:key="option"
class="ready-toggle ready-toggle-inline"
:data-testid="`claim-${option}`"
type="button"
:disabled="claimActionPending"
@click="emit('submitClaim', option)"
>
<span class="ready-toggle-label">
{{ option === 'peng' ? '碰' : option === 'gang' ? '杠' : option === 'hu' ? '胡' : '过' }}
</span>
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { SeatKey } from '../../game/seat'
import type { DeskSeatState } from '../../views/chengdu/types'
defineProps<{
deskSeats: Record<SeatKey, DeskSeatState>
}>()
</script>
<template>
<div v-if="deskSeats.top.tiles.length > 0 || deskSeats.top.hasHu" class="desk-zone desk-zone-top">
<img
v-for="tile in deskSeats.top.tiles"
:key="tile.key"
class="desk-tile"
:class="{ 'is-group-start': tile.isGroupStart, 'is-covered': tile.imageType === 'covered' }"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="deskSeats.top.hasHu" class="desk-hu-flag"></span>
</div>
<div v-if="deskSeats.right.tiles.length > 0 || deskSeats.right.hasHu" class="desk-zone desk-zone-right">
<img
v-for="tile in deskSeats.right.tiles"
:key="tile.key"
class="desk-tile"
:class="{ 'is-group-start': tile.isGroupStart, 'is-covered': tile.imageType === 'covered' }"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="deskSeats.right.hasHu" class="desk-hu-flag"></span>
</div>
<div v-if="deskSeats.bottom.tiles.length > 0 || deskSeats.bottom.hasHu" class="desk-zone desk-zone-bottom">
<img
v-for="tile in deskSeats.bottom.tiles"
:key="tile.key"
class="desk-tile"
:class="{ 'is-group-start': tile.isGroupStart, 'is-covered': tile.imageType === 'covered' }"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="deskSeats.bottom.hasHu" class="desk-hu-flag"></span>
</div>
<div v-if="deskSeats.left.tiles.length > 0 || deskSeats.left.hasHu" class="desk-zone desk-zone-left">
<img
v-for="tile in deskSeats.left.tiles"
:key="tile.key"
class="desk-tile"
:class="{ 'is-group-start': tile.isGroupStart, 'is-covered': tile.imageType === 'covered' }"
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="deskSeats.left.hasHu" class="desk-hu-flag"></span>
</div>
</template>

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
show: boolean
isLastRound: boolean
currentRound: number
totalRounds: number
settlementPlayers: Array<{
playerId: string
displayName: string
score: number
isWinner: boolean
seatIndex: number
isReady: boolean
}>
loggedInUserId: string
isRoomOwner: boolean
selfIsReady: boolean
readyTogglePending: boolean
startNextRoundPending: boolean
leaveRoomPending: boolean
settlementCountdown: number | null
}>()
const emit = defineEmits<{
ready: []
startNextRound: []
exit: []
backHall: []
}>()
const allPlayersReady = computed(() =>
props.settlementPlayers.length > 0 && props.settlementPlayers.every((p) => p.isReady),
)
</script>
<template>
<div v-if="show" class="settlement-overlay">
<div class="settlement-panel">
<h2 class="settlement-title">
{{ isLastRound ? '最终结算' : `${currentRound} 局结算` }}
</h2>
<p v-if="totalRounds > 0" class="settlement-round-info">{{ currentRound }} / {{ totalRounds }} </p>
<div class="settlement-list">
<div
v-for="(item, index) in settlementPlayers"
:key="item.playerId"
class="settlement-row"
:class="{ 'is-winner': item.isWinner, 'is-self': item.playerId === loggedInUserId }"
>
<span class="settlement-rank">{{ index + 1 }}</span>
<span class="settlement-name">
{{ item.displayName }}
<span v-if="item.isWinner" class="settlement-winner-badge"></span>
</span>
<span class="settlement-score" :class="{ 'is-positive': item.score > 0, 'is-negative': item.score < 0 }">
{{ item.score > 0 ? '+' : '' }}{{ item.score }}
</span>
<span class="settlement-ready-badge" :class="{ 'is-ready': item.isReady }">
{{ item.isReady ? '已准备' : '等待...' }}
</span>
</div>
</div>
<div class="settlement-actions">
<!-- 非末局准备按钮 + 房主开始游戏按钮 -->
<template v-if="!isLastRound">
<button
class="ready-toggle ready-toggle-inline settlement-btn"
:class="{ 'is-ready': selfIsReady }"
type="button"
:disabled="selfIsReady || readyTogglePending"
@click="emit('ready')"
>
<span class="ready-toggle-label">
{{
readyTogglePending
? '请求中...'
: selfIsReady
? '已准备'
: '准备'
}}
</span>
</button>
<button
v-if="isRoomOwner"
class="ready-toggle ready-toggle-inline settlement-btn"
type="button"
:disabled="!allPlayersReady || startNextRoundPending"
@click="emit('startNextRound')"
>
<span class="ready-toggle-label">
{{
startNextRoundPending
? '开始中...'
: allPlayersReady
? '开始游戏'
: `开始游戏 (${settlementPlayers.filter((p) => p.isReady).length}/${settlementPlayers.length})`
}}
</span>
</button>
<p v-else-if="allPlayersReady" class="settlement-waiting-owner">等待房主开始...</p>
</template>
<!-- 末局返回大厅 -->
<button v-else class="ready-toggle ready-toggle-inline settlement-btn" type="button" @click="emit('backHall')">
<span class="ready-toggle-label">返回大厅</span>
</button>
<!-- 退出按钮始终显示 -->
<button
class="ready-toggle ready-toggle-inline settlement-btn settlement-btn-exit"
type="button"
:disabled="leaveRoomPending"
@click="emit('exit')"
>
<span class="ready-toggle-label">{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { ActionCountdownView } from '../../views/chengdu/types'
defineProps<{
leaveRoomPending: boolean
menuOpen: boolean
menuTriggerActive: boolean
isTrustMode: boolean
wallCount: number
networkLabel: string
wsStatus: string
formattedClock: string
roomName: string
currentPhaseText: string
playerCount: number
maxPlayers: number
roundText: string
roomStatusText: string
wsError: string
actionCountdown: ActionCountdownView | null
}>()
const emit = defineEmits<{
toggleMenu: []
toggleTrustMode: []
leaveRoom: []
}>()
</script>
<template>
<div class="top-left-tools">
<div class="menu-trigger-wrap">
<button
class="metal-circle menu-trigger"
:class="{ 'is-feedback': menuTriggerActive }"
type="button"
:disabled="leaveRoomPending"
@click.stop="emit('toggleMenu')"
>
<span class="menu-trigger-icon"></span>
</button>
<transition name="menu-pop">
<div v-if="menuOpen" class="menu-popover" @click.stop>
<div class="menu-list">
<button class="menu-item menu-item-delay-1" type="button" @click="emit('toggleTrustMode')">
<slot name="robot-icon" />
<span>{{ isTrustMode ? '取消托管' : '托管' }}</span>
</button>
<button
class="menu-item menu-item-danger menu-item-delay-2"
type="button"
:disabled="leaveRoomPending"
@click="emit('leaveRoom')"
>
<slot name="exit-icon" />
<span>{{ leaveRoomPending ? '退出中...' : '退出' }}</span>
</button>
</div>
</div>
</transition>
</div>
<div class="left-counter">
<span class="counter-light"></span>
<strong>{{ wallCount }}</strong>
</div>
<span v-if="isTrustMode" class="trust-chip">托管中</span>
</div>
<div class="top-right-clock">
<div class="signal-chip">
<span class="wifi-dot" :class="`is-${wsStatus}`"></span>
<strong>{{ networkLabel }}</strong>
</div>
<span>{{ formattedClock }}</span>
</div>
<div class="room-status-panel">
<div class="room-status-grid">
<div class="room-status-item">
<span>房间</span>
<strong>{{ roomName || '未命名' }}</strong>
</div>
<div class="room-status-item">
<span>阶段</span>
<strong>{{ currentPhaseText }}</strong>
</div>
<div class="room-status-item">
<span>人数</span>
<strong>{{ playerCount }}/{{ maxPlayers }}</strong>
</div>
<div v-if="roundText" class="room-status-item">
<span>局数</span>
<strong>{{ roundText }}</strong>
</div>
<div class="room-status-item">
<span>状态</span>
<strong>{{ roomStatusText }}</strong>
</div>
</div>
<p v-if="wsError" class="room-status-error">{{ wsError }}</p>
</div>
<div v-if="actionCountdown" class="action-countdown" :class="{ 'is-self': actionCountdown.isSelf }">
<div class="action-countdown-head">
<span>{{ actionCountdown.playerLabel }}操作倒计时</span>
<strong>{{ actionCountdown.remaining }}s</strong>
</div>
<div class="action-countdown-track">
<span class="action-countdown-fill" :style="{ width: `${actionCountdown.progress}%` }"></span>
</div>
</div>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import type { SeatKey } from '../../game/seat'
import type { Tile } from '../../types/tile'
import type { WallSeatState } from '../../views/chengdu/types'
defineProps<{
wallSeats: Record<SeatKey, WallSeatState>
selectedDiscardTileId: number | null
discardBlockedReason: string
discardTileBlockedReason: (tile: Tile) => string
formatTile: (tile: Tile) => string
}>()
const emit = defineEmits<{
selectDiscardTile: [tile: Tile]
}>()
</script>
<template>
<div v-if="wallSeats.top.tiles.length > 0" class="wall wall-top wall-live">
<img
v-for="(tile, index) in wallSeats.top.tiles"
:key="tile.key"
class="wall-live-tile"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.top.tiles[index - 1]?.suit !== tile.suit,
'is-exposed': tile.imageType !== 'hand',
}"
:src="tile.src"
:alt="tile.alt"
/>
</div>
<div v-if="wallSeats.right.tiles.length > 0" class="wall wall-right wall-live">
<img
v-for="(tile, index) in wallSeats.right.tiles"
:key="tile.key"
class="wall-live-tile"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.right.tiles[index - 1]?.suit !== tile.suit,
'is-exposed': tile.imageType !== 'hand',
}"
:src="tile.src"
:alt="tile.alt"
/>
</div>
<div v-if="wallSeats.bottom.tiles.length > 0" class="wall wall-bottom wall-live">
<template v-for="(tile, index) in wallSeats.bottom.tiles" :key="tile.key">
<button
v-if="tile.tile && tile.imageType === 'hand'"
class="wall-live-tile-button"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
'is-lack-tagged': tile.showLackTag,
'is-selected': selectedDiscardTileId === tile.tile.id,
}"
:data-testid="`hand-tile-${tile.tile.id}`"
type="button"
:disabled="Boolean(discardBlockedReason)"
:title="discardTileBlockedReason(tile.tile) || formatTile(tile.tile)"
@click="emit('selectDiscardTile', tile.tile)"
>
<span v-if="tile.showLackTag" class="wall-live-tile-lack-tag"></span>
<img class="wall-live-tile" :src="tile.src" :alt="tile.alt" />
</button>
<img
v-else
class="wall-live-tile"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
'is-exposed': tile.imageType !== 'hand',
}"
:src="tile.src"
:alt="tile.alt"
/>
</template>
</div>
<div v-if="wallSeats.left.tiles.length > 0" class="wall wall-left wall-live">
<img
v-for="(tile, index) in wallSeats.left.tiles"
:key="tile.key"
class="wall-live-tile"
:class="{
'is-group-start': index > 0 && tile.suit && wallSeats.left.tiles[index - 1]?.suit !== tile.suit,
'is-exposed': tile.imageType !== 'hand',
}"
:src="tile.src"
:alt="tile.alt"
/>
</div>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import squareIcon from '../../assets/images/icons/square.svg' import squareIcon from '../../assets/images/icons/square.svg'
import '@src/assets/styles/windowSquare.css'
defineProps<{ defineProps<{
seatWinds: { seatWinds: {
@@ -8,12 +9,20 @@ defineProps<{
bottom: string bottom: string
left: string left: string
} }
activePosition?: 'top' | 'right' | 'bottom' | 'left' | ''
}>() }>()
</script> </script>
<template> <template>
<div class="wind-square"> <div class="wind-square">
<img class="square-base" :src="squareIcon" alt="" /> <img class="square-base" :src="squareIcon" alt="" />
<!-- 四个三角形高亮区域 -->
<div class="quadrant quadrant-top" :class="{ active: activePosition === 'top' }"></div>
<div class="quadrant quadrant-right" :class="{ active: activePosition === 'right' }"></div>
<div class="quadrant quadrant-bottom" :class="{ active: activePosition === 'bottom' }"></div>
<div class="quadrant quadrant-left" :class="{ active: activePosition === 'left' }"></div>
<div class="diagonal diagonal-a"></div> <div class="diagonal diagonal-a"></div>
<div class="diagonal diagonal-b"></div> <div class="diagonal diagonal-b"></div>
@@ -31,98 +40,3 @@ defineProps<{
</span> </span>
</div> </div>
</template> </template>
<style scoped>
.wind-square {
position: relative;
width: 96px;
height: 96px;
border-radius: 22px;
overflow: hidden;
box-shadow:
0 10px 18px rgba(0, 0, 0, 0.28),
inset 0 0 0 1px rgba(255, 240, 196, 0.2);
}
.square-base {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter:
sepia(1)
hue-rotate(92deg)
saturate(3.3)
brightness(0.22);
}
.wind-square::after {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 28% 22%, rgba(255, 238, 191, 0.08), transparent 42%),
linear-gradient(145deg, rgba(5, 33, 24, 0.34), rgba(0, 0, 0, 0.16));
pointer-events: none;
}
.diagonal {
position: absolute;
left: 50%;
top: 50%;
width: 150px;
height: 1px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(16, 40, 31, 0.22), rgba(25, 55, 42, 0.72), rgba(16, 40, 31, 0.22));
box-shadow:
0 0 4px rgba(0, 0, 0, 0.08);
transform-origin: center;
z-index: 1;
}
.diagonal-a {
transform: translate(-50%, -50%) rotate(45deg);
}
.diagonal-b {
transform: translate(-50%, -50%) rotate(-45deg);
}
.wind-slot {
position: absolute;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.wind-icon {
width: 100%;
height: 100%;
object-fit: contain;
filter: brightness(0) invert(1);
}
.wind-top {
top: 10px;
left: 34px;
}
.wind-right {
top: 34px;
right: 10px;
}
.wind-bottom {
bottom: 10px;
left: 34px;
}
.wind-left {
top: 34px;
left: 10px;
}
</style>

View File

@@ -0,0 +1,24 @@
// src/config/deskImageMap.ts
export interface DeskAsset {
width: number
height: number
ratio: number
src: string
}
// 所有桌面资源
export const DESK_ASSETS: DeskAsset[] = [
{
width: 1920,
height: 1080,
ratio: 1920 / 1080,
src: new URL('@/assets/images/desk/desk_01_1920_1080.png', import.meta.url).href,
},
{
width: 1920,
height: 945,
ratio: 1920 / 945,
src: new URL('@/assets/images/desk/desk_01_1920_945.png', import.meta.url).href,
},
]

View File

@@ -17,6 +17,7 @@ export interface RoomPlayerUpdatePayload {
avatar_url?: string avatar_url?: string
Ready?: boolean Ready?: boolean
ready?: boolean ready?: boolean
is_ready?: boolean
MissingSuit?: string | null MissingSuit?: string | null
missing_suit?: string | null missing_suit?: string | null
}> }>
@@ -29,6 +30,39 @@ export interface RoomTrusteePayload {
reason?: string reason?: string
} }
export interface DiscardActionPayload {
player_id?: string
playerId?: string
PlayerID?: string
tile?: Tile
next_seat?: number
nextSeat?: number
}
export interface DrawActionPayload {
player_id?: string
playerId?: string
PlayerID?: string
tile?: Tile
}
export interface PlayerTurnPayload {
player_id?: string
playerId?: string
PlayerID?: string
timeout?: number
Timeout?: number
start_at?: number
startAt?: number
StartAt?: number
allow_actions?: string[]
allowActions?: string[]
AllowActions?: string[]
available_actions?: string[]
availableActions?: string[]
AvailableActions?: string[]
}
/** /**
* 游戏动作定义(只描述“发生了什么”) * 游戏动作定义(只描述“发生了什么”)
@@ -51,20 +85,13 @@ export type GameAction =
// 摸牌 // 摸牌
| { | {
type: 'DRAW_TILE' type: 'DRAW_TILE'
payload: { payload: DrawActionPayload
playerId: string
tile: Tile
}
} }
// 出牌 // 出牌
| { | {
type: 'PLAY_TILE' type: 'PLAY_TILE'
payload: { payload: DiscardActionPayload
playerId: string
tile: Tile
nextSeat: number
}
} }
// 进入操作窗口(碰/杠/胡) // 进入操作窗口(碰/杠/胡)
@@ -92,3 +119,8 @@ export type GameAction =
type: 'ROOM_TRUSTEE' type: 'ROOM_TRUSTEE'
payload: RoomTrusteePayload payload: RoomTrusteePayload
} }
| {
type: 'PLAYER_TURN'
payload: PlayerTurnPayload
}

View File

@@ -0,0 +1,283 @@
import type {
ClaimOptionState,
MeldState,
PendingClaimState,
PlayerState,
Tile,
} from '../../types/state'
export function normalizeWsType(type: string): string {
return type.replace(/[-\s]/g, '_').toUpperCase()
}
export function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null
}
export function readString(source: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = source[key]
if (typeof value === 'string' && value.trim()) {
return value
}
}
return ''
}
export function readNumber(source: Record<string, unknown>, ...keys: string[]): number | null {
for (const key of keys) {
const value = source[key]
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
}
return null
}
export function normalizeTimestampMs(value: number | null): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return null
}
return value >= 1_000_000_000_000 ? value : value * 1000
}
export function readStringArray(source: Record<string, unknown>, ...keys: string[]): string[] {
for (const key of keys) {
const value = source[key]
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string')
}
}
return []
}
export function readBoolean(source: Record<string, unknown>, ...keys: string[]): boolean | null {
for (const key of keys) {
const value = source[key]
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
if (value === 1) {
return true
}
if (value === 0) {
return false
}
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'true' || normalized === '1') {
return true
}
if (normalized === 'false' || normalized === '0') {
return false
}
}
}
return null
}
export function readMissingSuit(source: Record<string, unknown> | null | undefined): string | null {
if (!source) {
return null
}
return readString(source, 'missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit') || null
}
export function readMissingSuitWithPresence(
source: Record<string, unknown> | null | undefined,
): { present: boolean; value: string | null } {
if (!source) {
return { present: false, value: null }
}
const keys = ['missing_suit', 'MissingSuit', 'ding_que', 'dingQue', 'suit', 'Suit']
const hasMissingSuitField = keys.some((key) => Object.prototype.hasOwnProperty.call(source, key))
if (!hasMissingSuitField) {
return { present: false, value: null }
}
return { present: true, value: readMissingSuit(source) }
}
export function tileToText(tile: Tile): string {
return `${tile.suit}${tile.value}`
}
export function readPlayerTurnPlayerId(payload: Record<string, unknown>): string {
return (
(typeof payload.player_id === 'string' && payload.player_id) ||
(typeof payload.playerId === 'string' && payload.playerId) ||
(typeof payload.PlayerID === 'string' && payload.PlayerID) ||
''
)
}
export function readPlayerTurnAllowActions(payload: Record<string, unknown>): string[] {
const source =
payload.allow_actions ??
payload.allowActions ??
payload.AllowActions ??
payload.available_actions ??
payload.availableActions ??
payload.AvailableActions
if (!Array.isArray(source)) {
return []
}
const actions = source
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim().toLowerCase())
.filter((item) => item.length > 0)
return Array.from(new Set(actions))
}
export function normalizeTile(tile: unknown): Tile | null {
const source = asRecord(tile)
if (!source) {
return null
}
const id = readNumber(source, 'id')
const suit = readString(source, 'suit') as Tile['suit'] | ''
const value = readNumber(source, 'value')
if (typeof id !== 'number' || !suit || typeof value !== 'number') {
return null
}
if (suit !== 'W' && suit !== 'T' && suit !== 'B') {
return null
}
return { id, suit, value }
}
export function normalizeTiles(value: unknown): Tile[] {
if (!Array.isArray(value)) {
return []
}
return value.map((item) => normalizeTile(item)).filter((item): item is Tile => Boolean(item))
}
export function normalizePendingClaim(
gameState: Record<string, unknown> | null | undefined,
loggedInUserId: string,
): PendingClaimState | undefined {
if (!gameState || !loggedInUserId) {
return undefined
}
const pendingClaim = asRecord(gameState.pending_claim ?? gameState.pendingClaim)
if (!pendingClaim) {
return undefined
}
const selfOptions = asRecord(pendingClaim[loggedInUserId])
if (!selfOptions) {
return undefined
}
const options: ClaimOptionState[] = []
if (readBoolean(selfOptions, 'hu')) {
options.push('hu')
}
if (readBoolean(selfOptions, 'gang')) {
options.push('gang')
}
if (readBoolean(selfOptions, 'peng')) {
options.push('peng')
}
if (options.length === 0) {
return undefined
}
options.push('pass')
return {
tile: normalizeTile(gameState.last_discard_tile ?? gameState.lastDiscardTile) ?? undefined,
fromPlayerId: readString(gameState, 'last_discard_by', 'lastDiscardBy') || undefined,
options,
}
}
export function normalizeMeldType(value: unknown, concealed = false): MeldState['type'] | null {
if (typeof value !== 'string') {
return concealed ? 'an_gang' : null
}
const normalized = value.replace(/[-\s]/g, '_').toLowerCase()
if (normalized === 'peng') {
return 'peng'
}
if (normalized === 'ming_gang' || normalized === 'gang' || normalized === 'gang_open') {
return concealed ? 'an_gang' : 'ming_gang'
}
if (normalized === 'an_gang' || normalized === 'angang' || normalized === 'concealed_gang') {
return 'an_gang'
}
return concealed ? 'an_gang' : null
}
export function normalizeMelds(value: unknown): PlayerState['melds'] {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => {
if (Array.isArray(item)) {
const tiles = normalizeTiles(item)
if (tiles.length === 3) {
return { type: 'peng', tiles, fromPlayerId: '' } satisfies MeldState
}
if (tiles.length === 4) {
return { type: 'ming_gang', tiles, fromPlayerId: '' } satisfies MeldState
}
return null
}
const source = asRecord(item)
if (!source) {
return null
}
const tiles = normalizeTiles(
source.tiles ??
source.meld_tiles ??
source.meldTiles ??
source.cards ??
source.card_list,
)
if (tiles.length === 0) {
return null
}
const concealed =
readBoolean(source, 'concealed', 'is_concealed', 'isConcealed', 'hidden', 'is_hidden') ?? false
const explicitType = normalizeMeldType(
source.type ?? source.meld_type ?? source.meldType ?? source.kind,
concealed,
)
const type =
explicitType ??
(tiles.length === 4 ? (concealed ? 'an_gang' : 'ming_gang') : tiles.length === 3 ? 'peng' : null)
if (type === 'peng' || type === 'ming_gang') {
return {
type,
tiles,
fromPlayerId: readString(source, 'from_player_id', 'fromPlayerId'),
} satisfies MeldState
}
if (type === 'an_gang') {
return { type, tiles } satisfies MeldState
}
return null
})
.filter((item): item is MeldState => Boolean(item))
}

View File

@@ -38,6 +38,10 @@ export function dispatchGameAction(action: GameAction) {
store.onRoomTrustee(action.payload) store.onRoomTrustee(action.payload)
break break
case 'PLAYER_TURN':
store.onPlayerTurn(action.payload)
break
default: default:
throw new Error('Invalid game action') throw new Error('Invalid game action')

View File

@@ -4,10 +4,35 @@ import {
type GameState, type GameState,
type PendingClaimState, type PendingClaimState,
} from '../types/state' } from '../types/state'
import type { RoomPlayerUpdatePayload, RoomTrusteePayload } from '../game/actions' import type { PlayerTurnPayload, RoomPlayerUpdatePayload, RoomTrusteePayload } from '../game/actions'
import { readStoredAuth } from '../utils/auth-storage'
import type { Tile } from '../types/tile' import type { Tile } from '../types/tile'
function parseBooleanish(value: unknown): boolean | null {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
if (value === 1) {
return true
}
if (value === 0) {
return false
}
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'true' || normalized === '1') {
return true
}
if (normalized === 'false' || normalized === '0') {
return false
}
}
return null
}
export const useGameStore = defineStore('game', { export const useGameStore = defineStore('game', {
state: (): GameState => ({ state: (): GameState => ({
roomId: '', roomId: '',
@@ -16,6 +41,7 @@ export const useGameStore = defineStore('game', {
dealerIndex: 0, dealerIndex: 0,
currentTurn: 0, currentTurn: 0,
currentPlayerId: '',
needDraw: false, needDraw: false,
players: {}, players: {},
@@ -27,6 +53,10 @@ export const useGameStore = defineStore('game', {
winners: [], winners: [],
scores: {}, scores: {},
currentRound: 0,
totalRounds: 0,
}), }),
actions: { actions: {
@@ -34,27 +64,36 @@ export const useGameStore = defineStore('game', {
this.$reset() this.$reset()
}, },
// 初始 // 初始<EFBFBD>?
initGame(data: GameState) { initGame(data: GameState) {
Object.assign(this, data) Object.assign(this, data)
}, },
// 摸牌 // 摸牌
onDrawTile(data: { playerId: string; tile: Tile }) { onDrawTile(data: { playerId?: string; player_id?: string; PlayerID?: string; tile?: Tile }) {
const player = this.players[data.playerId] const playerId =
(typeof data.playerId === 'string' && data.playerId) ||
(typeof data.player_id === 'string' && data.player_id) ||
(typeof data.PlayerID === 'string' && data.PlayerID) ||
''
const tile = data.tile
if (!playerId || !tile) return
const player = this.players[playerId]
if (!player) return if (!player) return
// 只更新自己的手牌 // 只更新自己的手牌
if (player.playerId === this.getMyPlayerId()) { if (player.playerId === this.getMyPlayerId()) {
player.handTiles.push(data.tile) player.handTiles.push(tile)
} }
player.handCount += 1 player.handCount += 1
// 剩余牌数减少 // 剩余牌数减少
this.remainingTiles = Math.max(0, this.remainingTiles - 1) this.remainingTiles = Math.max(0, this.remainingTiles - 1)
// 更新回合seatIndex // 更新回合seatIndex<EFBFBD>?
this.currentTurn = player.seatIndex this.currentTurn = player.seatIndex
this.currentPlayerId = player.playerId
// 清除操作窗口 // 清除操作窗口
this.pendingClaim = undefined this.pendingClaim = undefined
@@ -66,17 +105,28 @@ export const useGameStore = defineStore('game', {
// 出牌 // 出牌
onPlayTile(data: { onPlayTile(data: {
playerId: string playerId?: string
tile: Tile player_id?: string
nextSeat: number PlayerID?: string
tile?: Tile
nextSeat?: number
next_seat?: number
}) { }) {
const player = this.players[data.playerId] const playerId =
(typeof data.playerId === 'string' && data.playerId) ||
(typeof data.player_id === 'string' && data.player_id) ||
(typeof data.PlayerID === 'string' && data.PlayerID) ||
''
const tile = data.tile
if (!playerId || !tile) return
const player = this.players[playerId]
if (!player) return if (!player) return
// 如果是自己,移除手牌 // 如果是自己,移除手牌
if (player.playerId === this.getMyPlayerId()) { if (player.playerId === this.getMyPlayerId()) {
const index = player.handTiles.findIndex( const index = player.handTiles.findIndex(
(t) => t.id === data.tile.id (t) => t.id === tile.id
) )
if (index !== -1) { if (index !== -1) {
player.handTiles.splice(index, 1) player.handTiles.splice(index, 1)
@@ -84,18 +134,24 @@ export const useGameStore = defineStore('game', {
} }
player.handCount = Math.max(0, player.handCount - 1) player.handCount = Math.max(0, player.handCount - 1)
// 加入出牌 // 加入出牌<EFBFBD>?
player.discardTiles.push(data.tile) player.discardTiles.push(tile)
// 更新回合 // 更新回合
this.currentTurn = data.nextSeat const nextSeat =
typeof data.nextSeat === 'number'
? data.nextSeat
: typeof data.next_seat === 'number'
? data.next_seat
: this.currentTurn
this.currentTurn = nextSeat
this.needDraw = true this.needDraw = true
// 等待其他玩家响应 // 等待其他玩家响应
this.phase = GAME_PHASE.ACTION this.phase = GAME_PHASE.ACTION
}, },
// 触发操作窗口(碰/杠/胡) // 触发操作窗口(碰/<EFBFBD>?胡)
onPendingClaim(data: PendingClaimState) { onPendingClaim(data: PendingClaimState) {
this.pendingClaim = data this.pendingClaim = data
this.needDraw = false this.needDraw = false
@@ -141,7 +197,8 @@ export const useGameStore = defineStore('game', {
const seatRaw = raw.Index ?? raw.index ?? index const seatRaw = raw.Index ?? raw.index ?? index
const seatIndex = const seatIndex =
typeof seatRaw === 'number' && Number.isFinite(seatRaw) ? seatRaw : index typeof seatRaw === 'number' && Number.isFinite(seatRaw) ? seatRaw : index
const readyRaw = raw.Ready ?? raw.ready const readyRaw = raw.Ready ?? raw.ready ?? raw.is_ready
const ready = parseBooleanish(readyRaw)
const displayNameRaw = raw.PlayerName ?? raw.player_name const displayNameRaw = raw.PlayerName ?? raw.player_name
const avatarUrlRaw = raw.AvatarUrl ?? raw.avatar_url const avatarUrlRaw = raw.AvatarUrl ?? raw.avatar_url
const missingSuitRaw = raw.MissingSuit ?? raw.missing_suit const missingSuitRaw = raw.MissingSuit ?? raw.missing_suit
@@ -169,8 +226,8 @@ export const useGameStore = defineStore('game', {
hasHu: previous?.hasHu ?? false, hasHu: previous?.hasHu ?? false,
score: previous?.score ?? 0, score: previous?.score ?? 0,
isReady: isReady:
typeof readyRaw === 'boolean' ready !== null
? readyRaw ? ready
: (previous?.isReady ?? false), : (previous?.isReady ?? false),
} }
}) })
@@ -220,14 +277,48 @@ export const useGameStore = defineStore('game', {
}, },
// 清理操作窗口 // 清理操作窗口
onPlayerTurn(payload: PlayerTurnPayload) {
const playerId =
(typeof payload.player_id === 'string' && payload.player_id) ||
(typeof payload.playerId === 'string' && payload.playerId) ||
(typeof payload.PlayerID === 'string' && payload.PlayerID) ||
''
if (!playerId) {
return
}
const player = this.players[playerId]
if (player) {
this.currentTurn = player.seatIndex
}
this.currentPlayerId = playerId
this.needDraw = false
this.pendingClaim = undefined
this.phase = GAME_PHASE.PLAYING
},
clearPendingClaim() { clearPendingClaim() {
this.pendingClaim = undefined this.pendingClaim = undefined
this.phase = GAME_PHASE.PLAYING this.phase = GAME_PHASE.PLAYING
}, },
// 获取当前玩家ID后续建议放userStore // 获取当前玩家ID后续建议放<EFBFBD>?userStore<EFBFBD>?
getMyPlayerId(): string { getMyPlayerId(): string {
return Object.keys(this.players)[0] || '' const auth = readStoredAuth()
const source = auth?.user as Record<string, unknown> | undefined
const rawId =
source?.id ??
source?.userID ??
source?.user_id
if (typeof rawId === 'string' && rawId.trim()) {
return rawId
}
if (typeof rawId === 'number') {
return String(rawId)
}
return ''
}, },
}, },
}) })

View File

@@ -1,13 +1,13 @@
import { ref } from 'vue' import { ref } from 'vue'
import type { import type {
ActiveRoomState, RoomMetaSnapshotInput,
ActiveRoomSelectionInput, RoomMetaSnapshotState,
} from './state' } from './state'
import { clearActiveRoomSnapshot, readActiveRoomSnapshot, saveActiveRoom } from './storage' import { clearRoomMetaSnapshot, readRoomMetaSnapshot, saveRoomMetaSnapshot } from './storage'
const activeRoom = ref<ActiveRoomState | null>(readActiveRoomSnapshot()) const roomMetaSnapshot = ref<RoomMetaSnapshotState | null>(readRoomMetaSnapshot())
function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState { function normalizeRoom(input: RoomMetaSnapshotInput): RoomMetaSnapshotState {
return { return {
roomId: input.roomId, roomId: input.roomId,
roomName: input.roomName ?? '', roomName: input.roomName ?? '',
@@ -32,19 +32,17 @@ function normalizeRoom(input: ActiveRoomSelectionInput): ActiveRoomState {
} }
} }
// 设置当前房间 export function setRoomMetaSnapshot(input: RoomMetaSnapshotInput) {
export function setActiveRoom(input: ActiveRoomSelectionInput) {
const next = normalizeRoom(input) const next = normalizeRoom(input)
activeRoom.value = next roomMetaSnapshot.value = next
saveActiveRoom(next) saveRoomMetaSnapshot(next)
} }
export function clearActiveRoom() { export function clearRoomMetaSnapshotState() {
activeRoom.value = null roomMetaSnapshot.value = null
clearActiveRoomSnapshot() clearRoomMetaSnapshot()
} }
// 使用房间状态 export function useRoomMetaSnapshotState() {
export function useActiveRoomState() { return roomMetaSnapshot
return activeRoom
} }

View File

@@ -1,5 +1,5 @@
// 房间玩家状态 // 房间玩家状态
export interface RoomPlayerState { export interface RoomMetaPlayerState {
index: number index: number
playerId: string playerId: string
displayName?: string displayName?: string
@@ -13,7 +13,7 @@ export interface RoomPlayerState {
} }
// 房间整体状态 // 房间整体状态
export interface ActiveRoomState { export interface RoomMetaSnapshotState {
roomId: string roomId: string
roomName: string roomName: string
gameType: string gameType: string
@@ -23,7 +23,7 @@ export interface ActiveRoomState {
status: string status: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
players: RoomPlayerState[] players: RoomMetaPlayerState[]
myHand: string[] myHand: string[]
game?: { game?: {
state?: { state?: {
@@ -36,7 +36,7 @@ export interface ActiveRoomState {
} }
} }
export interface ActiveRoomSelectionInput { export interface RoomMetaSnapshotInput {
roomId: string roomId: string
roomName?: string roomName?: string
gameType?: string gameType?: string
@@ -46,7 +46,7 @@ export interface ActiveRoomSelectionInput {
status?: string status?: string
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
players?: RoomPlayerState[] players?: RoomMetaPlayerState[]
myHand?: string[] myHand?: string[]
game?: ActiveRoomState['game'] game?: RoomMetaSnapshotState['game']
} }

View File

@@ -1,9 +1,9 @@
import type { ActiveRoomState } from './state' import type { RoomMetaSnapshotState } from './state'
const KEY = 'mahjong_active_room' const KEY = 'mahjong_active_room'
// 读取缓存 // 读取缓存
export function readActiveRoomSnapshot(): ActiveRoomState | null { export function readRoomMetaSnapshot(): RoomMetaSnapshotState | null {
const raw = localStorage.getItem(KEY) const raw = localStorage.getItem(KEY)
if (!raw) return null if (!raw) return null
@@ -15,11 +15,11 @@ export function readActiveRoomSnapshot(): ActiveRoomState | null {
} }
// 写入缓存 // 写入缓存
export function saveActiveRoom(state: ActiveRoomState) { export function saveRoomMetaSnapshot(state: RoomMetaSnapshotState) {
localStorage.setItem(KEY, JSON.stringify(state)) localStorage.setItem(KEY, JSON.stringify(state))
} }
// 清除缓存 // 清除缓存
export function clearActiveRoomSnapshot() { export function clearRoomMetaSnapshot() {
localStorage.removeItem(KEY) localStorage.removeItem(KEY)
} }

View File

@@ -13,6 +13,8 @@ export interface GameState {
// 当前操作玩家(座位) // 当前操作玩家(座位)
currentTurn: number currentTurn: number
// 当前操作玩家ID
currentPlayerId: string
// 当前回合是否需要先摸牌 // 当前回合是否需要先摸牌
needDraw: boolean needDraw: boolean
@@ -31,4 +33,10 @@ export interface GameState {
// 分数playerId -> score // 分数playerId -> score
scores: Record<string, number> scores: Record<string, number>
// 当前第几局
currentRound: number
// 总局数
totalRounds: number
} }

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ import { AuthExpiredError, type AuthSession } from '../api/authed-request'
import { createRoom, joinRoom, listRooms, type RoomItem } from '../api/mahjong' import { createRoom, joinRoom, listRooms, type RoomItem } from '../api/mahjong'
import { getUserInfo, type UserInfo } from '../api/user' import { getUserInfo, type UserInfo } from '../api/user'
import refreshIcon from '../assets/images/icons/refresh.svg' import refreshIcon from '../assets/images/icons/refresh.svg'
import { setActiveRoom } from '../store' import { setRoomMetaSnapshot } from '../store'
import type { RoomPlayerState } from '../store/state' import type { RoomPlayerState } from '../store/state'
import type { StoredAuth } from '../types/session' import type { StoredAuth } from '../types/session'
import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage' import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
@@ -254,7 +254,7 @@ async function submitCreateRoom(): Promise<void> {
) )
createdRoom.value = room createdRoom.value = room
setActiveRoom({ setRoomMetaSnapshot({
roomId: room.room_id, roomId: room.room_id,
roomName: room.name, roomName: room.name,
gameType: room.game_type, gameType: room.game_type,
@@ -301,7 +301,7 @@ async function handleJoinRoom(room?: { roomId?: string; roomName?: string }): Pr
roomSubmitting.value = true roomSubmitting.value = true
try { try {
const joinedRoom = await joinRoom(session, { roomId: targetRoomId }, syncAuth) const joinedRoom = await joinRoom(session, { roomId: targetRoomId }, syncAuth)
setActiveRoom({ setRoomMetaSnapshot({
roomId: joinedRoom.room_id, roomId: joinedRoom.room_id,
roomName: joinedRoom.name, roomName: joinedRoom.name,
gameType: joinedRoom.game_type, gameType: joinedRoom.game_type,
@@ -355,7 +355,7 @@ async function enterCreatedRoom(): Promise<void> {
} }
showCreatedModal.value = false showCreatedModal.value = false
setActiveRoom({ setRoomMetaSnapshot({
roomId: createdRoom.value.room_id, roomId: createdRoom.value.room_id,
roomName: createdRoom.value.name, roomName: createdRoom.value.name,
gameType: createdRoom.value.game_type, gameType: createdRoom.value.game_type,

View File

@@ -0,0 +1,433 @@
import {computed} from 'vue'
import {sendWsMessage} from '../../../ws/sender'
import {formatTile} from './useChengduTableView'
import type {ClaimOptionState} from '../../../types/state'
import type {Tile} from '../../../types/tile'
import type {DisplayPlayer} from '../types'
interface UseChengduGameActionsOptions {
gameStore: {
roomId: string
phase: string
currentTurn: number
needDraw: boolean
currentRound: number
totalRounds: number
pendingClaim?: {
tile?: Tile
options: ClaimOptionState[]
}
}
roomMeta: { value: { roomId: string; ownerId: string } | null }
gamePlayers: { value: DisplayPlayer[] }
myPlayer: { value: DisplayPlayer | undefined }
session: any
}
export function useChengduGameActions(options: UseChengduGameActionsOptions) {
const isLastRound = computed(
() => options.gameStore.currentRound >= options.gameStore.totalRounds && options.gameStore.totalRounds > 0,
)
const myReadyState = computed(() => Boolean(options.myPlayer.value?.isReady))
const isRoomOwner = computed(() => {
const room = options.roomMeta.value
return Boolean(
room &&
room.roomId === options.gameStore.roomId &&
room.ownerId &&
options.session.loggedInUserId.value &&
room.ownerId === options.session.loggedInUserId.value,
)
})
const allPlayersReady = computed(
() => options.gamePlayers.value.length === 4 && options.gamePlayers.value.every((player) => Boolean(player.isReady)),
)
const hasRoundStarted = computed(() =>
options.gamePlayers.value.some(
(player) =>
player.handCount > 0 ||
player.handTiles.length > 0 ||
player.melds.length > 0 ||
player.discardTiles.length > 0,
),
)
const showStartGameButton = computed(
() => options.gameStore.phase === 'waiting' && allPlayersReady.value && !hasRoundStarted.value,
)
const showWaitingOwnerTip = computed(() => showStartGameButton.value && !isRoomOwner.value)
const canStartGame = computed(
() => showStartGameButton.value && isRoomOwner.value && !options.session.startGamePending.value,
)
const showReadyToggle = computed(() => {
if (options.gameStore.phase !== 'waiting' || !options.gameStore.roomId || hasRoundStarted.value) {
return false
}
if (showStartGameButton.value) {
return !isRoomOwner.value
}
return true
})
const showDingQueChooser = computed(() => {
const player = options.myPlayer.value
if (!player || options.gameStore.phase === 'settlement') {
return false
}
return player.handTiles.length > 0 && !player.missingSuit
})
const selectedDiscardTile = computed(() => {
const player = options.myPlayer.value
if (!player || options.session.selectedDiscardTileId.value === null) {
return null
}
return player.handTiles.find((tile) => tile.id === options.session.selectedDiscardTileId.value) ?? null
})
const hasMissingSuitTiles = computed(() => {
const player = options.myPlayer.value
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
if (!player || !missingSuit) {
return false
}
return player.handTiles.some((tile) => tile.suit === missingSuit)
})
function missingSuitLabel(value: string | null | undefined): string {
const suitMap: Record<string, string> = {
w: '万',
t: '筒',
b: '条',
wan: '万',
tong: '筒',
tiao: '条',
}
if (!value) {
return ''
}
return suitMap[value.trim().toLowerCase()] ?? value
}
const discardBlockedReason = computed(() => {
const player = options.myPlayer.value
if (!player || !options.gameStore.roomId) {
return '未进入房间'
}
if (options.session.wsStatus.value !== 'connected') {
return 'WebSocket 未连接'
}
if (showDingQueChooser.value) {
return '请先完成定缺'
}
if (options.gameStore.phase !== 'playing') {
return '当前不是出牌阶段'
}
if (player.seatIndex !== options.gameStore.currentTurn) {
return '未轮到你出牌'
}
if (options.gameStore.needDraw) {
return '请先摸牌'
}
if (options.gameStore.pendingClaim) {
return '等待当前操作结算'
}
if (player.handTiles.length === 0) {
return '当前没有可出的手牌'
}
if (options.session.discardPending.value) {
return '正在提交出牌'
}
return ''
})
function discardTileBlockedReason(tile: Tile): string {
if (discardBlockedReason.value) {
return discardBlockedReason.value
}
const player = options.myPlayer.value
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
if (player && missingSuit && hasMissingSuitTiles.value && tile.suit !== missingSuit) {
return `当前必须先打${missingSuitLabel(missingSuit)}`
}
return ''
}
const canConfirmDiscard = computed(() => {
const tile = selectedDiscardTile.value
return Boolean(tile && !discardTileBlockedReason(tile))
})
const confirmDiscardLabel = computed(() => {
const tile = selectedDiscardTile.value
return tile ? `出牌 ${formatTile(tile)}` : '出牌'
})
const canDrawTile = computed(() => {
const player = options.myPlayer.value
if (!player || !options.gameStore.roomId) {
return false
}
return (
options.gameStore.phase === 'playing' &&
options.gameStore.needDraw &&
player.seatIndex === options.gameStore.currentTurn
)
})
const myClaimState = computed(() => options.gameStore.pendingClaim)
const visibleClaimOptions = computed<ClaimOptionState[]>(() => {
const current = myClaimState.value?.options ?? []
const order: ClaimOptionState[] = ['hu', 'gang', 'peng', 'pass']
return order.filter((option) => current.includes(option))
})
const showClaimActions = computed(() => visibleClaimOptions.value.length > 0)
const canSelfHu = computed(() => {
const player = options.myPlayer.value
if (!player || !options.gameStore.roomId || options.session.wsStatus.value !== 'connected') {
return false
}
if (
showDingQueChooser.value ||
options.gameStore.phase !== 'playing' ||
options.gameStore.needDraw ||
options.gameStore.pendingClaim
) {
return false
}
if (player.seatIndex !== options.gameStore.currentTurn || options.session.turnActionPending.value) {
return false
}
return options.session.selfTurnAllowActions.value.includes('hu')
})
const canSelfGang = computed(() => {
const player = options.myPlayer.value
if (!player || !options.gameStore.roomId || options.session.wsStatus.value !== 'connected') {
return false
}
if (
showDingQueChooser.value ||
options.gameStore.phase !== 'playing' ||
options.gameStore.needDraw ||
options.gameStore.pendingClaim
) {
return false
}
if (player.seatIndex !== options.gameStore.currentTurn || options.session.turnActionPending.value) {
return false
}
return options.session.selfTurnAllowActions.value.includes('gang')
})
function toggleReadyState(): void {
if (options.session.readyTogglePending.value) {
return
}
const nextReady = !myReadyState.value
options.session.readyTogglePending.value = true
sendWsMessage({
type: 'set_ready',
roomId: options.gameStore.roomId,
payload: {
ready: nextReady,
isReady: nextReady,
},
})
}
function startGame(): void {
if (!canStartGame.value) {
return
}
options.session.startGamePending.value = true
sendWsMessage({
type: 'start_game',
roomId: options.gameStore.roomId,
payload: {
room_id: options.gameStore.roomId,
},
})
}
function nextRound(): void {
if (
options.session.nextRoundPending.value ||
!options.gameStore.roomId ||
options.gameStore.phase !== 'settlement'
) {
return
}
options.session.settlementOverlayDismissed.value = true
options.session.nextRoundPending.value = true
sendWsMessage({
type: 'next_round',
roomId: options.gameStore.roomId,
payload: {},
})
}
function chooseDingQue(suit: Tile['suit']): void {
if (options.session.dingQuePending.value || !showDingQueChooser.value) {
return
}
options.session.dingQuePending.value = true
sendWsMessage({
type: 'ding_que',
roomId: options.gameStore.roomId,
payload: {suit},
})
}
function selectDiscardTile(tile: Tile): void {
const blockedReason = discardTileBlockedReason(tile)
if (blockedReason) {
options.session.wsError.value = blockedReason
options.session.wsMessages.value.push(`[client-blocked] select ${formatTile(tile)}: ${blockedReason}`)
options.session.selectedDiscardTileId.value = null
return
}
options.session.wsError.value = ''
options.session.selectedDiscardTileId.value =
options.session.selectedDiscardTileId.value === tile.id ? null : tile.id
}
function confirmDiscard(): void {
const tile = selectedDiscardTile.value
if (!tile) {
return
}
const blockedReason = discardTileBlockedReason(tile)
if (blockedReason || !options.gameStore.roomId) {
if (blockedReason) {
options.session.wsError.value = blockedReason
options.session.wsMessages.value.push(`[client-blocked] discard ${formatTile(tile)}: ${blockedReason}`)
}
return
}
options.session.wsError.value = ''
options.session.markDiscardPendingWithFallback()
sendWsMessage({
type: 'discard',
roomId: options.gameStore.roomId,
payload: {
tile: {
id: tile.id,
suit: tile.suit,
value: tile.value,
},
},
})
}
function drawTile(): void {
if (!canDrawTile.value) {
return
}
sendWsMessage({
type: 'draw',
roomId: options.gameStore.roomId,
payload: {
room_id: options.gameStore.roomId,
},
})
}
function submitSelfGang(): void {
if (!options.gameStore.roomId || !canSelfGang.value || options.session.turnActionPending.value) {
return
}
options.session.markTurnActionPending('gang')
sendWsMessage({
type: 'gang',
roomId: options.gameStore.roomId,
payload: {
room_id: options.gameStore.roomId,
},
})
}
function submitSelfHu(): void {
if (!options.gameStore.roomId || !canSelfHu.value || options.session.turnActionPending.value) {
return
}
options.session.markTurnActionPending('hu')
sendWsMessage({
type: 'hu',
roomId: options.gameStore.roomId,
payload: {
room_id: options.gameStore.roomId,
},
})
}
function submitClaim(action: ClaimOptionState): void {
if (
options.session.claimActionPending.value ||
!options.gameStore.roomId ||
!visibleClaimOptions.value.includes(action)
) {
return
}
const claimTile = options.gameStore.pendingClaim?.tile
options.session.claimActionPending.value = true
sendWsMessage({
type: action,
roomId: options.gameStore.roomId,
payload: {
room_id: options.gameStore.roomId,
...(action !== 'pass' && claimTile
? {
tile: {
id: claimTile.id,
suit: claimTile.suit,
value: claimTile.value,
},
}
: {}),
},
})
}
return {
isLastRound,
myReadyState,
isRoomOwner,
showStartGameButton,
showWaitingOwnerTip,
canStartGame,
showReadyToggle,
showDingQueChooser,
selectedDiscardTile,
discardBlockedReason,
discardTileBlockedReason,
canConfirmDiscard,
confirmDiscardLabel,
canDrawTile,
visibleClaimOptions,
showClaimActions,
canSelfHu,
canSelfGang,
toggleReadyState,
startGame,
nextRound,
chooseDingQue,
selectDiscardTile,
confirmDiscard,
drawTile,
submitSelfGang,
submitSelfHu,
submitClaim,
}
}

View File

@@ -0,0 +1,459 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
import { refreshAccessToken } from '../../../api/auth'
import { AuthExpiredError, type AuthSession } from '../../../api/authed-request'
import { getUserInfo } from '../../../api/user'
import type { RoomMetaSnapshotState } from '../../../store/state'
import { clearAuth, readStoredAuth, writeStoredAuth } from '../../../utils/auth-storage'
import type { WsStatus } from '../../../ws/client'
import { wsClient } from '../../../ws/client'
import { sendWsMessage } from '../../../ws/sender'
import { buildWsUrl } from '../../../ws/url'
import type { PlayerActionTimer } from '../types'
interface UseChengduGameSessionOptions {
route: RouteLocationNormalizedLoaded
router: Router
gameStore: {
roomId: string
}
roomMeta: { value: RoomMetaSnapshotState | null }
}
export function useChengduGameSession(options: UseChengduGameSessionOptions) {
const auth = ref(readStoredAuth())
const now = ref(Date.now())
const wsStatus = ref<WsStatus>('idle')
const wsMessages = ref<string[]>([])
const wsError = ref('')
const roomCountdown = ref<PlayerActionTimer | null>(null)
const leaveRoomPending = ref(false)
const readyTogglePending = ref(false)
const startGamePending = ref(false)
const dingQuePending = ref(false)
const discardPending = ref(false)
const claimActionPending = ref(false)
const turnActionPending = ref(false)
const nextRoundPending = ref(false)
const settlementOverlayDismissed = ref(false)
const settlementDeadlineMs = ref<number | null>(null)
const selectedDiscardTileId = ref<number | null>(null)
const selfTurnAllowActions = ref<string[]>([])
const menuOpen = ref(false)
const isTrustMode = ref(false)
const menuTriggerActive = ref(false)
let clockTimer: number | null = null
let discardPendingTimer: number | null = null
let turnActionPendingTimer: number | null = null
let menuTriggerTimer: number | null = null
let menuOpenTimer: number | null = null
let refreshingWsToken = false
let lastForcedRefreshAt = 0
const loggedInUserId = computed(() => {
const source = auth.value?.user as Record<string, unknown> | undefined
const rawId = source?.id ?? source?.userID ?? source?.user_id
if (typeof rawId === 'string' && rawId.trim()) {
return rawId
}
if (typeof rawId === 'number') {
return String(rawId)
}
return ''
})
const loggedInUserName = computed(() => auth.value?.user?.nickname || auth.value?.user?.username || '')
const localCachedAvatarUrl = computed(() => {
const source = auth.value?.user as Record<string, unknown> | undefined
if (!source) {
return ''
}
const avatarCandidates = [
source.avatar,
source.avatar_url,
source.avatarUrl,
source.head_img,
source.headImg,
source.profile_image,
source.profileImage,
]
for (const candidate of avatarCandidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate
}
}
return ''
})
const networkLabel = computed(() => {
const map: Record<WsStatus, string> = {
connected: '已连接',
connecting: '连接中',
error: '连接异常',
idle: '未连接',
closed: '未连接',
}
return map[wsStatus.value] ?? '未连接'
})
const formattedClock = computed(() =>
new Date(now.value).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}),
)
function toggleMenu(): void {
menuTriggerActive.value = true
if (menuTriggerTimer !== null) {
window.clearTimeout(menuTriggerTimer)
}
menuTriggerTimer = window.setTimeout(() => {
menuTriggerActive.value = false
menuTriggerTimer = null
}, 180)
if (menuOpen.value) {
menuOpen.value = false
return
}
if (menuOpenTimer !== null) {
window.clearTimeout(menuOpenTimer)
}
menuOpenTimer = window.setTimeout(() => {
menuOpen.value = true
menuOpenTimer = null
}, 85)
}
function toggleTrustMode(): void {
isTrustMode.value = !isTrustMode.value
menuOpen.value = false
}
function clearTurnActionPending(): void {
turnActionPending.value = false
if (turnActionPendingTimer !== null) {
window.clearTimeout(turnActionPendingTimer)
turnActionPendingTimer = null
}
}
function markTurnActionPending(kind: 'gang' | 'hu'): void {
clearTurnActionPending()
turnActionPending.value = true
turnActionPendingTimer = window.setTimeout(() => {
turnActionPending.value = false
turnActionPendingTimer = null
wsError.value = `${kind === 'gang' ? '杠牌' : '胡牌'}未收到服务器确认`
}, 2500)
}
function clearDiscardPendingTimer(): void {
if (discardPendingTimer !== null) {
window.clearTimeout(discardPendingTimer)
discardPendingTimer = null
}
}
function markDiscardCompleted(): void {
clearDiscardPendingTimer()
discardPending.value = false
selectedDiscardTileId.value = null
}
function markDiscardPendingWithFallback(): void {
clearDiscardPendingTimer()
discardPending.value = true
discardPendingTimer = window.setTimeout(() => {
discardPending.value = false
selectedDiscardTileId.value = null
discardPendingTimer = null
}, 2000)
}
function logoutToLogin(): void {
clearAuth()
auth.value = null
wsClient.close()
void options.router.replace('/login')
}
function currentSession(): AuthSession | null {
const current = auth.value
if (!current?.token) {
return null
}
return {
token: current.token,
tokenType: current.tokenType,
refreshToken: current.refreshToken,
expiresIn: current.expiresIn,
}
}
function syncAuthSession(next: AuthSession): void {
if (!auth.value) {
return
}
auth.value = {
...auth.value,
token: next.token,
tokenType: next.tokenType ?? auth.value.tokenType,
refreshToken: next.refreshToken ?? auth.value.refreshToken,
expiresIn: next.expiresIn,
}
writeStoredAuth(auth.value)
}
function syncCurrentUserID(userID: string): void {
if (!userID || loggedInUserId.value || !auth.value) {
return
}
auth.value = {
...auth.value,
user: {
...(auth.value.user ?? {}),
id: userID,
},
}
writeStoredAuth(auth.value)
}
async function ensureCurrentUserLoaded(): Promise<void> {
if (loggedInUserId.value) {
return
}
const currentAuth = auth.value
const session = currentSession()
if (!session) {
return
}
try {
const userInfo = await getUserInfo(session, syncAuthSession)
const resolvedId = userInfo.userID ?? userInfo.user_id ?? userInfo.id ?? currentAuth?.user?.id
const nextUser = {
...(currentAuth?.user ?? {}),
...userInfo,
id: typeof resolvedId === 'string' || typeof resolvedId === 'number' ? resolvedId : undefined,
}
if (!currentAuth) {
return
}
auth.value = {
...currentAuth,
user: nextUser,
}
writeStoredAuth(auth.value)
} catch (error) {
if (error instanceof AuthExpiredError) {
logoutToLogin()
}
}
}
function decodeJwtExpMs(token: string): number | null {
const payloadPart = token.split('.')[1]
if (!payloadPart) {
return null
}
try {
const normalized = payloadPart.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
const payload = JSON.parse(window.atob(padded)) as { exp?: number }
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
} catch {
return null
}
}
function shouldRefreshWsToken(token: string): boolean {
const expMs = decodeJwtExpMs(token)
return Boolean(expMs && expMs <= Date.now() + 30_000)
}
async function resolveWsToken(forceRefresh = false, logoutOnRefreshFail = false): Promise<string | null> {
const current = auth.value
if (!current?.token) {
return null
}
if (!forceRefresh && !shouldRefreshWsToken(current.token)) {
return current.token
}
if (!current.refreshToken || refreshingWsToken) {
return current.token
}
refreshingWsToken = true
try {
const refreshed = await refreshAccessToken({
token: current.token,
tokenType: current.tokenType,
refreshToken: current.refreshToken,
})
const nextAuth = {
...current,
token: refreshed.token,
tokenType: refreshed.tokenType ?? current.tokenType,
refreshToken: refreshed.refreshToken ?? current.refreshToken,
expiresIn: refreshed.expiresIn,
}
auth.value = nextAuth
writeStoredAuth(nextAuth)
return nextAuth.token
} catch {
if (logoutOnRefreshFail) {
logoutToLogin()
}
return null
} finally {
refreshingWsToken = false
}
}
async function ensureWsConnected(forceRefresh = false): Promise<void> {
const token = await resolveWsToken(forceRefresh, false)
if (!token) {
wsError.value = '未找到登录凭证,无法建立连接'
return
}
wsError.value = ''
wsClient.connect(buildWsUrl(), token)
}
function backHall(): void {
leaveRoomPending.value = true
const roomId = options.gameStore.roomId
sendWsMessage({
type: 'leave_room',
roomId,
payload: {
room_id: roomId,
},
})
wsClient.close()
void options.router.push('/hall').finally(() => {
leaveRoomPending.value = false
})
}
function handleGlobalClick(event: MouseEvent): void {
const target = event.target as HTMLElement | null
if (!target || target.closest('.menu-trigger-wrap')) {
return
}
menuOpen.value = false
}
function handleGlobalEsc(event: KeyboardEvent): void {
if (event.key === 'Escape') {
menuOpen.value = false
}
}
onMounted(() => {
clockTimer = window.setInterval(() => {
now.value = Date.now()
}, 1000)
window.addEventListener('click', handleGlobalClick)
window.addEventListener('keydown', handleGlobalEsc)
})
onBeforeUnmount(() => {
if (clockTimer !== null) {
window.clearInterval(clockTimer)
clockTimer = null
}
clearDiscardPendingTimer()
clearTurnActionPending()
window.removeEventListener('click', handleGlobalClick)
window.removeEventListener('keydown', handleGlobalEsc)
if (menuTriggerTimer !== null) {
window.clearTimeout(menuTriggerTimer)
menuTriggerTimer = null
}
if (menuOpenTimer !== null) {
window.clearTimeout(menuOpenTimer)
menuOpenTimer = null
}
})
return {
auth,
now,
wsStatus,
wsMessages,
wsError,
roomCountdown,
leaveRoomPending,
readyTogglePending,
startGamePending,
dingQuePending,
discardPending,
claimActionPending,
turnActionPending,
nextRoundPending,
settlementOverlayDismissed,
settlementDeadlineMs,
selectedDiscardTileId,
selfTurnAllowActions,
menuOpen,
isTrustMode,
menuTriggerActive,
loggedInUserId,
loggedInUserName,
localCachedAvatarUrl,
networkLabel,
formattedClock,
toggleMenu,
toggleTrustMode,
clearTurnActionPending,
markTurnActionPending,
clearDiscardPendingTimer,
markDiscardCompleted,
markDiscardPendingWithFallback,
logoutToLogin,
currentSession,
syncAuthSession,
syncCurrentUserID,
ensureCurrentUserLoaded,
resolveWsToken,
ensureWsConnected,
backHall,
lastForcedRefreshAtRef: {
get value() {
return lastForcedRefreshAt
},
set value(next: number) {
lastForcedRefreshAt = next
},
},
}
}

View File

@@ -0,0 +1,143 @@
import { computed, onBeforeUnmount, onMounted, type ComputedRef } from 'vue'
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
import type { RoomMetaSnapshotState } from '../../../store/state'
import type { PlayerState, Tile } from '../../../types/state'
import { wsClient, type WsStatus } from '../../../ws/client'
import { buildWsUrl } from '../../../ws/url'
import type { DisplayPlayer } from '../types'
import { useChengduGameSession } from './useChengduGameSession'
import { createChengduMessageHandlers } from '../socket/createMessageHandlers'
interface UseChengduGameSocketOptions {
route: RouteLocationNormalizedLoaded
router: Router
gameStore: {
roomId: string
phase: string
players: Record<string, PlayerState>
dealerIndex: number
currentTurn: number
remainingTiles: number
needDraw: boolean
pendingClaim?: any
scores: Record<string, number>
winners: string[]
currentRound: number
totalRounds: number
resetGame: () => void
}
roomMeta: { value: RoomMetaSnapshotState | null }
roomName: ComputedRef<string>
myHandTiles: ComputedRef<Tile[]>
myPlayer: ComputedRef<DisplayPlayer | undefined>
session: ReturnType<typeof useChengduGameSession>
}
export function useChengduGameSocket(options: UseChengduGameSocketOptions) {
let unsubscribe: (() => void) | null = null
let needsInitialRoomInfo = false
const showSettlementOverlay = computed(
() => options.gameStore.phase === 'settlement' && !options.session.settlementOverlayDismissed.value,
)
const settlementCountdown = computed(() => {
if (!showSettlementOverlay.value || !options.session.settlementDeadlineMs.value) {
return null
}
return Math.max(
0,
Math.ceil((options.session.settlementDeadlineMs.value - options.session.now.value) / 1000),
)
})
const handlers = createChengduMessageHandlers({
router: options.router,
gameStore: options.gameStore,
roomMeta: options.roomMeta,
roomName: options.roomName,
myHandTiles: options.myHandTiles,
myPlayer: options.myPlayer,
session: options.session,
})
function requestRoomInfo(): void {
const routeRoomId = typeof options.route.params.roomId === 'string' ? options.route.params.roomId : ''
const roomId = routeRoomId || options.gameStore.roomId || options.roomMeta.value?.roomId || ''
if (!roomId || options.session.wsStatus.value !== 'connected') {
return
}
needsInitialRoomInfo = false
options.session.wsMessages.value.push(`[client] get_room_info ${roomId}`)
wsClient.send({
type: 'get_room_info',
roomId,
payload: {
room_id: roomId,
},
})
}
function handleSocketError(message: string): void {
options.session.markDiscardCompleted()
options.session.clearTurnActionPending()
options.session.wsError.value = message
options.session.wsMessages.value.push(`[error] ${message}`)
const nowMs = Date.now()
if (nowMs - options.session.lastForcedRefreshAtRef.value > 5000) {
options.session.lastForcedRefreshAtRef.value = nowMs
void options.session
.resolveWsToken(true, true)
.then((refreshedToken) => {
if (!refreshedToken) {
return
}
options.session.wsError.value = ''
wsClient.reconnect(buildWsUrl(), refreshedToken)
})
.catch(() => {
options.session.logoutToLogin()
})
}
}
onMounted(() => {
const routeRoomId = typeof options.route.params.roomId === 'string' ? options.route.params.roomId : ''
needsInitialRoomInfo = true
void options.session.ensureCurrentUserLoaded().finally(() => {
handlers.hydrateFromActiveRoom(routeRoomId)
if (routeRoomId) {
options.gameStore.roomId = routeRoomId
}
if (options.session.wsStatus.value === 'connected' && needsInitialRoomInfo) {
requestRoomInfo()
}
})
const statusHandler = (status: WsStatus) => {
options.session.wsStatus.value = status
if (status === 'connected' && needsInitialRoomInfo) {
requestRoomInfo()
}
}
wsClient.onMessage(handlers.handleSocketMessage)
wsClient.onError(handleSocketError)
unsubscribe = wsClient.onStatusChange(statusHandler)
void options.session.ensureWsConnected()
})
onBeforeUnmount(() => {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
})
return {
showSettlementOverlay,
settlementCountdown,
}
}

View File

@@ -0,0 +1,431 @@
import { computed } from 'vue'
import eastWind from '../../../assets/images/direction/dong.png'
import southWind from '../../../assets/images/direction/nan.png'
import westWind from '../../../assets/images/direction/xi.png'
import northWind from '../../../assets/images/direction/bei.png'
import type { SeatPlayerCardModel } from '../../../components/game/seat-player-card'
import { getTileImage as getBottomTileImage } from '../../../config/bottomTileMap'
import { getTileImage as getLeftTileImage } from '../../../config/leftTileMap'
import { getTileImage as getRightTileImage } from '../../../config/rightTileMap'
import { getTileImage as getTopTileImage } from '../../../config/topTileMap'
import type { SeatKey } from '../../../game/seat'
import type { Tile } from '../../../types/tile'
import type {
DeskSeatState,
DisplayPlayer,
HandSuitLabel,
TableTileImageType,
TableViewDeps,
TableViewResult,
WallSeatState,
WallTileItem,
} from '../types'
const handSuitOrder: Record<Tile['suit'], number> = { W: 0, T: 1, B: 2 }
const handSuitLabelMap: Record<Tile['suit'], HandSuitLabel> = { W: '万', T: '筒', B: '条' }
function buildWallTileImage(
seat: SeatKey,
tile: Tile | undefined,
imageType: TableTileImageType,
): string {
switch (seat) {
case 'top':
return getTopTileImage(tile, imageType, 'top')
case 'right':
return getRightTileImage(tile, imageType, 'right')
case 'left':
return getLeftTileImage(tile, imageType, 'left')
case 'bottom':
default:
return tile ? getBottomTileImage(tile, imageType, 'bottom') : ''
}
}
export function missingSuitLabel(value: string | null | undefined): string {
const suitMap: Record<string, string> = {
w: '万',
t: '筒',
b: '条',
wan: '万',
tong: '筒',
tiao: '条',
}
if (!value) {
return ''
}
const normalized = value.trim().toLowerCase()
return suitMap[normalized] ?? value
}
export function formatTile(tile: Tile): string {
return `${tile.suit}${tile.value}`
}
function emptyWallSeat(): WallSeatState {
return { tiles: [] }
}
function emptyDeskSeat(): DeskSeatState {
return { tiles: [], hasHu: false }
}
export function useChengduTableView(deps: TableViewDeps): TableViewResult {
const visibleHandTileGroups = computed(() => {
const grouped = new Map<HandSuitLabel, Tile[]>()
deps.myHandTiles.value
.slice()
.sort((left, right) => {
const suitDiff = handSuitOrder[left.suit] - handSuitOrder[right.suit]
if (suitDiff !== 0) {
return suitDiff
}
const valueDiff = left.value - right.value
if (valueDiff !== 0) {
return valueDiff
}
return left.id - right.id
})
.forEach((tile) => {
const label = handSuitLabelMap[tile.suit]
const current = grouped.get(label) ?? []
current.push(tile)
grouped.set(label, current)
})
return (['万', '筒', '条'] as HandSuitLabel[])
.map((suit) => ({
suit,
tiles: grouped.get(suit) ?? [],
}))
.filter((group) => group.tiles.length > 0)
})
const sortedVisibleHandTiles = computed(() => visibleHandTileGroups.value.flatMap((group) => group.tiles))
const roomName = computed(() => {
const activeRoomName =
deps.roomMeta.value && deps.roomMeta.value.roomId === deps.gameStore.roomId
? deps.roomMeta.value.roomName
: ''
return deps.routeRoomName.value || activeRoomName || `房间 ${deps.gameStore.roomId || '--'}`
})
const roomState = computed(() => {
const status =
deps.gameStore.phase === 'waiting'
? 'waiting'
: deps.gameStore.phase === 'settlement'
? 'finished'
: 'playing'
const wall = Array.from({ length: deps.gameStore.remainingTiles }, (_, index) => `wall-${index}`)
const maxPlayers =
deps.roomMeta.value && deps.roomMeta.value.roomId === deps.gameStore.roomId
? deps.roomMeta.value.maxPlayers
: 4
return {
roomId: deps.gameStore.roomId,
name: roomName.value,
playerCount: deps.gamePlayers.value.length,
maxPlayers,
status,
game: {
state: {
wall,
dealerIndex: deps.gameStore.dealerIndex,
currentTurn: deps.gameStore.currentTurn,
phase: deps.gameStore.phase,
},
},
}
})
const seatViews = computed(() => {
const players = deps.gamePlayers.value
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
const selfSeatIndex =
deps.myPlayer.value?.seatIndex ??
players.find((player) => player.playerId === deps.loggedInUserId.value)?.seatIndex ??
0
return players.slice(0, 4).map((player) => {
const relativeIndex = (selfSeatIndex - player.seatIndex + 4) % 4
const seatKey = tableOrder[relativeIndex] ?? 'top'
return {
key: seatKey,
player,
isSelf: player.playerId === deps.loggedInUserId.value,
isTurn: player.seatIndex === deps.gameStore.currentTurn,
}
})
})
const seatWinds = computed<Record<SeatKey, string>>(() => {
const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left']
const players = deps.gamePlayers.value
const selfSeatIndex =
deps.myPlayer.value?.seatIndex ??
players.find((player) => player.playerId === deps.loggedInUserId.value)?.seatIndex ??
0
const directionBySeatIndex = [eastWind, southWind, westWind, northWind]
const result: Record<SeatKey, string> = {
top: northWind,
right: eastWind,
bottom: southWind,
left: westWind,
}
for (let absoluteSeat = 0; absoluteSeat < 4; absoluteSeat += 1) {
const relativeIndex = (selfSeatIndex - absoluteSeat + 4) % 4
const seatKey = tableOrder[relativeIndex] ?? 'top'
result[seatKey] = directionBySeatIndex[absoluteSeat] ?? northWind
}
return result
})
const currentTurnSeat = computed<SeatKey | ''>(() => seatViews.value.find((seat) => seat.isTurn)?.key ?? '')
const currentPhaseText = computed(() => {
const map: Record<string, string> = {
waiting: '等待中',
dealing: '发牌中',
playing: '对局中',
action: '操作中',
settlement: '已结算',
}
return map[deps.gameStore.phase] ?? deps.gameStore.phase
})
const roomStatusText = computed(() => {
const map: Record<string, string> = {
waiting: '等待玩家',
playing: '游戏中',
finished: '已结束',
}
const status = roomState.value.status
return map[status] ?? status ?? '--'
})
const roundText = computed(() => {
if (deps.gameStore.totalRounds > 0) {
return `${deps.gameStore.currentRound}/${deps.gameStore.totalRounds}`
}
return ''
})
const settlementPlayers = computed(() => {
const winnerSet = new Set(deps.gameStore.winners)
return Object.values(deps.gameStore.players as Record<string, DisplayPlayer>)
.map((player) => ({
playerId: player.playerId,
displayName: player.displayName || `玩家${player.seatIndex + 1}`,
score: deps.gameStore.scores[player.playerId] ?? 0,
isWinner: winnerSet.has(player.playerId),
seatIndex: player.seatIndex,
isReady: Boolean(player.isReady),
}))
.sort((a, b) => b.score - a.score)
})
const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
const emptyState: Record<SeatKey, WallSeatState> = {
top: emptyWallSeat(),
right: emptyWallSeat(),
bottom: emptyWallSeat(),
left: emptyWallSeat(),
}
if (deps.gameStore.phase === 'waiting' && deps.myHandTiles.value.length === 0) {
return emptyState
}
for (const seat of seatViews.value) {
if (!seat.player) {
continue
}
const seatTiles: WallTileItem[] = []
const targetSeat = seat.key
if (seat.isSelf) {
const missingSuit = seat.player.missingSuit as Tile['suit'] | null | undefined
sortedVisibleHandTiles.value.forEach((tile, index) => {
const src = buildWallTileImage(targetSeat, tile, 'hand')
if (!src) {
return
}
const previousTile = index > 0 ? sortedVisibleHandTiles.value[index - 1] : undefined
const isMissingSuitGroupStart = Boolean(
missingSuit &&
tile.suit === missingSuit &&
(!previousTile || previousTile.suit !== tile.suit),
)
seatTiles.push({
key: `hand-${tile.id}-${index}`,
src,
alt: formatTile(tile),
imageType: 'hand',
showLackTag: isMissingSuitGroupStart,
suit: tile.suit,
tile,
})
})
} else {
for (let index = 0; index < seat.player.handCount; index += 1) {
const src = buildWallTileImage(targetSeat, undefined, 'hand')
if (!src) {
continue
}
seatTiles.push({
key: `concealed-${index}`,
src,
alt: '手牌背面',
imageType: 'hand',
})
}
}
emptyState[targetSeat] = { tiles: seatTiles }
}
return emptyState
})
const deskSeats = computed<Record<SeatKey, DeskSeatState>>(() => {
const emptyState: Record<SeatKey, DeskSeatState> = {
top: emptyDeskSeat(),
right: emptyDeskSeat(),
bottom: emptyDeskSeat(),
left: emptyDeskSeat(),
}
if (deps.gameStore.phase === 'waiting' && deps.myHandTiles.value.length === 0) {
return emptyState
}
for (const seat of seatViews.value) {
if (!seat.player) {
continue
}
const seatTiles: WallTileItem[] = []
const targetSeat = seat.key
seat.player.discardTiles.forEach((tile, index) => {
const src = buildWallTileImage(targetSeat, tile, 'exposed')
if (!src) {
return
}
seatTiles.push({
key: `discard-${tile.id}-${index}`,
src,
alt: formatTile(tile),
imageType: 'exposed',
suit: tile.suit,
})
})
seat.player.melds.forEach((meld, meldIndex) => {
meld.tiles.forEach((tile, tileIndex) => {
const imageType: TableTileImageType = meld.type === 'an_gang' ? 'covered' : 'exposed'
const src = buildWallTileImage(targetSeat, tile, imageType)
if (!src) {
return
}
seatTiles.push({
key: `desk-${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`,
src,
alt: formatTile(tile),
imageType,
isGroupStart: tileIndex === 0,
suit: tile.suit,
})
})
})
emptyState[targetSeat] = {
tiles: seatTiles,
hasHu: seat.player.hasHu,
}
}
return emptyState
})
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
const defaultMissingSuitLabel = missingSuitLabel(null)
const emptySeat = (): SeatPlayerCardModel => ({
avatarUrl: '',
name: '空位',
dealer: false,
isTurn: false,
isReady: false,
isTrustee: false,
missingSuitLabel: defaultMissingSuitLabel,
})
const result: Record<SeatKey, SeatPlayerCardModel> = {
top: emptySeat(),
right: emptySeat(),
bottom: emptySeat(),
left: emptySeat(),
}
for (const seat of seatViews.value) {
if (!seat.player) {
continue
}
const displayName = seat.player.displayName || `玩家${seat.player.seatIndex + 1}`
const avatarUrl = seat.isSelf
? deps.localCachedAvatarUrl.value || seat.player.avatarURL || ''
: seat.player.avatarURL || ''
const selfDisplayName = seat.player.displayName || deps.loggedInUserName.value || '你自己'
result[seat.key] = {
avatarUrl,
name: Array.from(seat.isSelf ? selfDisplayName : displayName)
.slice(0, 4)
.join(''),
dealer: seat.player.seatIndex === dealerIndex,
isTurn: seat.isTurn,
isReady: Boolean(seat.player.isReady),
isTrustee: Boolean(seat.player.isTrustee),
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
}
}
return result
})
return {
roomName,
roomState,
seatViews,
seatWinds,
currentTurnSeat,
currentPhaseText,
roomStatusText,
roundText,
visibleHandTileGroups,
sortedVisibleHandTiles,
wallSeats,
deskSeats,
seatDecor,
settlementPlayers,
}
}

View File

@@ -0,0 +1,51 @@
import { createPlayerHandlers } from './handlers/playerHandlers'
import { createRoomInfoHandlers } from './handlers/roomInfoHandlers'
import { createRoomStateHandlers } from './handlers/roomStateHandlers'
import { createStatusHandlers } from './handlers/statusHandlers'
import { dispatchSocketGameAction } from './handlers/socketDispatch'
import { createTurnHandlers } from './handlers/turnHandlers'
import type { SocketHandlerContext } from './types'
import { parseSocketEnvelope } from './parsers/socketEnvelope'
import { hydrateGameStoreFromActiveRoom } from './room/roomSnapshotSync'
import { createSocketMessageRouter } from './router/socketMessageRouter'
import { pushWsMessage } from './session/sessionStateAdapter'
export function createChengduMessageHandlers(context: SocketHandlerContext) {
const roomHandlers = {
...createRoomInfoHandlers(context),
...createRoomStateHandlers(context),
}
const playerHandlers = createPlayerHandlers(context)
const turnHandlers = createTurnHandlers(context)
const statusHandlers = createStatusHandlers(context)
const router = createSocketMessageRouter({
roomHandlers,
playerHandlers,
turnHandlers,
statusHandlers,
})
function handleSocketMessage(msg: unknown): void {
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
pushWsMessage(context.session, `[server] ${text}`)
const envelope = parseSocketEnvelope(msg)
if (!envelope) {
return
}
router.route(envelope.normalizedType, envelope.source)
dispatchSocketGameAction(
context,
envelope.source,
playerHandlers.syncReadyStatesFromRoomUpdate,
playerHandlers.syncTrusteeState,
)
}
return {
hydrateFromActiveRoom: (routeRoomId: string) => hydrateGameStoreFromActiveRoom(context, routeRoomId),
handleSocketMessage,
}
}

View File

@@ -0,0 +1,170 @@
import {
asRecord,
normalizeTiles,
normalizeWsType,
readBoolean,
readString,
} from '../../../../game/chengdu/messageNormalizers'
import type { RoomPlayerUpdatePayload, RoomTrusteePayload } from '../../../../game/actions'
import {
clearDingQuePending,
clearReadyTogglePending,
clearStartGamePending,
clearTurnPending,
completeDiscard,
setTrustMode,
syncCurrentUserId,
} from '../session/sessionStateAdapter'
import { setPlayerHandState, setPlayerMissingSuit, setPlayerReadyState, setPlayerTrusteeState } from '../store/gameStoreAdapter'
import type { PlayerHandlerApi, SocketHandlerContext } from '../types'
export function createPlayerHandlers(context: SocketHandlerContext): PlayerHandlerApi {
function applyPlayerReadyState(playerId: string, ready: boolean): void {
setPlayerReadyState(context.gameStore, playerId, ready)
}
function syncReadyStatesFromRoomUpdate(payload: RoomPlayerUpdatePayload): void {
if (!Array.isArray(payload.players)) {
return
}
for (const item of payload.players) {
const playerId =
(typeof item.PlayerID === 'string' && item.PlayerID) ||
(typeof item.player_id === 'string' && item.player_id) ||
''
const ready =
typeof item.Ready === 'boolean'
? item.Ready
: typeof item.ready === 'boolean'
? item.ready
: typeof item.is_ready === 'boolean'
? item.is_ready
: undefined
if (!playerId || typeof ready !== 'boolean') {
continue
}
applyPlayerReadyState(playerId, ready)
}
}
function handlePlayerHandResponse(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_HAND') {
return
}
const payload = asRecord(source.payload)
if (!payload) {
return
}
syncCurrentUserId(context.session, readString(source, 'target'))
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId')
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
return
}
const handTiles = normalizeTiles(payload.hand)
if (!context.session.loggedInUserId.value || handTiles.length === 0) {
return
}
clearTurnPending(context.session)
setPlayerHandState(context.gameStore, context.session.loggedInUserId.value, handTiles)
clearDingQuePending(context.session)
completeDiscard(context.session)
if (context.gameStore.phase !== 'waiting') {
clearStartGamePending(context.session)
}
}
function syncTrusteeState(payload: RoomTrusteePayload): void {
const playerId =
(typeof payload.player_id === 'string' && payload.player_id) ||
(typeof payload.playerId === 'string' && payload.playerId) ||
''
if (!playerId) {
return
}
const trustee = typeof payload.trustee === 'boolean' ? payload.trustee : true
setPlayerTrusteeState(context.gameStore, playerId, trustee)
if (playerId === context.session.loggedInUserId.value) {
setTrustMode(context.session, trustee)
}
}
function handleReadyStateResponse(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_READY') {
return
}
const payload = asRecord(source.payload)
if (!payload) {
return
}
const roomId = typeof payload.room_id === 'string' ? payload.room_id : readString(source, 'roomId')
const userId =
(typeof payload.player_id === 'string' && payload.player_id) ||
(typeof payload.user_id === 'string' && payload.user_id) ||
readString(source, 'target')
const ready = readBoolean(payload, 'is_ready', 'ready', 'Ready')
if (roomId && roomId !== context.gameStore.roomId) {
return
}
if (ready !== null && userId) {
applyPlayerReadyState(userId, ready)
}
if (userId && userId === context.session.loggedInUserId.value) {
clearReadyTogglePending(context.session)
}
}
function handlePlayerDingQueResponse(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_DING_QUE') {
return
}
const payload = asRecord(source.payload)
if (!payload) {
return
}
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId')
if (roomId && roomId !== context.gameStore.roomId) {
return
}
const userId =
readString(payload, 'user_id', 'userId', 'player_id', 'playerId') || readString(source, 'target')
const suit = readString(payload, 'suit', 'Suit')
if (!userId || !suit) {
return
}
setPlayerMissingSuit(context.gameStore, userId, suit)
if (userId === context.session.loggedInUserId.value) {
clearDingQuePending(context.session)
}
}
return {
applyPlayerReadyState,
syncReadyStatesFromRoomUpdate,
handlePlayerHandResponse,
handleReadyStateResponse,
handlePlayerDingQueResponse,
syncTrusteeState,
}
}

View File

@@ -0,0 +1,96 @@
import { asRecord, normalizeWsType, readString } from '../../../../game/chengdu/messageNormalizers'
import { parseRoomInfoSnapshot } from '../parsers/roomInfoSnapshot'
import { clearRoomAndRedirect, syncActiveRoomFromRoomInfo } from '../room/roomSnapshotSync'
import {
clearClaimAndTurnPending,
clearRoomCountdown,
clearDingQuePending,
clearSelfTurnAllowActions,
clearTurnPending,
setRoomCountdown,
setSettlementDeadline,
syncCurrentUserId,
} from '../session/sessionStateAdapter'
import { applyRoomSnapshot } from '../store/gameStoreAdapter'
import type { SocketHandlerContext } from '../types'
export function createRoomInfoHandlers(context: SocketHandlerContext) {
function handleRoomInfoResponse(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string') {
return
}
const normalizedType = normalizeWsType(source.type)
if (normalizedType !== 'GET_ROOM_INFO' && normalizedType !== 'ROOM_INFO') {
return
}
const payload = asRecord(source.payload) ?? source
syncCurrentUserId(context.session, readString(source, 'target'))
const room = asRecord(payload.room)
const gameState = asRecord(payload.game_state)
const playerView = asRecord(payload.player_view)
if (!room && !gameState && !playerView) {
clearRoomAndRedirect(context)
return
}
const snapshot = parseRoomInfoSnapshot({
message: source,
loggedInUserId: context.session.loggedInUserId.value,
loggedInUserName: context.session.loggedInUserName.value,
previousPlayers: context.gameStore.players,
})
if (!snapshot) {
return
}
if (Object.keys(snapshot.nextPlayers).length > 0) {
clearDingQuePending(context.session)
}
applyRoomSnapshot(context.gameStore, {
roomId: snapshot.roomId,
players: snapshot.nextPlayers,
phase: snapshot.phase,
wallCount: snapshot.wallCount,
dealerIndex: snapshot.dealerIndex,
currentTurn: snapshot.currentTurn,
needDraw: snapshot.needDraw,
pendingClaim: snapshot.pendingClaim,
scores: snapshot.scores,
winners: snapshot.winners,
currentRound: snapshot.currentRound,
totalRounds: snapshot.totalRounds,
})
if (!snapshot.pendingClaim) {
clearClaimAndTurnPending(context.session)
} else {
clearTurnPending(context.session)
}
if (snapshot.actionTimer) {
setRoomCountdown(context.session, snapshot.actionTimer)
} else {
clearRoomCountdown(context.session)
}
if (typeof snapshot.settlementDeadlineMs === 'number' && snapshot.settlementDeadlineMs > 0) {
setSettlementDeadline(context.session, snapshot.settlementDeadlineMs)
}
if (context.gameStore.phase !== 'playing' || snapshot.currentTurnPlayerId !== context.session.loggedInUserId.value) {
clearSelfTurnAllowActions(context.session)
}
syncActiveRoomFromRoomInfo(context, {
roomId: snapshot.roomId,
room: snapshot.room,
status: snapshot.status,
})
}
return {
handleRoomInfoResponse,
}
}

View File

@@ -0,0 +1,103 @@
import {
asRecord,
normalizeWsType,
readNumber,
readString,
} from '../../../../game/chengdu/messageNormalizers'
import { parseRoomStateSnapshot } from '../parsers/roomStateSnapshot'
import { syncActiveRoomFromRoomState } from '../room/roomSnapshotSync'
import {
clearClaimAndTurnPending,
clearRoomCountdown,
clearSelfTurnAllowActions,
clearStartGamePending,
clearTurnPending,
completeDiscard,
resetSettlementOverlayState,
setRoomCountdown,
setSettlementDeadline,
} from '../session/sessionStateAdapter'
import { applyRoomSnapshot } from '../store/gameStoreAdapter'
import type { SocketHandlerContext } from '../types'
export function createRoomStateHandlers(context: SocketHandlerContext) {
function handleRoomStateResponse(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'ROOM_STATE') {
return
}
const payload = asRecord(source.payload)
if (!payload) {
return
}
const roomId = readString(payload, 'room_id', 'roomId') || context.gameStore.roomId
if (!roomId || (context.gameStore.roomId && roomId !== context.gameStore.roomId)) {
return
}
const snapshot = parseRoomStateSnapshot({
payload,
roomId,
loggedInUserId: context.session.loggedInUserId.value,
previousPlayers: context.gameStore.players,
})
applyRoomSnapshot(context.gameStore, {
roomId: snapshot.roomId,
players: snapshot.nextPlayers,
phase: snapshot.phase,
wallCount: snapshot.wallCount,
currentTurn: snapshot.currentTurn,
needDraw: snapshot.needDraw,
pendingClaim: snapshot.pendingClaim,
scores: snapshot.scores,
winners: snapshot.winners,
currentRound: snapshot.currentRound,
totalRounds: snapshot.totalRounds,
})
if (typeof snapshot.settlementDeadlineMs === 'number' && snapshot.settlementDeadlineMs > 0) {
setSettlementDeadline(context.session, snapshot.settlementDeadlineMs)
} else if (snapshot.phase !== 'settlement') {
setSettlementDeadline(context.session, null)
}
if (snapshot.actionTimer) {
setRoomCountdown(context.session, snapshot.actionTimer)
} else {
clearRoomCountdown(context.session)
}
if (!snapshot.pendingClaim) {
clearClaimAndTurnPending(context.session)
} else {
clearTurnPending(context.session)
}
syncActiveRoomFromRoomState(context, {
roomId,
phase: snapshot.phase,
})
if (snapshot.phase !== 'waiting') {
clearStartGamePending(context.session)
}
if (snapshot.phase !== 'settlement') {
resetSettlementOverlayState(context.session)
}
if (snapshot.phase !== 'playing' || snapshot.currentTurnPlayerId !== context.session.loggedInUserId.value) {
clearSelfTurnAllowActions(context.session)
}
if (
snapshot.currentTurnPlayerId &&
snapshot.currentTurnPlayerId !== context.session.loggedInUserId.value
) {
completeDiscard(context.session)
}
}
return {
handleRoomStateResponse,
}
}

View File

@@ -0,0 +1,19 @@
import { dispatchGameAction } from '../../../../game/dispatcher'
import { parseGameActionMessage } from '../parsers/gameActionMessage'
import { applyGameActionSessionEffects } from '../session/gameActionEffects'
import type { SocketHandlerContext } from '../types'
export function dispatchSocketGameAction(
context: SocketHandlerContext,
msg: unknown,
onRoomPlayerUpdate: (payload: any) => void,
onRoomTrustee: (payload: any) => void,
): void {
const gameAction = parseGameActionMessage(msg)
if (!gameAction) {
return
}
dispatchGameAction(gameAction)
applyGameActionSessionEffects(context, gameAction, onRoomPlayerUpdate, onRoomTrustee)
}

View File

@@ -0,0 +1,59 @@
import {
asRecord,
normalizeWsType,
readString,
} from '../../../../game/chengdu/messageNormalizers'
import { clearClaimAndTurnPending, clearRoomCountdown, pushWsMessage, setWsError } from '../session/sessionStateAdapter'
import type { SocketHandlerContext, StatusHandlerApi } from '../types'
export function createStatusHandlers(context: SocketHandlerContext): StatusHandlerApi {
function handleActionAck(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'ACTION_ACK') {
return
}
const payload = asRecord(source.payload)
const roomId = readString(payload ?? {}, 'room_id', 'roomId') || readString(source, 'roomId')
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
return
}
const action = normalizeWsType(readString(payload ?? {}, 'action') || readString(source, 'action'))
if (
action === 'DISCARD' ||
action === 'DRAW' ||
action === 'PENG' ||
action === 'GANG' ||
action === 'HU' ||
action === 'PASS' ||
action === 'DING_QUE'
) {
clearRoomCountdown(context.session)
}
}
function handleActionError(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'ACTION_ERROR') {
return
}
const payload = asRecord(source.payload)
const roomId = readString(payload ?? {}, 'room_id', 'roomId') || readString(source, 'roomId')
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
return
}
const action = readString(payload ?? {}, 'action') || 'unknown'
const messageText = readString(payload ?? {}, 'message') || '操作失败'
clearClaimAndTurnPending(context.session)
setWsError(context.session, messageText)
pushWsMessage(context.session, `[action-error] ${action}: ${messageText}`)
}
return {
handleActionAck,
handleActionError,
}
}

View File

@@ -0,0 +1,197 @@
import {
asRecord,
normalizeTimestampMs,
normalizeWsType,
readNumber,
readPlayerTurnAllowActions,
readPlayerTurnPlayerId,
readString,
readStringArray,
} from '../../../../game/chengdu/messageNormalizers'
import {
clearClaimAndTurnPending,
clearDingQuePending,
clearNextRoundPending,
clearRoomCountdown,
clearSelfTurnAllowActions,
clearTurnPending,
completeDiscard,
resetSettlementOverlayState,
setRoomCountdown,
setSelfTurnAllowActions,
} from '../session/sessionStateAdapter'
import { resetRoundResolutionState } from '../store/gameStoreAdapter'
import type { SocketHandlerContext, TurnHandlerApi, TurnPayloadRecord } from '../types'
export function createTurnHandlers(context: SocketHandlerContext): TurnHandlerApi {
function handlePlayerAllowAction(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'PLAYER_ALLOW_ACTION') {
return
}
const payload = asRecord(source.payload) ?? source
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId', 'room_id')
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
return
}
const playerId = readPlayerTurnPlayerId(payload)
const timeout =
readNumber(payload, 'timeout', 'Timeout') ??
readNumber(source, 'timeout', 'Timeout') ??
0
const startAtRaw =
readNumber(payload, 'start_at', 'startAt', 'StartAt') ??
readNumber(source, 'start_at', 'startAt', 'StartAt')
if (!playerId || timeout <= 0) {
clearRoomCountdown(context.session)
return
}
const startAtMs = normalizeTimestampMs(startAtRaw)
const deadlineAtMs = startAtMs !== null ? startAtMs + timeout * 1000 : null
const remaining =
deadlineAtMs !== null ? Math.max(0, Math.ceil((deadlineAtMs - context.session.now.value) / 1000)) : timeout
setRoomCountdown(context.session, {
playerIds: [playerId],
actionDeadlineAt: deadlineAtMs !== null ? new Date(deadlineAtMs).toISOString() : null,
countdownSeconds: timeout,
duration: timeout,
remaining,
})
}
function handleDingQueCountdown(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string' || normalizeWsType(source.type) !== 'DING_QUE_COUNTDOWN') {
return
}
const payload = asRecord(source.payload) ?? source
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId')
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
return
}
const playerIds = readStringArray(payload, 'player_ids', 'playerIds', 'PlayerIDs')
const fallbackPlayerId = readString(payload, 'player_id', 'playerId', 'PlayerID')
const normalizedPlayerIds = playerIds.length > 0 ? playerIds : fallbackPlayerId ? [fallbackPlayerId] : []
if (normalizedPlayerIds.length === 0) {
clearRoomCountdown(context.session)
return
}
const countdownSeconds = readNumber(payload, 'countdown_seconds', 'CountdownSeconds') ?? 0
const duration = readNumber(payload, 'duration', 'Duration') ?? countdownSeconds
const remaining = readNumber(payload, 'remaining', 'Remaining') ?? countdownSeconds
const actionDeadlineAt = readString(payload, 'action_deadline_at', 'ActionDeadlineAt') || null
if (countdownSeconds <= 0 && remaining <= 0 && !actionDeadlineAt) {
clearRoomCountdown(context.session)
return
}
setRoomCountdown(context.session, {
playerIds: normalizedPlayerIds,
actionDeadlineAt,
countdownSeconds,
duration,
remaining,
})
}
function applyPlayerTurnCountdown(payload: TurnPayloadRecord): void {
const playerId = readPlayerTurnPlayerId(payload)
const timeout =
(typeof payload.timeout === 'number' && Number.isFinite(payload.timeout) ? payload.timeout : null) ??
(typeof payload.Timeout === 'number' && Number.isFinite(payload.Timeout) ? payload.Timeout : null) ??
0
const startAtRaw =
(typeof payload.start_at === 'number' && Number.isFinite(payload.start_at) ? payload.start_at : null) ??
(typeof payload.startAt === 'number' && Number.isFinite(payload.startAt) ? payload.startAt : null) ??
(typeof payload.StartAt === 'number' && Number.isFinite(payload.StartAt) ? payload.StartAt : null)
if (!playerId || timeout <= 0) {
clearRoomCountdown(context.session)
return
}
const startAtMs = normalizeTimestampMs(startAtRaw)
const deadlineAtMs = startAtMs !== null ? startAtMs + timeout * 1000 : null
const remaining =
deadlineAtMs !== null ? Math.max(0, Math.ceil((deadlineAtMs - context.session.now.value) / 1000)) : timeout
setRoomCountdown(context.session, {
playerIds: [playerId],
actionDeadlineAt: deadlineAtMs !== null ? new Date(deadlineAtMs).toISOString() : null,
countdownSeconds: timeout,
duration: timeout,
remaining,
})
}
function resetRoundStateForNextTurn(payload: Record<string, unknown>): void {
const nextRound = readNumber(payload, 'current_round', 'currentRound')
const totalRounds = readNumber(payload, 'total_rounds', 'totalRounds')
if (typeof nextRound !== 'number' && typeof totalRounds !== 'number') {
return
}
if (typeof nextRound === 'number') {
context.gameStore.currentRound = nextRound
}
if (typeof totalRounds === 'number') {
context.gameStore.totalRounds = totalRounds
}
clearNextRoundPending(context.session)
resetSettlementOverlayState(context.session)
clearDingQuePending(context.session)
clearRoomCountdown(context.session)
clearClaimAndTurnPending(context.session)
clearSelfTurnAllowActions(context.session)
resetRoundResolutionState(context.gameStore)
completeDiscard(context.session)
clearTurnPending(context.session)
}
function handlePlayerTurn(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string') {
return
}
const normalizedType = normalizeWsType(source.type)
if (normalizedType !== 'PLAYER_TURN' && normalizedType !== 'NEXT_TURN') {
return
}
const payload = asRecord(source.payload) ?? source
const roomId = readString(payload, 'room_id', 'roomId') || readString(source, 'roomId')
if (roomId && context.gameStore.roomId && roomId !== context.gameStore.roomId) {
return
}
resetRoundStateForNextTurn(payload)
const turnPayload = payload as TurnPayloadRecord
const turnPlayerID = readPlayerTurnPlayerId(turnPayload)
if (turnPlayerID && turnPlayerID === context.session.loggedInUserId.value) {
setSelfTurnAllowActions(context.session, readPlayerTurnAllowActions(turnPayload))
} else {
clearSelfTurnAllowActions(context.session)
}
if (normalizedType === 'PLAYER_TURN') {
applyPlayerTurnCountdown(turnPayload)
}
}
return {
handleDingQueCountdown,
handlePlayerAllowAction,
handlePlayerTurn,
}
}

View File

@@ -0,0 +1,27 @@
import { asRecord, readNumber, readString, readStringArray } from '../../../../game/chengdu/messageNormalizers'
import type { PlayerActionTimer } from '../../types'
export function parseActionTimerSnapshot(source: unknown): PlayerActionTimer | null {
const timer = asRecord(source)
if (!timer) {
return null
}
const playerIds = readStringArray(timer, 'player_ids', 'playerIds', 'PlayerIDs')
const countdownSeconds = readNumber(timer, 'countdown_seconds', 'countdownSeconds', 'CountdownSeconds') ?? 0
const duration = readNumber(timer, 'duration', 'Duration') ?? countdownSeconds
const remaining = readNumber(timer, 'remaining', 'Remaining') ?? countdownSeconds
const actionDeadlineAt = readString(timer, 'action_deadline_at', 'actionDeadlineAt', 'ActionDeadlineAt') || null
if (playerIds.length === 0 && countdownSeconds <= 0 && remaining <= 0 && !actionDeadlineAt) {
return null
}
return {
playerIds,
actionDeadlineAt,
countdownSeconds,
duration,
remaining,
}
}

View File

@@ -0,0 +1,100 @@
import type { DiscardActionPayload, DrawActionPayload, GameAction, PlayerTurnPayload, RoomTrusteePayload } from '../../../../game/actions'
import type { ClaimOptionState } from '../../../../types/state'
import { asRecord, normalizeTile, normalizeWsType, readNumber, readString } from '../../../../game/chengdu/messageNormalizers'
export function parseGameActionMessage(message: unknown): GameAction | null {
if (!message || typeof message !== 'object') {
return null
}
const source = message as Record<string, unknown>
if (typeof source.type !== 'string') {
return null
}
const type = normalizeWsType(source.type)
const payload = source.payload
switch (type) {
case 'GAME_INIT':
case 'GAME_START':
case 'DRAW_TILE':
case 'PLAY_TILE':
case 'PENDING_CLAIM':
case 'CLAIM_RESOLVED':
case 'ROOM_PLAYER_UPDATE':
return payload && typeof payload === 'object' ? ({ type, payload } as GameAction) : null
case 'ROOM_MEMBER_JOINED':
return payload && typeof payload === 'object'
? ({ type: 'ROOM_PLAYER_UPDATE', payload } as GameAction)
: null
case 'ROOM_TRUSTEE':
case 'PLAYER_TRUSTEE':
return payload && typeof payload === 'object'
? ({ type: 'ROOM_TRUSTEE', payload } as GameAction)
: ({ type: 'ROOM_TRUSTEE', payload: source as unknown as RoomTrusteePayload } as GameAction)
case 'DRAW': {
const resolvedPayload = asRecord(payload)
const playerId =
readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') ||
readString(source, 'target')
if (!playerId) {
return null
}
return {
type: 'DRAW_TILE',
payload: {
...(resolvedPayload as DrawActionPayload | null),
player_id: playerId,
},
}
}
case 'DISCARD': {
const resolvedPayload = asRecord(payload)
const playerId =
readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') ||
readString(source, 'target')
const tile = normalizeTile(resolvedPayload?.tile)
if (!playerId || !tile) {
return null
}
const nextSeat = readNumber(resolvedPayload ?? {}, 'next_seat', 'nextSeat')
return {
type: 'PLAY_TILE',
payload: {
...(resolvedPayload as DiscardActionPayload | null),
player_id: playerId,
tile,
...(typeof nextSeat === 'number' ? { next_seat: nextSeat } : {}),
},
}
}
case 'PLAYER_TURN':
case 'NEXT_TURN':
return payload && typeof payload === 'object'
? ({ type: 'PLAYER_TURN', payload } as GameAction)
: ({ type: 'PLAYER_TURN', payload: source as unknown as PlayerTurnPayload } as GameAction)
case 'PENG':
case 'GANG':
case 'HU':
case 'PASS': {
const resolvedPayload = asRecord(payload)
const playerId =
readString(resolvedPayload ?? {}, 'player_id', 'playerId', 'PlayerID') ||
readString(source, 'target')
const action = type.toLowerCase()
if (!playerId || !['peng', 'gang', 'hu', 'pass'].includes(action)) {
return null
}
return {
type: 'CLAIM_RESOLVED',
payload: {
playerId,
action: action as ClaimOptionState,
},
}
}
default:
return null
}
}

View File

@@ -0,0 +1,281 @@
import {
asRecord,
normalizeMelds,
normalizePendingClaim,
normalizeTiles,
readBoolean,
readMissingSuit,
readMissingSuitWithPresence,
readNumber,
readString,
readStringArray,
tileToText,
} from '../../../../game/chengdu/messageNormalizers'
import type { RoomMetaSnapshotState } from '../../../../store/state'
import type { PendingClaimState, PlayerState } from '../../../../types/state'
import type { PlayerActionTimer } from '../../types'
import { parseActionTimerSnapshot } from './actionTimerSnapshot'
interface RoomInfoSnapshotPlayerPair {
roomPlayer: RoomMetaSnapshotState['players'][number]
gamePlayer: PlayerState
}
export interface ParsedRoomInfoSnapshot {
room: Record<string, unknown> | null
gameState: Record<string, unknown> | null
playerView: Record<string, unknown> | null
roomId: string
roomPlayers: RoomInfoSnapshotPlayerPair[]
nextPlayers: Record<string, PlayerState>
status: string
phase: string
wallCount: number | null
dealerIndex: number | null
currentTurnPlayerId: string
currentTurn: number | null
needDraw: boolean
pendingClaim?: PendingClaimState
scores?: Record<string, number>
winners: string[]
currentRound: number | null
totalRounds: number | null
settlementDeadlineMs: number | null
actionTimer: PlayerActionTimer | null
}
interface ParseRoomInfoSnapshotOptions {
message: Record<string, unknown>
loggedInUserId: string
loggedInUserName: string
previousPlayers: Record<string, PlayerState>
}
function buildPlayerPairs(options: ParseRoomInfoSnapshotOptions, payload: Record<string, unknown>) {
const room = asRecord(payload.room)
const gameState = asRecord(payload.game_state)
const playerView = asRecord(payload.player_view)
const roomPlayers = Array.isArray(room?.players) ? room.players : []
const gamePlayers = Array.isArray(gameState?.players) ? gameState.players : []
const playerMap = new Map<string, RoomInfoSnapshotPlayerPair>()
roomPlayers.forEach((item, fallbackIndex) => {
const player = asRecord(item)
if (!player) {
return
}
const playerId = readString(player, 'player_id', 'PlayerID', 'id', 'user_id')
if (!playerId) {
return
}
const seatIndex = readNumber(player, 'index', 'Index', 'seat_index', 'seatIndex') ?? fallbackIndex
const displayName =
readString(player, 'player_name', 'PlayerName', 'display_name', 'displayName', 'nickname', 'username') ||
(playerId === options.loggedInUserId ? options.loggedInUserName : '')
const ready = readBoolean(player, 'ready', 'Ready') ?? false
const missingSuit = readMissingSuit(player)
playerMap.set(playerId, {
roomPlayer: {
index: seatIndex,
playerId,
displayName: displayName || undefined,
missingSuit,
ready,
trustee: false,
hand: [],
melds: [],
outTiles: [],
hasHu: false,
},
gamePlayer: {
playerId,
seatIndex,
displayName: displayName || undefined,
avatarURL: readString(player, 'avatar_url', 'AvatarUrl', 'avatar', 'avatarUrl') || undefined,
missingSuit,
isReady: ready,
isTrustee: false,
handTiles: [],
handCount: 0,
melds: [],
discardTiles: [],
hasHu: false,
score: 0,
},
})
})
gamePlayers.forEach((item, fallbackIndex) => {
const player = asRecord(item)
if (!player) {
return
}
const playerId = readString(player, 'player_id', 'PlayerID')
if (!playerId) {
return
}
const existing = playerMap.get(playerId)
const seatIndex =
existing?.gamePlayer.seatIndex ??
readNumber(player, 'index', 'Index', 'seat_index', 'seatIndex') ??
fallbackIndex
const displayName =
existing?.gamePlayer.displayName || (playerId === options.loggedInUserId ? options.loggedInUserName : '')
const missingSuit = readMissingSuitWithPresence(player)
const handCount = readNumber(player, 'hand_count', 'handCount') ?? 0
const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles)
const melds = normalizeMelds(player.melds ?? player.exposed_melds ?? player.exposedMelds ?? player.claims)
const hasHu = Boolean(player.has_hu ?? player.hasHu)
playerMap.set(playerId, {
roomPlayer: {
index: seatIndex,
playerId,
displayName: displayName || undefined,
missingSuit: missingSuit.present ? missingSuit.value : (existing?.gamePlayer.missingSuit ?? null),
ready: existing?.roomPlayer.ready ?? false,
trustee: existing?.roomPlayer.trustee ?? false,
hand: Array.from({ length: handCount }, () => ''),
melds: melds.map((meld) => meld.type),
outTiles: outTiles.map((tile) => tileToText(tile)),
hasHu,
},
gamePlayer: {
playerId,
seatIndex,
displayName: displayName || undefined,
avatarURL: existing?.gamePlayer.avatarURL,
missingSuit: missingSuit.present ? missingSuit.value : (existing?.gamePlayer.missingSuit ?? null),
isReady: existing?.gamePlayer.isReady ?? false,
isTrustee: existing?.gamePlayer.isTrustee ?? false,
handTiles: existing?.gamePlayer.handTiles ?? [],
handCount,
melds: melds.length > 0 ? melds : existing?.gamePlayer.melds ?? [],
discardTiles: outTiles,
hasHu,
score: existing?.gamePlayer.score ?? 0,
},
})
})
const privateHandTiles = normalizeTiles(playerView?.hand)
const privateHand = privateHandTiles.map((tile) => tileToText(tile))
if (options.loggedInUserId && playerMap.has(options.loggedInUserId)) {
const current = playerMap.get(options.loggedInUserId)
if (current) {
const selfMissingSuit = readMissingSuitWithPresence(playerView)
current.roomPlayer.hand = privateHand
if (selfMissingSuit.present) {
current.roomPlayer.missingSuit = selfMissingSuit.value
}
current.gamePlayer.handTiles = privateHandTiles
current.gamePlayer.handCount = privateHandTiles.length
if (selfMissingSuit.present) {
current.gamePlayer.missingSuit = selfMissingSuit.value
}
}
}
return {
room,
gameState,
playerView,
roomPlayers: Array.from(playerMap.values()).sort((a, b) => a.gamePlayer.seatIndex - b.gamePlayer.seatIndex),
}
}
export function parseRoomInfoSnapshot(
options: ParseRoomInfoSnapshotOptions,
): ParsedRoomInfoSnapshot | null {
const payload = asRecord(options.message.payload) ?? options.message
const { room, gameState, playerView, roomPlayers } = buildPlayerPairs(options, payload)
const roomId =
readString(room ?? {}, 'room_id', 'roomId') ||
readString(gameState ?? {}, 'room_id', 'roomId') ||
readString(playerView ?? {}, 'room_id', 'roomId') ||
readString(payload, 'room_id', 'roomId') ||
readString(options.message, 'roomId')
if (!roomId) {
return null
}
const nextPlayers: Record<string, PlayerState> = {}
roomPlayers.forEach(({ gamePlayer }) => {
const previous = options.previousPlayers[gamePlayer.playerId]
const score =
gameState?.scores && typeof gameState.scores === 'object'
? (gameState.scores as Record<string, unknown>)[gamePlayer.playerId]
: undefined
nextPlayers[gamePlayer.playerId] = {
playerId: gamePlayer.playerId,
seatIndex: gamePlayer.seatIndex,
displayName: gamePlayer.displayName ?? previous?.displayName,
avatarURL: gamePlayer.avatarURL ?? previous?.avatarURL,
missingSuit:
typeof gamePlayer.missingSuit === 'undefined' ? (previous?.missingSuit ?? null) : gamePlayer.missingSuit,
isTrustee: previous?.isTrustee ?? gamePlayer.isTrustee,
handTiles: gamePlayer.handTiles.length > 0 ? gamePlayer.handTiles : previous?.handTiles ?? [],
handCount:
gamePlayer.handCount > 0
? gamePlayer.handCount
: gamePlayer.handTiles.length > 0
? gamePlayer.handTiles.length
: (previous?.handCount ?? 0),
melds: gamePlayer.melds.length > 0 ? gamePlayer.melds : previous?.melds ?? [],
discardTiles: gamePlayer.discardTiles.length > 0 ? gamePlayer.discardTiles : previous?.discardTiles ?? [],
hasHu: gamePlayer.hasHu || previous?.hasHu || false,
score: typeof score === 'number' ? score : previous?.score ?? gamePlayer.score ?? 0,
isReady: gamePlayer.isReady,
}
})
const status =
readString(gameState ?? {}, 'status') ||
readString(room ?? {}, 'status') ||
readString(gameState ?? {}, 'phase') ||
'waiting'
const rawPendingClaim = asRecord(gameState?.pending_claim ?? gameState?.pendingClaim)
const hasPendingClaimWindow = Boolean(rawPendingClaim && Object.keys(rawPendingClaim).length > 0)
const phase =
hasPendingClaimWindow ? 'action' : readString(gameState ?? {}, 'phase') || readString(room ?? {}, 'status') || 'waiting'
const wallCount = readNumber(gameState ?? {}, 'wall_count', 'wallCount')
const dealerIndex = readNumber(gameState ?? {}, 'dealer_index', 'dealerIndex')
const currentTurnSeat = readNumber(gameState ?? {}, 'current_turn', 'currentTurn')
const currentTurnPlayerId = readString(gameState ?? {}, 'current_turn_player', 'currentTurnPlayer') || ''
const currentTurn =
currentTurnSeat ??
(currentTurnPlayerId && nextPlayers[currentTurnPlayerId] ? nextPlayers[currentTurnPlayerId].seatIndex : null)
return {
room,
gameState,
playerView,
roomId,
roomPlayers,
nextPlayers,
status,
phase,
wallCount,
dealerIndex,
currentTurnPlayerId,
currentTurn,
needDraw: readBoolean(gameState ?? {}, 'need_draw', 'needDraw') ?? false,
pendingClaim: normalizePendingClaim(gameState, options.loggedInUserId),
scores: asRecord(gameState?.scores)
? (Object.fromEntries(
Object.entries(asRecord(gameState?.scores) ?? {}).filter(([, value]) => typeof value === 'number'),
) as Record<string, number>)
: undefined,
winners: readStringArray(gameState ?? {}, 'winners'),
currentRound: readNumber(gameState ?? {}, 'current_round', 'currentRound'),
totalRounds: readNumber(gameState ?? {}, 'total_rounds', 'totalRounds'),
settlementDeadlineMs: readNumber(gameState ?? {}, 'settlement_deadline_ms', 'settlementDeadlineMs'),
actionTimer: parseActionTimerSnapshot(gameState?.action_timer ?? gameState?.actionTimer),
}
}

View File

@@ -0,0 +1,116 @@
import {
asRecord,
normalizeMelds,
normalizePendingClaim,
normalizeTiles,
readBoolean,
readMissingSuitWithPresence,
readNumber,
readString,
readStringArray,
} from '../../../../game/chengdu/messageNormalizers'
import type { PendingClaimState, PlayerState } from '../../../../types/state'
import type { PlayerActionTimer } from '../../types'
import { parseActionTimerSnapshot } from './actionTimerSnapshot'
export interface ParsedRoomStateSnapshot {
roomId: string
nextPlayers: Record<string, PlayerState>
phase: string
wallCount: number | null
currentTurnPlayerId: string
currentTurn: number | null
needDraw: boolean
pendingClaim?: PendingClaimState
scores?: Record<string, number>
winners: string[]
currentRound: number | null
totalRounds: number | null
settlementDeadlineMs: number | null
actionTimer: PlayerActionTimer | null
}
interface ParseRoomStateSnapshotOptions {
payload: Record<string, unknown>
roomId: string
loggedInUserId: string
previousPlayers: Record<string, PlayerState>
}
export function parseRoomStateSnapshot(
options: ParseRoomStateSnapshotOptions,
): ParsedRoomStateSnapshot {
const { payload, previousPlayers } = options
const nextPlayers: Record<string, PlayerState> = {}
const gamePlayers = Array.isArray(payload.players) ? payload.players : []
gamePlayers.forEach((item, fallbackIndex) => {
const player = asRecord(item)
if (!player) {
return
}
const playerId = readString(player, 'player_id', 'PlayerID')
if (!playerId) {
return
}
const previous = previousPlayers[playerId]
const seatIndex = previous?.seatIndex ?? fallbackIndex
const handCount = readNumber(player, 'hand_count', 'handCount') ?? previous?.handCount ?? 0
const outTiles = normalizeTiles(player.out_tiles ?? player.outTiles)
const melds = normalizeMelds(player.melds ?? player.exposed_melds ?? player.exposedMelds ?? player.claims)
const hasHu = Boolean(player.has_hu ?? player.hasHu)
const dingQue = readMissingSuitWithPresence(player)
const scores = asRecord(payload.scores)
const score = scores?.[playerId]
nextPlayers[playerId] = {
playerId,
seatIndex,
displayName: previous?.displayName ?? playerId,
avatarURL: previous?.avatarURL,
isTrustee: previous?.isTrustee ?? false,
missingSuit: dingQue.present ? dingQue.value : (previous?.missingSuit ?? null),
handTiles: previous?.handTiles ?? [],
handCount,
melds,
discardTiles: outTiles,
hasHu,
score: typeof score === 'number' ? score : previous?.score ?? 0,
isReady: previous?.isReady ?? false,
}
})
const rawPendingClaim = asRecord(payload.pending_claim ?? payload.pendingClaim)
const hasPendingClaimWindow = Boolean(rawPendingClaim && Object.keys(rawPendingClaim).length > 0)
const phase =
hasPendingClaimWindow ? 'action' : readString(payload, 'phase') || readString(payload, 'status') || 'waiting'
const wallCount = readNumber(payload, 'wall_count', 'wallCount')
const currentTurnSeat = readNumber(payload, 'current_turn', 'currentTurn')
const currentTurnPlayerId = readString(payload, 'current_turn_player', 'currentTurnPlayer') || ''
const currentTurn =
currentTurnSeat ??
(currentTurnPlayerId && nextPlayers[currentTurnPlayerId] ? nextPlayers[currentTurnPlayerId].seatIndex : null)
return {
roomId: options.roomId,
nextPlayers,
phase,
wallCount,
currentTurnPlayerId,
currentTurn,
needDraw: readBoolean(payload, 'need_draw', 'needDraw') ?? false,
pendingClaim: normalizePendingClaim(payload, options.loggedInUserId),
scores: asRecord(payload.scores)
? (Object.fromEntries(
Object.entries(asRecord(payload.scores) ?? {}).filter(([, value]) => typeof value === 'number'),
) as Record<string, number>)
: undefined,
winners: readStringArray(payload, 'winners'),
currentRound: readNumber(payload, 'current_round', 'currentRound'),
totalRounds: readNumber(payload, 'total_rounds', 'totalRounds'),
settlementDeadlineMs: readNumber(payload, 'settlement_deadline_ms', 'settlementDeadlineMs'),
actionTimer: parseActionTimerSnapshot(payload.action_timer ?? payload.actionTimer),
}
}

View File

@@ -0,0 +1,20 @@
import { asRecord, normalizeWsType } from '../../../../game/chengdu/messageNormalizers'
export interface SocketEnvelope {
raw: unknown
source: Record<string, unknown>
normalizedType: string
}
export function parseSocketEnvelope(message: unknown): SocketEnvelope | null {
const source = asRecord(message)
if (!source || typeof source.type !== 'string') {
return null
}
return {
raw: message,
source,
normalizedType: normalizeWsType(source.type),
}
}

View File

@@ -0,0 +1,83 @@
import { clearRoomMetaSnapshotState, setRoomMetaSnapshot } from '../../../../store'
import { wsClient } from '../../../../ws/client'
import { readNumber, readString } from '../../../../game/chengdu/messageNormalizers'
import type { SocketHandlerContext } from '../types'
interface SyncRoomStateSnapshotOptions {
roomId: string
phase: string
}
interface SyncRoomInfoSnapshotOptions {
roomId: string
room: Record<string, unknown> | null
status: string
}
export function clearRoomAndRedirect(context: SocketHandlerContext): void {
clearRoomMetaSnapshotState()
context.gameStore.resetGame()
wsClient.close()
void context.router.push('/hall')
}
export function syncActiveRoomFromRoomState(
context: SocketHandlerContext,
options: SyncRoomStateSnapshotOptions,
): void {
const previousRoom = context.roomMeta.value
setRoomMetaSnapshot({
roomId: options.roomId,
roomName: previousRoom?.roomName || context.roomName.value,
gameType: previousRoom?.gameType || 'chengdu',
ownerId: previousRoom?.ownerId || '',
maxPlayers: previousRoom?.maxPlayers ?? 4,
playerCount: previousRoom?.playerCount ?? Object.keys(context.gameStore.players).length,
status:
options.phase === 'settlement' ? 'finished' : options.phase === 'waiting' ? 'waiting' : 'playing',
createdAt: previousRoom?.createdAt || '',
updatedAt: previousRoom?.updatedAt || '',
players: previousRoom?.players ?? [],
myHand: previousRoom?.myHand ?? [],
game: previousRoom?.game,
})
}
export function syncActiveRoomFromRoomInfo(
context: SocketHandlerContext,
options: SyncRoomInfoSnapshotOptions,
): void {
const previousRoom = context.roomMeta.value
setRoomMetaSnapshot({
roomId: options.roomId,
roomName:
readString(options.room ?? {}, 'name', 'room_name') ||
previousRoom?.roomName ||
context.roomName.value,
gameType: readString(options.room ?? {}, 'game_type') || previousRoom?.gameType || 'chengdu',
ownerId: readString(options.room ?? {}, 'owner_id') || previousRoom?.ownerId || '',
maxPlayers: readNumber(options.room ?? {}, 'max_players') ?? previousRoom?.maxPlayers ?? 4,
playerCount: readNumber(options.room ?? {}, 'player_count') ?? previousRoom?.playerCount ?? 0,
status: options.status,
createdAt: readString(options.room ?? {}, 'created_at') || previousRoom?.createdAt || '',
updatedAt: readString(options.room ?? {}, 'updated_at') || previousRoom?.updatedAt || '',
players: previousRoom?.players ?? [],
myHand: previousRoom?.myHand ?? [],
game: previousRoom?.game,
})
}
export function hydrateGameStoreFromActiveRoom(context: SocketHandlerContext, routeRoomId: string): void {
const room = context.roomMeta.value
if (!room) {
return
}
const targetRoomId = routeRoomId || room.roomId
if (!targetRoomId || room.roomId !== targetRoomId) {
return
}
context.gameStore.roomId = room.roomId
}

View File

@@ -0,0 +1,40 @@
import type { PlayerHandlerApi, RoomHandlerApi, StatusHandlerApi, TurnHandlerApi } from '../types'
interface SocketMessageRouterDeps {
roomHandlers: RoomHandlerApi
playerHandlers: PlayerHandlerApi
turnHandlers: TurnHandlerApi
statusHandlers: StatusHandlerApi
}
type SocketRouteHandler = (message: unknown) => void
export function createSocketMessageRouter(deps: SocketMessageRouterDeps) {
const routes = new Map<string, SocketRouteHandler[]>([
['GET_ROOM_INFO', [deps.roomHandlers.handleRoomInfoResponse]],
['ROOM_INFO', [deps.roomHandlers.handleRoomInfoResponse]],
['ROOM_STATE', [deps.roomHandlers.handleRoomStateResponse]],
['PLAYER_HAND', [deps.playerHandlers.handlePlayerHandResponse]],
['PLAYER_ALLOW_ACTION', [deps.turnHandlers.handlePlayerAllowAction]],
['PLAYER_TURN', [deps.turnHandlers.handlePlayerTurn]],
['NEXT_TURN', [deps.turnHandlers.handlePlayerTurn]],
['ACTION_ACK', [deps.statusHandlers.handleActionAck]],
['ACTION_ERROR', [deps.statusHandlers.handleActionError]],
['DING_QUE_COUNTDOWN', [deps.turnHandlers.handleDingQueCountdown]],
['PLAYER_READY', [deps.playerHandlers.handleReadyStateResponse]],
['PLAYER_DING_QUE', [deps.playerHandlers.handlePlayerDingQueResponse]],
])
function route(normalizedType: string, message: unknown): void {
const handlers = routes.get(normalizedType)
if (!handlers) {
return
}
handlers.forEach((handler) => handler(message))
}
return {
route,
}
}

View File

@@ -0,0 +1,49 @@
import type { GameAction, RoomPlayerUpdatePayload, RoomTrusteePayload } from '../../../../game/actions'
import { readPlayerTurnPlayerId } from '../../../../game/chengdu/messageNormalizers'
import {
clearClaimAndTurnPending,
clearReadyTogglePending,
clearRoomCountdown,
clearSelectedDiscard,
clearStartGamePending,
completeDiscard,
} from './sessionStateAdapter'
import type { SocketHandlerContext } from '../types'
export function applyGameActionSessionEffects(
context: SocketHandlerContext,
gameAction: GameAction,
onRoomPlayerUpdate: (payload: RoomPlayerUpdatePayload) => void,
onRoomTrustee: (payload: RoomTrusteePayload) => void,
): void {
if (gameAction.type === 'GAME_START') {
clearStartGamePending(context.session)
clearRoomCountdown(context.session)
}
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === context.session.loggedInUserId.value) {
completeDiscard(context.session)
}
if (
gameAction.type === 'PLAY_TILE' ||
gameAction.type === 'PENDING_CLAIM' ||
gameAction.type === 'CLAIM_RESOLVED'
) {
clearRoomCountdown(context.session)
}
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
onRoomPlayerUpdate(gameAction.payload)
clearReadyTogglePending(context.session)
}
if (gameAction.type === 'CLAIM_RESOLVED') {
clearClaimAndTurnPending(context.session)
}
if (gameAction.type === 'ROOM_TRUSTEE') {
onRoomTrustee(gameAction.payload)
}
if (
gameAction.type === 'PLAYER_TURN' &&
readPlayerTurnPlayerId(gameAction.payload as Record<string, unknown>) !== context.session.loggedInUserId.value
) {
clearSelectedDiscard(context.session)
}
}

View File

@@ -0,0 +1,79 @@
import type { PlayerActionTimer } from '../../types'
import type { useChengduGameSession } from '../../composables/useChengduGameSession'
type ChengduGameSession = ReturnType<typeof useChengduGameSession>
export function pushWsMessage(session: ChengduGameSession, message: string): void {
session.wsMessages.value.push(message)
}
export function setWsError(session: ChengduGameSession, message: string): void {
session.wsError.value = message
}
export function syncCurrentUserId(session: ChengduGameSession, userId: string): void {
session.syncCurrentUserID(userId)
}
export function clearClaimAndTurnPending(session: ChengduGameSession): void {
session.claimActionPending.value = false
session.clearTurnActionPending()
}
export function clearTurnPending(session: ChengduGameSession): void {
session.clearTurnActionPending()
}
export function clearReadyTogglePending(session: ChengduGameSession): void {
session.readyTogglePending.value = false
}
export function clearStartGamePending(session: ChengduGameSession): void {
session.startGamePending.value = false
}
export function clearDingQuePending(session: ChengduGameSession): void {
session.dingQuePending.value = false
}
export function clearNextRoundPending(session: ChengduGameSession): void {
session.nextRoundPending.value = false
}
export function resetSettlementOverlayState(session: ChengduGameSession): void {
session.nextRoundPending.value = false
session.settlementOverlayDismissed.value = false
session.settlementDeadlineMs.value = null
}
export function setSettlementDeadline(session: ChengduGameSession, deadlineMs: number | null): void {
session.settlementDeadlineMs.value = deadlineMs
}
export function clearRoomCountdown(session: ChengduGameSession): void {
session.roomCountdown.value = null
}
export function setRoomCountdown(session: ChengduGameSession, countdown: PlayerActionTimer): void {
session.roomCountdown.value = countdown
}
export function completeDiscard(session: ChengduGameSession): void {
session.markDiscardCompleted()
}
export function clearSelectedDiscard(session: ChengduGameSession): void {
session.selectedDiscardTileId.value = null
}
export function setSelfTurnAllowActions(session: ChengduGameSession, actions: string[]): void {
session.selfTurnAllowActions.value = actions
}
export function clearSelfTurnAllowActions(session: ChengduGameSession): void {
session.selfTurnAllowActions.value = []
}
export function setTrustMode(session: ChengduGameSession, enabled: boolean): void {
session.isTrustMode.value = enabled
}

View File

@@ -0,0 +1,109 @@
import type { PendingClaimState, PlayerState, Tile } from '../../../../types/state'
import type { ChengduSocketGameStore } from '../types'
interface ApplyRoomSnapshotOptions {
roomId: string
players?: Record<string, PlayerState>
phase: string
wallCount?: number | null
dealerIndex?: number | null
currentTurn?: number | null
needDraw: boolean
pendingClaim?: PendingClaimState
scores?: Record<string, number>
winners?: string[]
currentRound?: number | null
totalRounds?: number | null
}
const phaseMap: Record<string, ChengduSocketGameStore['phase']> = {
waiting: 'waiting',
dealing: 'dealing',
ding_que: 'dealing',
playing: 'playing',
action: 'action',
settlement: 'settlement',
finished: 'settlement',
}
function normalizePhase(phase: string, fallback: ChengduSocketGameStore['phase']): ChengduSocketGameStore['phase'] {
return phaseMap[phase] ?? fallback
}
export function applyRoomSnapshot(gameStore: ChengduSocketGameStore, options: ApplyRoomSnapshotOptions): void {
gameStore.roomId = options.roomId
if (options.players && Object.keys(options.players).length > 0) {
gameStore.players = options.players
}
gameStore.phase = normalizePhase(options.phase, gameStore.phase)
if (typeof options.wallCount === 'number') {
gameStore.remainingTiles = options.wallCount
}
if (typeof options.dealerIndex === 'number') {
gameStore.dealerIndex = options.dealerIndex
}
if (typeof options.currentTurn === 'number') {
gameStore.currentTurn = options.currentTurn
}
gameStore.needDraw = options.needDraw
gameStore.pendingClaim = options.pendingClaim
gameStore.scores = options.scores ?? {}
gameStore.winners = options.winners ?? []
if (typeof options.currentRound === 'number') {
gameStore.currentRound = options.currentRound
}
if (typeof options.totalRounds === 'number') {
gameStore.totalRounds = options.totalRounds
}
}
export function setPlayerReadyState(gameStore: ChengduSocketGameStore, playerId: string, ready: boolean): void {
const player = gameStore.players[playerId]
if (player) {
player.isReady = ready
}
}
export function setPlayerHandState(
gameStore: ChengduSocketGameStore,
playerId: string,
handTiles: Tile[],
): void {
const player = gameStore.players[playerId]
if (player) {
player.handTiles = handTiles
player.handCount = handTiles.length
}
}
export function setPlayerMissingSuit(
gameStore: ChengduSocketGameStore,
playerId: string,
missingSuit: string | null,
): void {
const player = gameStore.players[playerId]
if (player) {
player.missingSuit = missingSuit
}
}
export function setPlayerTrusteeState(gameStore: ChengduSocketGameStore, playerId: string, trustee: boolean): void {
const player = gameStore.players[playerId]
if (player) {
player.isTrustee = trustee
}
}
export function resetRoundResolutionState(gameStore: ChengduSocketGameStore): void {
gameStore.pendingClaim = undefined
gameStore.winners = []
Object.values(gameStore.players).forEach((player) => {
player.missingSuit = null
player.hasHu = false
})
}

View File

@@ -0,0 +1,63 @@
import type { ComputedRef, Ref } from 'vue'
import type { PlayerTurnPayload, RoomPlayerUpdatePayload, RoomTrusteePayload } from '../../../game/actions'
import type { RoomMetaSnapshotState } from '../../../store/state'
import type { PlayerState, Tile } from '../../../types/state'
import type { DisplayPlayer } from '../types'
import type { useChengduGameSession } from '../composables/useChengduGameSession'
export interface ChengduSocketGameStore {
roomId: string
phase: string
players: Record<string, PlayerState>
dealerIndex: number
currentTurn: number
remainingTiles: number
needDraw: boolean
pendingClaim?: unknown
scores: Record<string, number>
winners: string[]
currentRound: number
totalRounds: number
resetGame: () => void
}
export interface SocketHandlerContext {
router: { push: (to: string) => Promise<unknown> | unknown }
gameStore: ChengduSocketGameStore
roomMeta: Ref<RoomMetaSnapshotState | null>
roomName: ComputedRef<string>
myHandTiles: ComputedRef<Tile[]>
myPlayer: ComputedRef<DisplayPlayer | undefined>
session: ReturnType<typeof useChengduGameSession>
}
export interface ReadyStateApi {
applyPlayerReadyState: (playerId: string, ready: boolean) => void
syncReadyStatesFromRoomUpdate: (payload: RoomPlayerUpdatePayload) => void
}
export interface RoomHandlerApi {
handleRoomStateResponse: (message: unknown) => void
handleRoomInfoResponse: (message: unknown) => void
hydrateFromActiveRoom: (routeRoomId: string) => void
}
export interface PlayerHandlerApi extends ReadyStateApi {
handlePlayerHandResponse: (message: unknown) => void
handleReadyStateResponse: (message: unknown) => void
handlePlayerDingQueResponse: (message: unknown) => void
syncTrusteeState: (payload: RoomTrusteePayload) => void
}
export interface TurnHandlerApi {
handleDingQueCountdown: (message: unknown) => void
handlePlayerAllowAction: (message: unknown) => void
handlePlayerTurn: (message: unknown) => void
}
export interface StatusHandlerApi {
handleActionAck: (message: unknown) => void
handleActionError: (message: unknown) => void
}
export type TurnPayloadRecord = PlayerTurnPayload & Record<string, unknown>

116
src/views/chengdu/types.ts Normal file
View File

@@ -0,0 +1,116 @@
import type {ComputedRef, Ref} from 'vue'
import type {SeatPlayerCardModel} from '../../components/game/seat-player-card'
import type {SeatKey} from '../../game/seat'
import type {RoomMetaSnapshotState} from '../../store/state'
import type {PlayerState} from '../../types/state'
import type {Tile} from '../../types/tile'
export type DisplayPlayer = PlayerState & {
displayName?: string
missingSuit?: string | null
}
export type HandSuitLabel = '万' | '筒' | '条'
export type TableTileImageType = 'hand' | 'exposed' | 'covered'
export interface WallTileItem {
key: string
src: string
alt: string
imageType: TableTileImageType
isGroupStart?: boolean
showLackTag?: boolean
suit?: Tile['suit']
tile?: Tile
}
export interface WallSeatState {
tiles: WallTileItem[]
}
export interface DeskSeatState {
tiles: WallTileItem[]
hasHu: boolean
}
export interface SeatViewModel {
key: SeatKey
player?: DisplayPlayer
isSelf: boolean
isTurn: boolean
}
export interface PlayerActionTimer {
playerIds: string[]
actionDeadlineAt?: string | null
countdownSeconds: number
duration: number
remaining: number
}
export interface ActionCountdownView {
playerLabel: string
remaining: number
duration: number
isSelf: boolean
progress: number
}
export interface TableViewDeps {
roomMeta: Ref<RoomMetaSnapshotState | null>
gamePlayers: ComputedRef<DisplayPlayer[]>
gameStore: {
roomId: string
phase: string
remainingTiles: number
dealerIndex: number
currentTurn: number
currentRound: number
totalRounds: number
winners: string[]
scores: Record<string, number>
}
localCachedAvatarUrl: ComputedRef<string>
loggedInUserId: ComputedRef<string>
loggedInUserName: ComputedRef<string>
myHandTiles: ComputedRef<Tile[]>
myPlayer: ComputedRef<DisplayPlayer | undefined>
routeRoomName: ComputedRef<string>
}
export interface TableViewResult {
roomName: ComputedRef<string>
roomState: ComputedRef<{
roomId: string
name: string
playerCount: number
maxPlayers: number
status: string
game: {
state: {
wall: string[]
dealerIndex: number
currentTurn: number
phase: string
}
}
}>
seatViews: ComputedRef<SeatViewModel[]>
seatWinds: ComputedRef<Record<SeatKey, string>>
currentTurnSeat: ComputedRef<SeatKey | ''>
currentPhaseText: ComputedRef<string>
roomStatusText: ComputedRef<string>
roundText: ComputedRef<string>
visibleHandTileGroups: ComputedRef<Array<{ suit: HandSuitLabel; tiles: Tile[] }>>
sortedVisibleHandTiles: ComputedRef<Tile[]>
wallSeats: ComputedRef<Record<SeatKey, WallSeatState>>
deskSeats: ComputedRef<Record<SeatKey, DeskSeatState>>
seatDecor: ComputedRef<Record<SeatKey, SeatPlayerCardModel>>
settlementPlayers: ComputedRef<Array<{
playerId: string
displayName: string
score: number
isWinner: boolean
seatIndex: number
}>>
}

View File

@@ -134,6 +134,7 @@ class WsClient {
// 订阅状态变化 // 订阅状态变化
onStatusChange(handler: StatusHandler) { onStatusChange(handler: StatusHandler) {
this.statusHandlers.push(handler) this.statusHandlers.push(handler)
handler(this.status)
return () => { return () => {
this.statusHandlers = this.statusHandlers.filter(fn => fn !== handler) this.statusHandlers = this.statusHandlers.filter(fn => fn !== handler)
} }

View File

@@ -1,27 +1,36 @@
import { defineConfig, loadEnv } from 'vite' import {defineConfig, loadEnv} from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig(({ mode }) => { export default defineConfig(({mode}) => {
const env = loadEnv(mode, process.cwd(), '') const env = loadEnv(mode, process.cwd(), '')
const apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:19000').replace(/\/$/, '') const apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:19000').replace(/\/$/, '')
const wsProxyTarget = (env.VITE_WS_PROXY_TARGET || apiProxyTarget).replace(/\/$/, '') const wsProxyTarget = (env.VITE_WS_PROXY_TARGET || apiProxyTarget).replace(/\/$/, '')
return { return {
plugins: [vue()], resolve: {
server: { alias: {
host: '0.0.0.0', '@': path.resolve(__dirname, 'src'),
proxy: { '@src': path.resolve(__dirname, 'src'),
'/ws': { },
target: wsProxyTarget,
changeOrigin: true,
ws: true,
rewriteWsOrigin: true,
}, },
'/api/v1': {
target: apiProxyTarget, plugins: [vue()],
changeOrigin: true, server: {
host: '0.0.0.0',
port: 8080,
proxy: {
'/ws': {
target: wsProxyTarget,
changeOrigin: true,
ws: true,
rewriteWsOrigin: true,
},
'/api/v1': {
target: apiProxyTarget,
changeOrigin: true,
},
},
}, },
}, }
},
}
}) })