feat(game): 添加成都麻将房间配置和桌面牌面显示功能
- 在房间创建接口中添加总回合数配置选项 - 实现桌面弃牌区域的可视化展示,区分各玩家的弃牌和组合 - 添加缺门标识显示,帮助玩家识别缺门牌组起始位置 - 优化牌面操作状态管理,增加弃牌等待状态和超时处理机制 - 更新样式布局适配新的桌面牌面区域,调整墙体和桌面对齐方式 - 修复多处牌面状态同步问题,确保游戏流程中的界面一致性
This commit is contained in:
@@ -31,6 +31,7 @@ HTTP 接口:
|
|||||||
{
|
{
|
||||||
"name": "房间名",
|
"name": "房间名",
|
||||||
"game_type": "chengdu",
|
"game_type": "chengdu",
|
||||||
|
"total_rounds": 8,
|
||||||
"max_players": 4
|
"max_players": 4
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const ROOM_JOIN_PATH = import.meta.env.VITE_ROOM_JOIN_PATH ?? '/api/v1/game/mahj
|
|||||||
|
|
||||||
export async function createRoom(
|
export async function createRoom(
|
||||||
auth: AuthSession,
|
auth: AuthSession,
|
||||||
input: { name: string; gameType: string; maxPlayers: number; totalRounds: number },
|
input: { name: string; gameType: string; totalRounds: number; maxPlayers: number },
|
||||||
onAuthUpdated?: (next: AuthSession) => void,
|
onAuthUpdated?: (next: AuthSession) => void,
|
||||||
): Promise<RoomItem> {
|
): Promise<RoomItem> {
|
||||||
return authedRequest<RoomItem>({
|
return authedRequest<RoomItem>({
|
||||||
@@ -44,8 +44,8 @@ export async function createRoom(
|
|||||||
body: {
|
body: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
game_type: input.gameType,
|
game_type: input.gameType,
|
||||||
max_players: input.maxPlayers,
|
|
||||||
total_rounds: input.totalRounds,
|
total_rounds: input.totalRounds,
|
||||||
|
max_players: input.maxPlayers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,6 +312,61 @@
|
|||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-countdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 92px;
|
||||||
|
right: 40px;
|
||||||
|
min-width: 188px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(255, 219, 131, 0.22);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff5d5;
|
||||||
|
background: rgba(21, 17, 14, 0.82);
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.22);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-countdown.is-self {
|
||||||
|
border-color: rgba(107, 237, 174, 0.36);
|
||||||
|
color: #e8fff3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-countdown-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-countdown-head strong {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-countdown-track {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-countdown-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #ffd569 0%, #ff8e3c 100%);
|
||||||
|
transition: width 0.9s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-countdown.is-self .action-countdown-fill {
|
||||||
|
background: linear-gradient(90deg, #75f0aa 0%, #22b573 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.signal-chip {
|
.signal-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -445,6 +500,22 @@
|
|||||||
0 2px 6px rgba(0, 0, 0, 0.2);
|
0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.picture-scene .player-meta .trustee-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #eaffef;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(180deg, rgba(57, 182, 110, 0.86), rgba(19, 105, 61, 0.94));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
.picture-scene .player-badge.seat-right .player-meta,
|
.picture-scene .player-badge.seat-right .player-meta,
|
||||||
.picture-scene .player-badge.seat-left .player-meta {
|
.picture-scene .player-badge.seat-left .player-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -554,12 +625,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,
|
||||||
@@ -636,6 +707,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wall-live-tile-button {
|
.wall-live-tile-button {
|
||||||
|
position: relative;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -643,6 +715,24 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -701,6 +791,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%;
|
||||||
@@ -1287,6 +1487,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;
|
||||||
}
|
}
|
||||||
@@ -1307,6 +1523,12 @@
|
|||||||
height: 62px;
|
height: 62px;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-countdown {
|
||||||
|
top: 82px;
|
||||||
|
right: 20px;
|
||||||
|
min-width: 164px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
@@ -1329,6 +1551,11 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desk-zone-top,
|
||||||
|
.desk-zone-bottom {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.wall-left {
|
.wall-left {
|
||||||
left: 32px;
|
left: 32px;
|
||||||
}
|
}
|
||||||
@@ -1337,6 +1564,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;
|
||||||
@@ -1354,6 +1589,21 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-right-clock {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-countdown {
|
||||||
|
top: 62px;
|
||||||
|
right: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.bottom-control-panel {
|
.bottom-control-panel {
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const resolvedAvatarUrl = computed(() => {
|
|||||||
|
|
||||||
<div class="player-meta">
|
<div class="player-meta">
|
||||||
<p>{{ player.name }}</p>
|
<p>{{ player.name }}</p>
|
||||||
|
<small v-if="player.isTrustee" class="trustee-chip">托管中</small>
|
||||||
<small v-if="player.isReady" class="ready-chip">已准备</small>
|
<small v-if="player.isReady" class="ready-chip">已准备</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export interface SeatPlayerCardModel {
|
|||||||
dealer: boolean // 是否庄家
|
dealer: boolean // 是否庄家
|
||||||
isTurn: boolean // 是否当前轮到该玩家
|
isTurn: boolean // 是否当前轮到该玩家
|
||||||
isReady: boolean // 是否已准备
|
isReady: boolean // 是否已准备
|
||||||
|
isTrustee: boolean // 是否托管
|
||||||
missingSuitLabel: string // 定缺花色(万/筒/条)
|
missingSuitLabel: string // 定缺花色(万/筒/条)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ export interface RoomPlayerUpdatePayload {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoomTrusteePayload {
|
||||||
|
player_id?: string
|
||||||
|
playerId?: string
|
||||||
|
trustee?: boolean
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 游戏动作定义(只描述“发生了什么”)
|
* 游戏动作定义(只描述“发生了什么”)
|
||||||
@@ -80,3 +87,8 @@ export type GameAction =
|
|||||||
type: 'ROOM_PLAYER_UPDATE'
|
type: 'ROOM_PLAYER_UPDATE'
|
||||||
payload: RoomPlayerUpdatePayload
|
payload: RoomPlayerUpdatePayload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
| {
|
||||||
|
type: 'ROOM_TRUSTEE'
|
||||||
|
payload: RoomTrusteePayload
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export function dispatchGameAction(action: GameAction) {
|
|||||||
store.onRoomPlayerUpdate(action.payload)
|
store.onRoomPlayerUpdate(action.payload)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'ROOM_TRUSTEE':
|
||||||
|
store.onRoomTrustee(action.payload)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error('Invalid game action')
|
throw new Error('Invalid game action')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
type GameState,
|
type GameState,
|
||||||
type PendingClaimState,
|
type PendingClaimState,
|
||||||
} from '../types/state'
|
} from '../types/state'
|
||||||
import type { RoomPlayerUpdatePayload } from '../game/actions'
|
import type { RoomPlayerUpdatePayload, RoomTrusteePayload } from '../game/actions'
|
||||||
|
|
||||||
import type { Tile } from '../types/tile'
|
import type { Tile } from '../types/tile'
|
||||||
|
|
||||||
@@ -157,6 +157,7 @@ export const useGameStore = defineStore('game', {
|
|||||||
typeof avatarUrlRaw === 'string'
|
typeof avatarUrlRaw === 'string'
|
||||||
? avatarUrlRaw
|
? avatarUrlRaw
|
||||||
: previous?.avatarURL,
|
: previous?.avatarURL,
|
||||||
|
isTrustee: previous?.isTrustee ?? false,
|
||||||
missingSuit:
|
missingSuit:
|
||||||
typeof missingSuitRaw === 'string' || missingSuitRaw === null
|
typeof missingSuitRaw === 'string' || missingSuitRaw === null
|
||||||
? missingSuitRaw
|
? missingSuitRaw
|
||||||
@@ -185,6 +186,7 @@ export const useGameStore = defineStore('game', {
|
|||||||
seatIndex: previous?.seatIndex ?? index,
|
seatIndex: previous?.seatIndex ?? index,
|
||||||
displayName: previous?.displayName ?? playerId,
|
displayName: previous?.displayName ?? playerId,
|
||||||
avatarURL: previous?.avatarURL,
|
avatarURL: previous?.avatarURL,
|
||||||
|
isTrustee: previous?.isTrustee ?? false,
|
||||||
missingSuit: previous?.missingSuit,
|
missingSuit: previous?.missingSuit,
|
||||||
handTiles: previous?.handTiles ?? [],
|
handTiles: previous?.handTiles ?? [],
|
||||||
handCount: previous?.handCount ?? 0,
|
handCount: previous?.handCount ?? 0,
|
||||||
@@ -200,6 +202,23 @@ export const useGameStore = defineStore('game', {
|
|||||||
this.players = nextPlayers
|
this.players = nextPlayers
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onRoomTrustee(payload: RoomTrusteePayload) {
|
||||||
|
const playerId =
|
||||||
|
(typeof payload.player_id === 'string' && payload.player_id) ||
|
||||||
|
(typeof payload.playerId === 'string' && payload.playerId) ||
|
||||||
|
''
|
||||||
|
if (!playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = this.players[playerId]
|
||||||
|
if (!player) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
player.isTrustee = typeof payload.trustee === 'boolean' ? payload.trustee : true
|
||||||
|
},
|
||||||
|
|
||||||
// 清理操作窗口
|
// 清理操作窗口
|
||||||
clearPendingClaim() {
|
clearPendingClaim() {
|
||||||
this.pendingClaim = undefined
|
this.pendingClaim = undefined
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface RoomPlayerState {
|
|||||||
displayName?: string
|
displayName?: string
|
||||||
missingSuit?: string | null
|
missingSuit?: string | null
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
trustee?: boolean
|
||||||
hand: string[]
|
hand: string[]
|
||||||
melds: string[]
|
melds: string[]
|
||||||
outTiles: string[]
|
outTiles: string[]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface PlayerState {
|
|||||||
seatIndex: number
|
seatIndex: number
|
||||||
displayName?: string
|
displayName?: string
|
||||||
missingSuit?: string | null
|
missingSuit?: string | null
|
||||||
|
isTrustee: boolean
|
||||||
|
|
||||||
// 手牌(只有自己有完整数据,后端可控制)
|
// 手牌(只有自己有完整数据,后端可控制)
|
||||||
handTiles: Tile[]
|
handTiles: Tile[]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import westWind from '../assets/images/direction/xi.png'
|
|||||||
import northWind from '../assets/images/direction/bei.png'
|
import northWind from '../assets/images/direction/bei.png'
|
||||||
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
|
import type {SeatPlayerCardModel} from '../components/game/seat-player-card'
|
||||||
import type {SeatKey} from '../game/seat'
|
import type {SeatKey} from '../game/seat'
|
||||||
import type {GameAction, RoomPlayerUpdatePayload} from '../game/actions'
|
import type {GameAction, RoomPlayerUpdatePayload, RoomTrusteePayload} from '../game/actions'
|
||||||
import {dispatchGameAction} from '../game/dispatcher'
|
import {dispatchGameAction} from '../game/dispatcher'
|
||||||
import {refreshAccessToken} from '../api/auth'
|
import {refreshAccessToken} from '../api/auth'
|
||||||
import {AuthExpiredError, type AuthSession} from '../api/authed-request'
|
import {AuthExpiredError, type AuthSession} from '../api/authed-request'
|
||||||
@@ -55,12 +55,18 @@ interface WallTileItem {
|
|||||||
src: string
|
src: string
|
||||||
alt: string
|
alt: string
|
||||||
imageType: TableTileImageType
|
imageType: TableTileImageType
|
||||||
|
isGroupStart?: boolean
|
||||||
|
showLackTag?: boolean
|
||||||
suit?: Tile['suit']
|
suit?: Tile['suit']
|
||||||
tile?: Tile
|
tile?: Tile
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WallSeatState {
|
interface WallSeatState {
|
||||||
tiles: WallTileItem[]
|
tiles: WallTileItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeskSeatState {
|
||||||
|
tiles: WallTileItem[]
|
||||||
hasHu: boolean
|
hasHu: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,10 +77,19 @@ interface SeatViewModel {
|
|||||||
isTurn: boolean
|
isTurn: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlayerActionTimer {
|
||||||
|
playerIds: string[]
|
||||||
|
actionDeadlineAt?: string | null
|
||||||
|
countdownSeconds: number
|
||||||
|
duration: number
|
||||||
|
remaining: number
|
||||||
|
}
|
||||||
|
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
const wsStatus = ref<WsStatus>('idle')
|
const wsStatus = ref<WsStatus>('idle')
|
||||||
const wsMessages = ref<string[]>([])
|
const wsMessages = ref<string[]>([])
|
||||||
const wsError = ref('')
|
const wsError = ref('')
|
||||||
|
const roomCountdown = ref<PlayerActionTimer | null>(null)
|
||||||
const leaveRoomPending = ref(false)
|
const leaveRoomPending = ref(false)
|
||||||
const readyTogglePending = ref(false)
|
const readyTogglePending = ref(false)
|
||||||
const startGamePending = ref(false)
|
const startGamePending = ref(false)
|
||||||
@@ -82,6 +97,7 @@ const dingQuePending = ref(false)
|
|||||||
const discardPending = ref(false)
|
const discardPending = ref(false)
|
||||||
const claimActionPending = ref(false)
|
const claimActionPending = ref(false)
|
||||||
let clockTimer: number | null = null
|
let clockTimer: number | null = null
|
||||||
|
let discardPendingTimer: number | null = null
|
||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
let needsInitialRoomInfo = false
|
let needsInitialRoomInfo = false
|
||||||
|
|
||||||
@@ -322,8 +338,19 @@ const allPlayersReady = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasRoundStarted = computed(() => {
|
||||||
|
return gamePlayers.value.some((player) => {
|
||||||
|
return (
|
||||||
|
player.handCount > 0 ||
|
||||||
|
player.handTiles.length > 0 ||
|
||||||
|
player.melds.length > 0 ||
|
||||||
|
player.discardTiles.length > 0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const showStartGameButton = computed(() => {
|
const showStartGameButton = computed(() => {
|
||||||
return gameStore.phase === 'waiting' && allPlayersReady.value
|
return gameStore.phase === 'waiting' && allPlayersReady.value && !hasRoundStarted.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const showWaitingOwnerTip = computed(() => {
|
const showWaitingOwnerTip = computed(() => {
|
||||||
@@ -335,7 +362,7 @@ const canStartGame = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const showReadyToggle = computed(() => {
|
const showReadyToggle = computed(() => {
|
||||||
if (gameStore.phase !== 'waiting' || !gameStore.roomId) {
|
if (gameStore.phase !== 'waiting' || !gameStore.roomId || hasRoundStarted.value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,19 +392,20 @@ const canDiscardTiles = computed(() => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gameStore.phase !== 'playing') {
|
if (wsStatus.value !== 'connected') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gameStore.needDraw) {
|
if (player.handTiles.length === 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!player.missingSuit || player.handTiles.length === 0) {
|
if (discardPending.value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return player.seatIndex === gameStore.currentTurn
|
// 交给后端做最终合法性校验,前端只避免明显无效点击。
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const canDrawTile = computed(() => {
|
const canDrawTile = computed(() => {
|
||||||
@@ -411,6 +439,56 @@ const showClaimActions = computed(() => {
|
|||||||
return visibleClaimOptions.value.length > 0
|
return visibleClaimOptions.value.length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const actionCountdown = computed(() => {
|
||||||
|
const countdown = roomCountdown.value
|
||||||
|
if (!countdown) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadlineAt = countdown.actionDeadlineAt ? Date.parse(countdown.actionDeadlineAt) : Number.NaN
|
||||||
|
const fallbackRemaining = countdown.remaining > 0 ? countdown.remaining : countdown.countdownSeconds
|
||||||
|
const derivedRemaining = Number.isFinite(deadlineAt)
|
||||||
|
? Math.ceil((deadlineAt - now.value) / 1000)
|
||||||
|
: fallbackRemaining
|
||||||
|
const remaining = Math.max(0, derivedRemaining)
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPlayerIds = countdown.playerIds.filter((playerId) => typeof playerId === 'string' && playerId.trim())
|
||||||
|
if (targetPlayerIds.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerLabel = targetPlayerIds
|
||||||
|
.map((playerId) => {
|
||||||
|
if (playerId === loggedInUserId.value) {
|
||||||
|
return '你'
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPlayer = gameStore.players[playerId]
|
||||||
|
if (targetPlayer?.displayName) {
|
||||||
|
return targetPlayer.displayName
|
||||||
|
}
|
||||||
|
if (targetPlayer) {
|
||||||
|
return `玩家${targetPlayer.seatIndex + 1}`
|
||||||
|
}
|
||||||
|
return '玩家'
|
||||||
|
})
|
||||||
|
.join('、')
|
||||||
|
const duration = countdown.duration > 0 ? countdown.duration : Math.max(remaining, fallbackRemaining, 1)
|
||||||
|
const includesSelf = targetPlayerIds.includes(loggedInUserId.value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerLabel,
|
||||||
|
remaining,
|
||||||
|
duration,
|
||||||
|
isSelf: includesSelf,
|
||||||
|
progress: Math.max(0, Math.min(100, (remaining / duration) * 100)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function applyPlayerReadyState(playerId: string, ready: boolean): void {
|
function applyPlayerReadyState(playerId: string, ready: boolean): void {
|
||||||
const player = gameStore.players[playerId]
|
const player = gameStore.players[playerId]
|
||||||
if (player) {
|
if (player) {
|
||||||
@@ -745,6 +823,7 @@ function handleRoomStateResponse(message: unknown): void {
|
|||||||
seatIndex,
|
seatIndex,
|
||||||
displayName: previous?.displayName ?? playerId,
|
displayName: previous?.displayName ?? playerId,
|
||||||
avatarURL: previous?.avatarURL,
|
avatarURL: previous?.avatarURL,
|
||||||
|
isTrustee: previous?.isTrustee ?? false,
|
||||||
missingSuit: dingQue || previous?.missingSuit || null,
|
missingSuit: dingQue || previous?.missingSuit || null,
|
||||||
handTiles: previous?.handTiles ?? [],
|
handTiles: previous?.handTiles ?? [],
|
||||||
handCount,
|
handCount,
|
||||||
@@ -852,7 +931,7 @@ function handleRoomStateResponse(message: unknown): void {
|
|||||||
startGamePending.value = false
|
startGamePending.value = false
|
||||||
}
|
}
|
||||||
if (currentTurnPlayerId && currentTurnPlayerId !== loggedInUserId.value) {
|
if (currentTurnPlayerId && currentTurnPlayerId !== loggedInUserId.value) {
|
||||||
discardPending.value = false
|
markDiscardCompleted()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,6 +979,7 @@ function handleRoomInfoResponse(message: unknown): void {
|
|||||||
displayName?: string
|
displayName?: string
|
||||||
missingSuit?: string | null
|
missingSuit?: string | null
|
||||||
ready: boolean
|
ready: boolean
|
||||||
|
trustee: boolean
|
||||||
hand: string[]
|
hand: string[]
|
||||||
melds: string[]
|
melds: string[]
|
||||||
outTiles: string[]
|
outTiles: string[]
|
||||||
@@ -912,6 +992,7 @@ function handleRoomInfoResponse(message: unknown): void {
|
|||||||
avatarURL?: string
|
avatarURL?: string
|
||||||
missingSuit?: string | null
|
missingSuit?: string | null
|
||||||
isReady: boolean
|
isReady: boolean
|
||||||
|
isTrustee: boolean
|
||||||
handTiles: Tile[]
|
handTiles: Tile[]
|
||||||
handCount: number
|
handCount: number
|
||||||
melds: PlayerState['melds']
|
melds: PlayerState['melds']
|
||||||
@@ -946,6 +1027,7 @@ function handleRoomInfoResponse(message: unknown): void {
|
|||||||
displayName: displayName || undefined,
|
displayName: displayName || undefined,
|
||||||
missingSuit,
|
missingSuit,
|
||||||
ready,
|
ready,
|
||||||
|
trustee: false,
|
||||||
hand: [],
|
hand: [],
|
||||||
melds: [],
|
melds: [],
|
||||||
outTiles: [],
|
outTiles: [],
|
||||||
@@ -958,6 +1040,7 @@ function handleRoomInfoResponse(message: unknown): void {
|
|||||||
avatarURL: readString(player, 'avatar_url', 'AvatarUrl', 'avatar', 'avatarUrl') || undefined,
|
avatarURL: readString(player, 'avatar_url', 'AvatarUrl', 'avatar', 'avatarUrl') || undefined,
|
||||||
missingSuit,
|
missingSuit,
|
||||||
isReady: ready,
|
isReady: ready,
|
||||||
|
isTrustee: false,
|
||||||
handTiles: [],
|
handTiles: [],
|
||||||
handCount: 0,
|
handCount: 0,
|
||||||
melds: [],
|
melds: [],
|
||||||
@@ -1003,6 +1086,7 @@ function handleRoomInfoResponse(message: unknown): void {
|
|||||||
displayName: displayName || undefined,
|
displayName: displayName || undefined,
|
||||||
missingSuit,
|
missingSuit,
|
||||||
ready: existing?.roomPlayer.ready ?? false,
|
ready: existing?.roomPlayer.ready ?? false,
|
||||||
|
trustee: existing?.roomPlayer.trustee ?? false,
|
||||||
hand: Array.from({length: handCount}, () => ''),
|
hand: Array.from({length: handCount}, () => ''),
|
||||||
melds: melds.map((meld) => meld.type),
|
melds: melds.map((meld) => meld.type),
|
||||||
outTiles: outTiles.map((tile) => tileToText(tile)),
|
outTiles: outTiles.map((tile) => tileToText(tile)),
|
||||||
@@ -1015,6 +1099,7 @@ function handleRoomInfoResponse(message: unknown): void {
|
|||||||
avatarURL: existing?.gamePlayer.avatarURL,
|
avatarURL: existing?.gamePlayer.avatarURL,
|
||||||
missingSuit,
|
missingSuit,
|
||||||
isReady: existing?.gamePlayer.isReady ?? false,
|
isReady: existing?.gamePlayer.isReady ?? false,
|
||||||
|
isTrustee: existing?.gamePlayer.isTrustee ?? false,
|
||||||
handTiles: existing?.gamePlayer.handTiles ?? [],
|
handTiles: existing?.gamePlayer.handTiles ?? [],
|
||||||
handCount,
|
handCount,
|
||||||
melds: melds.length > 0 ? melds : existing?.gamePlayer.melds ?? [],
|
melds: melds.length > 0 ? melds : existing?.gamePlayer.melds ?? [],
|
||||||
@@ -1053,6 +1138,7 @@ function handleRoomInfoResponse(message: unknown): void {
|
|||||||
displayName: gamePlayer.displayName ?? previous?.displayName,
|
displayName: gamePlayer.displayName ?? previous?.displayName,
|
||||||
avatarURL: gamePlayer.avatarURL ?? previous?.avatarURL,
|
avatarURL: gamePlayer.avatarURL ?? previous?.avatarURL,
|
||||||
missingSuit: gamePlayer.missingSuit ?? previous?.missingSuit,
|
missingSuit: gamePlayer.missingSuit ?? previous?.missingSuit,
|
||||||
|
isTrustee: previous?.isTrustee ?? gamePlayer.isTrustee,
|
||||||
handTiles: gamePlayer.handTiles.length > 0 ? gamePlayer.handTiles : previous?.handTiles ?? [],
|
handTiles: gamePlayer.handTiles.length > 0 ? gamePlayer.handTiles : previous?.handTiles ?? [],
|
||||||
handCount: gamePlayer.handCount > 0
|
handCount: gamePlayer.handCount > 0
|
||||||
? gamePlayer.handCount
|
? gamePlayer.handCount
|
||||||
@@ -1190,6 +1276,12 @@ function buildWallTileImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emptyWallSeat(): WallSeatState {
|
function emptyWallSeat(): WallSeatState {
|
||||||
|
return {
|
||||||
|
tiles: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDeskSeat(): DeskSeatState {
|
||||||
return {
|
return {
|
||||||
tiles: [],
|
tiles: [],
|
||||||
hasHu: false,
|
hasHu: false,
|
||||||
@@ -1217,17 +1309,25 @@ const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
|
|||||||
const targetSeat = seat.key
|
const targetSeat = seat.key
|
||||||
|
|
||||||
if (seat.isSelf) {
|
if (seat.isSelf) {
|
||||||
|
const missingSuit = seat.player.missingSuit as Tile['suit'] | null | undefined
|
||||||
sortedVisibleHandTiles.value.forEach((tile, index) => {
|
sortedVisibleHandTiles.value.forEach((tile, index) => {
|
||||||
const src = buildWallTileImage(targetSeat, tile, 'hand')
|
const src = buildWallTileImage(targetSeat, tile, 'hand')
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const previousTile = index > 0 ? sortedVisibleHandTiles.value[index - 1] : undefined
|
||||||
|
const isMissingSuitGroupStart = Boolean(
|
||||||
|
missingSuit &&
|
||||||
|
tile.suit === missingSuit &&
|
||||||
|
(!previousTile || previousTile.suit !== tile.suit),
|
||||||
|
)
|
||||||
|
|
||||||
seatTiles.push({
|
seatTiles.push({
|
||||||
key: `hand-${tile.id}-${index}`,
|
key: `hand-${tile.id}-${index}`,
|
||||||
src,
|
src,
|
||||||
alt: formatTile(tile),
|
alt: formatTile(tile),
|
||||||
imageType: 'hand',
|
imageType: 'hand',
|
||||||
|
showLackTag: isMissingSuitGroupStart,
|
||||||
suit: tile.suit,
|
suit: tile.suit,
|
||||||
tile,
|
tile,
|
||||||
})
|
})
|
||||||
@@ -1248,6 +1348,49 @@ const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (gameStore.phase === 'waiting' && 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) => {
|
seat.player.melds.forEach((meld, meldIndex) => {
|
||||||
meld.tiles.forEach((tile, tileIndex) => {
|
meld.tiles.forEach((tile, tileIndex) => {
|
||||||
const imageType: TableTileImageType = meld.type === 'an_gang' ? 'covered' : 'exposed'
|
const imageType: TableTileImageType = meld.type === 'an_gang' ? 'covered' : 'exposed'
|
||||||
@@ -1257,10 +1400,11 @@ const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
seatTiles.push({
|
seatTiles.push({
|
||||||
key: `${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`,
|
key: `desk-${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`,
|
||||||
src,
|
src,
|
||||||
alt: formatTile(tile),
|
alt: formatTile(tile),
|
||||||
imageType,
|
imageType,
|
||||||
|
isGroupStart: tileIndex === 0,
|
||||||
suit: tile.suit,
|
suit: tile.suit,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1285,6 +1429,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
dealer: false,
|
dealer: false,
|
||||||
isTurn: false,
|
isTurn: false,
|
||||||
isReady: false,
|
isReady: false,
|
||||||
|
isTrustee: false,
|
||||||
missingSuitLabel: defaultMissingSuitLabel,
|
missingSuitLabel: defaultMissingSuitLabel,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1312,6 +1457,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
|||||||
dealer: seat.player.seatIndex === dealerIndex,
|
dealer: seat.player.seatIndex === dealerIndex,
|
||||||
isTurn: seat.isTurn,
|
isTurn: seat.isTurn,
|
||||||
isReady: Boolean(seat.player.isReady),
|
isReady: Boolean(seat.player.isReady),
|
||||||
|
isTrustee: Boolean(seat.player.isTrustee),
|
||||||
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1415,12 +1561,57 @@ function handlePlayerHandResponse(message: unknown): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
discardPending.value = false
|
markDiscardCompleted()
|
||||||
if (gameStore.phase !== 'waiting') {
|
if (gameStore.phase !== 'waiting') {
|
||||||
startGamePending.value = false
|
startGamePending.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRoomCountdown(message: unknown): void {
|
||||||
|
const source = asRecord(message)
|
||||||
|
if (!source || typeof source.type !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizeWsType(source.type) !== 'ROOM_COUNTDOWN') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(source.payload) ?? source
|
||||||
|
const roomId =
|
||||||
|
readString(payload, 'room_id', 'roomId') ||
|
||||||
|
readString(source, 'roomId')
|
||||||
|
if (roomId && gameStore.roomId && roomId !== 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) {
|
||||||
|
roomCountdown.value = null
|
||||||
|
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) {
|
||||||
|
roomCountdown.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roomCountdown.value = {
|
||||||
|
playerIds: normalizedPlayerIds,
|
||||||
|
actionDeadlineAt,
|
||||||
|
countdownSeconds,
|
||||||
|
duration,
|
||||||
|
remaining,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toGameAction(message: unknown): GameAction | null {
|
function toGameAction(message: unknown): GameAction | null {
|
||||||
if (!message || typeof message !== 'object') {
|
if (!message || typeof message !== 'object') {
|
||||||
return null
|
return null
|
||||||
@@ -1475,11 +1666,44 @@ function toGameAction(message: unknown): GameAction | null {
|
|||||||
return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameActionPayload<'ROOM_PLAYER_UPDATE'>}
|
return {type: 'ROOM_PLAYER_UPDATE', payload: payload as GameActionPayload<'ROOM_PLAYER_UPDATE'>}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
case 'ROOM_TRUSTEE':
|
||||||
|
if (payload && typeof payload === 'object') {
|
||||||
|
return {type: 'ROOM_TRUSTEE', payload: payload as GameActionPayload<'ROOM_TRUSTEE'>}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'ROOM_TRUSTEE',
|
||||||
|
payload: source as unknown as GameActionPayload<'ROOM_TRUSTEE'>,
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if (playerId === loggedInUserId.value) {
|
||||||
|
isTrustMode.value = trustee
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = activeRoom.value
|
||||||
|
if (!room || room.roomId !== gameStore.roomId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomPlayer = room.players.find((item) => item.playerId === playerId)
|
||||||
|
if (roomPlayer) {
|
||||||
|
roomPlayer.trustee = trustee
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleReadyStateResponse(message: unknown): void {
|
function handleReadyStateResponse(message: unknown): void {
|
||||||
if (!message || typeof message !== 'object') {
|
if (!message || typeof message !== 'object') {
|
||||||
return
|
return
|
||||||
@@ -1838,18 +2062,42 @@ function chooseDingQue(suit: Tile['suit']): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearDiscardPendingTimer(): void {
|
||||||
|
if (discardPendingTimer !== null) {
|
||||||
|
window.clearTimeout(discardPendingTimer)
|
||||||
|
discardPendingTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDiscardCompleted(): void {
|
||||||
|
clearDiscardPendingTimer()
|
||||||
|
discardPending.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDiscardPendingWithFallback(): void {
|
||||||
|
clearDiscardPendingTimer()
|
||||||
|
discardPending.value = true
|
||||||
|
discardPendingTimer = window.setTimeout(() => {
|
||||||
|
discardPending.value = false
|
||||||
|
discardPendingTimer = null
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
function discardTile(tile: Tile): void {
|
function discardTile(tile: Tile): void {
|
||||||
if (discardPending.value || !canDiscardTiles.value) {
|
if (!canDiscardTiles.value || !gameStore.roomId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
discardPending.value = true
|
markDiscardPendingWithFallback()
|
||||||
sendWsMessage({
|
sendWsMessage({
|
||||||
type: 'discard',
|
type: 'discard',
|
||||||
roomId: gameStore.roomId,
|
roomId: gameStore.roomId,
|
||||||
payload: {
|
payload: {
|
||||||
room_id: gameStore.roomId,
|
tile: {
|
||||||
tile,
|
id: tile.id,
|
||||||
|
suit: tile.suit,
|
||||||
|
value: tile.value,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1940,6 +2188,7 @@ function hydrateFromActiveRoom(routeRoomId: string): void {
|
|||||||
displayName: player.displayName || player.playerId,
|
displayName: player.displayName || player.playerId,
|
||||||
avatarURL: previous?.avatarURL,
|
avatarURL: previous?.avatarURL,
|
||||||
missingSuit: player.missingSuit ?? previous?.missingSuit,
|
missingSuit: player.missingSuit ?? previous?.missingSuit,
|
||||||
|
isTrustee: player.trustee ?? previous?.isTrustee ?? false,
|
||||||
isReady: player.ready,
|
isReady: player.ready,
|
||||||
handTiles: previous?.handTiles ?? [],
|
handTiles: previous?.handTiles ?? [],
|
||||||
handCount: previous?.handCount ?? 0,
|
handCount: previous?.handCount ?? 0,
|
||||||
@@ -1979,6 +2228,7 @@ onMounted(() => {
|
|||||||
handleRoomInfoResponse(msg)
|
handleRoomInfoResponse(msg)
|
||||||
handleRoomStateResponse(msg)
|
handleRoomStateResponse(msg)
|
||||||
handlePlayerHandResponse(msg)
|
handlePlayerHandResponse(msg)
|
||||||
|
handleRoomCountdown(msg)
|
||||||
handleReadyStateResponse(msg)
|
handleReadyStateResponse(msg)
|
||||||
handlePlayerDingQueResponse(msg)
|
handlePlayerDingQueResponse(msg)
|
||||||
const gameAction = toGameAction(msg)
|
const gameAction = toGameAction(msg)
|
||||||
@@ -1988,7 +2238,7 @@ onMounted(() => {
|
|||||||
startGamePending.value = false
|
startGamePending.value = false
|
||||||
}
|
}
|
||||||
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === loggedInUserId.value) {
|
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === loggedInUserId.value) {
|
||||||
discardPending.value = false
|
markDiscardCompleted()
|
||||||
}
|
}
|
||||||
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
|
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
|
||||||
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
||||||
@@ -1997,9 +2247,13 @@ onMounted(() => {
|
|||||||
if (gameAction.type === 'CLAIM_RESOLVED') {
|
if (gameAction.type === 'CLAIM_RESOLVED') {
|
||||||
claimActionPending.value = false
|
claimActionPending.value = false
|
||||||
}
|
}
|
||||||
|
if (gameAction.type === 'ROOM_TRUSTEE') {
|
||||||
|
syncTrusteeState(gameAction.payload)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
wsClient.onError((message: string) => {
|
wsClient.onError((message: string) => {
|
||||||
|
markDiscardCompleted()
|
||||||
wsError.value = message
|
wsError.value = message
|
||||||
wsMessages.value.push(`[error] ${message}`)
|
wsMessages.value.push(`[error] ${message}`)
|
||||||
|
|
||||||
@@ -2040,6 +2294,7 @@ onBeforeUnmount(() => {
|
|||||||
window.clearInterval(clockTimer)
|
window.clearInterval(clockTimer)
|
||||||
clockTimer = null
|
clockTimer = null
|
||||||
}
|
}
|
||||||
|
clearDiscardPendingTimer()
|
||||||
|
|
||||||
window.removeEventListener('click', handleGlobalClick)
|
window.removeEventListener('click', handleGlobalClick)
|
||||||
window.removeEventListener('keydown', handleGlobalEsc)
|
window.removeEventListener('keydown', handleGlobalEsc)
|
||||||
@@ -2117,13 +2372,80 @@ onBeforeUnmount(() => {
|
|||||||
<span>{{ formattedClock }}</span>
|
<span>{{ formattedClock }}</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
|
||||||
<TopPlayerCard :player="seatDecor.top"/>
|
<TopPlayerCard :player="seatDecor.top"/>
|
||||||
<RightPlayerCard :player="seatDecor.right"/>
|
<RightPlayerCard :player="seatDecor.right"/>
|
||||||
<BottomPlayerCard :player="seatDecor.bottom"/>
|
<BottomPlayerCard :player="seatDecor.bottom"/>
|
||||||
<LeftPlayerCard :player="seatDecor.left"/>
|
<LeftPlayerCard :player="seatDecor.left"/>
|
||||||
|
|
||||||
<div v-if="wallSeats.top.tiles.length > 0 || wallSeats.top.hasHu" class="wall wall-top wall-live">
|
<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>
|
||||||
|
|
||||||
|
<div v-if="wallSeats.top.tiles.length > 0" class="wall wall-top wall-live">
|
||||||
<img
|
<img
|
||||||
v-for="(tile, index) in wallSeats.top.tiles"
|
v-for="(tile, index) in wallSeats.top.tiles"
|
||||||
:key="tile.key"
|
:key="tile.key"
|
||||||
@@ -2135,9 +2457,8 @@ onBeforeUnmount(() => {
|
|||||||
:src="tile.src"
|
:src="tile.src"
|
||||||
:alt="tile.alt"
|
:alt="tile.alt"
|
||||||
/>
|
/>
|
||||||
<span v-if="wallSeats.top.hasHu" class="wall-hu-flag">胡</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="wallSeats.right.tiles.length > 0 || wallSeats.right.hasHu" class="wall wall-right wall-live">
|
<div v-if="wallSeats.right.tiles.length > 0" class="wall wall-right wall-live">
|
||||||
<img
|
<img
|
||||||
v-for="(tile, index) in wallSeats.right.tiles"
|
v-for="(tile, index) in wallSeats.right.tiles"
|
||||||
:key="tile.key"
|
:key="tile.key"
|
||||||
@@ -2149,21 +2470,22 @@ onBeforeUnmount(() => {
|
|||||||
:src="tile.src"
|
:src="tile.src"
|
||||||
:alt="tile.alt"
|
:alt="tile.alt"
|
||||||
/>
|
/>
|
||||||
<span v-if="wallSeats.right.hasHu" class="wall-hu-flag">胡</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="wallSeats.bottom.tiles.length > 0 || wallSeats.bottom.hasHu" class="wall wall-bottom wall-live">
|
<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">
|
<template v-for="(tile, index) in wallSeats.bottom.tiles" :key="tile.key">
|
||||||
<button
|
<button
|
||||||
v-if="tile.tile && tile.imageType === 'hand'"
|
v-if="tile.tile && tile.imageType === 'hand'"
|
||||||
class="wall-live-tile-button"
|
class="wall-live-tile-button"
|
||||||
:class="{
|
:class="{
|
||||||
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
'is-group-start': index > 0 && tile.suit && wallSeats.bottom.tiles[index - 1]?.suit !== tile.suit,
|
||||||
|
'is-lack-tagged': tile.showLackTag,
|
||||||
}"
|
}"
|
||||||
:data-testid="`hand-tile-${tile.tile.id}`"
|
:data-testid="`hand-tile-${tile.tile.id}`"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!canDiscardTiles || discardPending"
|
:disabled="!canDiscardTiles || discardPending"
|
||||||
@click="discardTile(tile.tile)"
|
@click="discardTile(tile.tile)"
|
||||||
>
|
>
|
||||||
|
<span v-if="tile.showLackTag" class="wall-live-tile-lack-tag">缺</span>
|
||||||
<img
|
<img
|
||||||
class="wall-live-tile"
|
class="wall-live-tile"
|
||||||
:src="tile.src"
|
:src="tile.src"
|
||||||
@@ -2181,9 +2503,8 @@ onBeforeUnmount(() => {
|
|||||||
:alt="tile.alt"
|
:alt="tile.alt"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<span v-if="wallSeats.bottom.hasHu" class="wall-hu-flag">胡</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="wallSeats.left.tiles.length > 0 || wallSeats.left.hasHu" class="wall wall-left wall-live">
|
<div v-if="wallSeats.left.tiles.length > 0" class="wall wall-left wall-live">
|
||||||
<img
|
<img
|
||||||
v-for="(tile, index) in wallSeats.left.tiles"
|
v-for="(tile, index) in wallSeats.left.tiles"
|
||||||
:key="tile.key"
|
:key="tile.key"
|
||||||
@@ -2195,7 +2516,6 @@ onBeforeUnmount(() => {
|
|||||||
:src="tile.src"
|
:src="tile.src"
|
||||||
:alt="tile.alt"
|
:alt="tile.alt"
|
||||||
/>
|
/>
|
||||||
<span v-if="wallSeats.left.hasHu" class="wall-hu-flag">胡</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div class="floating-status top">-->
|
<!-- <div class="floating-status top">-->
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ function mapRoomPlayers(room: RoomItem): RoomPlayerState[] {
|
|||||||
(typeof item.PlayerName === 'string' && item.PlayerName) ||
|
(typeof item.PlayerName === 'string' && item.PlayerName) ||
|
||||||
(item.player_id === currentUserId.value ? displayName.value : undefined),
|
(item.player_id === currentUserId.value ? displayName.value : undefined),
|
||||||
ready: Boolean(item.ready),
|
ready: Boolean(item.ready),
|
||||||
|
trustee: false,
|
||||||
hand: [],
|
hand: [],
|
||||||
melds: [],
|
melds: [],
|
||||||
outTiles: [],
|
outTiles: [],
|
||||||
|
|||||||
@@ -77,8 +77,10 @@ class WsClient {
|
|||||||
this.ws.onmessage = (event) => {
|
this.ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
|
console.log('[WS:RECV]', data)
|
||||||
this.messageHandlers.forEach(fn => fn(data))
|
this.messageHandlers.forEach(fn => fn(data))
|
||||||
} catch {
|
} catch {
|
||||||
|
console.log('[WS:RECV]', event.data)
|
||||||
this.messageHandlers.forEach(fn => fn(event.data))
|
this.messageHandlers.forEach(fn => fn(event.data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user