Compare commits

...

2 Commits

Author SHA1 Message Date
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
4 changed files with 331 additions and 23 deletions

View File

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

View File

@@ -33,7 +33,7 @@ const ROOM_JOIN_PATH = import.meta.env.VITE_ROOM_JOIN_PATH ?? '/api/v1/game/mahj
export async function createRoom(
auth: AuthSession,
input: { name: string; gameType: string; maxPlayers: number },
input: { name: string; gameType: string; totalRounds: number; maxPlayers: number },
onAuthUpdated?: (next: AuthSession) => void,
): Promise<RoomItem> {
return authedRequest<RoomItem>({
@@ -44,6 +44,7 @@ export async function createRoom(
body: {
name: input.name,
game_type: input.gameType,
total_rounds: input.totalRounds,
max_players: input.maxPlayers,
},
})

View File

@@ -625,12 +625,12 @@
}
.wall-right {
right: 110px;
right: 140px;
gap: 0;
}
.wall-left {
left: 110px;
left: 140px;
}
.wall-left img,
@@ -707,6 +707,7 @@
}
.wall-live-tile-button {
position: relative;
padding: 0;
border: 0;
background: transparent;
@@ -714,6 +715,24 @@
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 {
cursor: default;
opacity: 1;
@@ -772,6 +791,116 @@
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 {
position: absolute;
left: 50%;
@@ -1358,6 +1487,22 @@
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 {
inset: 70px 72px 120px;
}
@@ -1406,6 +1551,11 @@
display: none;
}
.desk-zone-top,
.desk-zone-bottom {
display: none;
}
.wall-left {
left: 32px;
}
@@ -1414,6 +1564,14 @@
right: 32px;
}
.desk-zone-left {
left: 84px;
}
.desk-zone-right {
right: 84px;
}
.floating-status.left,
.floating-status.right {
display: none;

View File

@@ -55,12 +55,18 @@ interface WallTileItem {
src: string
alt: string
imageType: TableTileImageType
isGroupStart?: boolean
showLackTag?: boolean
suit?: Tile['suit']
tile?: Tile
}
interface WallSeatState {
tiles: WallTileItem[]
}
interface DeskSeatState {
tiles: WallTileItem[]
hasHu: boolean
}
@@ -91,6 +97,7 @@ const dingQuePending = ref(false)
const discardPending = ref(false)
const claimActionPending = ref(false)
let clockTimer: number | null = null
let discardPendingTimer: number | null = null
let unsubscribe: (() => void) | null = null
let needsInitialRoomInfo = false
@@ -385,19 +392,20 @@ const canDiscardTiles = computed(() => {
return false
}
if (gameStore.phase !== 'playing') {
if (wsStatus.value !== 'connected') {
return false
}
if (gameStore.needDraw) {
if (player.handTiles.length === 0) {
return false
}
if (!player.missingSuit || player.handTiles.length === 0) {
if (discardPending.value) {
return false
}
return player.seatIndex === gameStore.currentTurn
// 交给后端做最终合法性校验,前端只避免明显无效点击。
return true
})
const canDrawTile = computed(() => {
@@ -815,6 +823,7 @@ function handleRoomStateResponse(message: unknown): void {
seatIndex,
displayName: previous?.displayName ?? playerId,
avatarURL: previous?.avatarURL,
isTrustee: previous?.isTrustee ?? false,
missingSuit: dingQue || previous?.missingSuit || null,
handTiles: previous?.handTiles ?? [],
handCount,
@@ -922,7 +931,7 @@ function handleRoomStateResponse(message: unknown): void {
startGamePending.value = false
}
if (currentTurnPlayerId && currentTurnPlayerId !== loggedInUserId.value) {
discardPending.value = false
markDiscardCompleted()
}
}
@@ -1267,6 +1276,12 @@ function buildWallTileImage(
}
function emptyWallSeat(): WallSeatState {
return {
tiles: [],
}
}
function emptyDeskSeat(): DeskSeatState {
return {
tiles: [],
hasHu: false,
@@ -1294,17 +1309,25 @@ const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
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,
})
@@ -1325,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) => {
meld.tiles.forEach((tile, tileIndex) => {
const imageType: TableTileImageType = meld.type === 'an_gang' ? 'covered' : 'exposed'
@@ -1334,10 +1400,11 @@ const wallSeats = computed<Record<SeatKey, WallSeatState>>(() => {
}
seatTiles.push({
key: `${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`,
key: `desk-${meld.type}-${meldIndex}-${tile.id}-${tileIndex}`,
src,
alt: formatTile(tile),
imageType,
isGroupStart: tileIndex === 0,
suit: tile.suit,
})
})
@@ -1494,7 +1561,7 @@ function handlePlayerHandResponse(message: unknown): void {
}
}
discardPending.value = false
markDiscardCompleted()
if (gameStore.phase !== 'waiting') {
startGamePending.value = false
}
@@ -1995,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 {
if (discardPending.value || !canDiscardTiles.value) {
if (!canDiscardTiles.value || !gameStore.roomId) {
return
}
discardPending.value = true
markDiscardPendingWithFallback()
sendWsMessage({
type: 'discard',
roomId: gameStore.roomId,
payload: {
room_id: gameStore.roomId,
tile,
tile: {
id: tile.id,
suit: tile.suit,
value: tile.value,
},
},
})
}
@@ -2147,7 +2238,7 @@ onMounted(() => {
startGamePending.value = false
}
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === loggedInUserId.value) {
discardPending.value = false
markDiscardCompleted()
}
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
syncReadyStatesFromRoomUpdate(gameAction.payload)
@@ -2162,6 +2253,7 @@ onMounted(() => {
}
})
wsClient.onError((message: string) => {
markDiscardCompleted()
wsError.value = message
wsMessages.value.push(`[error] ${message}`)
@@ -2202,6 +2294,7 @@ onBeforeUnmount(() => {
window.clearInterval(clockTimer)
clockTimer = null
}
clearDiscardPendingTimer()
window.removeEventListener('click', handleGlobalClick)
window.removeEventListener('keydown', handleGlobalEsc)
@@ -2295,7 +2388,64 @@ onBeforeUnmount(() => {
<BottomPlayerCard :player="seatDecor.bottom"/>
<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
v-for="(tile, index) in wallSeats.top.tiles"
:key="tile.key"
@@ -2307,9 +2457,8 @@ onBeforeUnmount(() => {
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.top.hasHu" class="wall-hu-flag"></span>
</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
v-for="(tile, index) in wallSeats.right.tiles"
:key="tile.key"
@@ -2321,21 +2470,22 @@ onBeforeUnmount(() => {
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.right.hasHu" class="wall-hu-flag"></span>
</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">
<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,
}"
:data-testid="`hand-tile-${tile.tile.id}`"
type="button"
:disabled="!canDiscardTiles || discardPending"
@click="discardTile(tile.tile)"
>
<span v-if="tile.showLackTag" class="wall-live-tile-lack-tag"></span>
<img
class="wall-live-tile"
:src="tile.src"
@@ -2353,9 +2503,8 @@ onBeforeUnmount(() => {
:alt="tile.alt"
/>
</template>
<span v-if="wallSeats.bottom.hasHu" class="wall-hu-flag"></span>
</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
v-for="(tile, index) in wallSeats.left.tiles"
:key="tile.key"
@@ -2367,7 +2516,6 @@ onBeforeUnmount(() => {
:src="tile.src"
:alt="tile.alt"
/>
<span v-if="wallSeats.left.hasHu" class="wall-hu-flag"></span>
</div>
<!-- <div class="floating-status top">-->