Compare commits
2 Commits
623ee94b04
...
43439cb09d
| Author | SHA1 | Date | |
|---|---|---|---|
| 43439cb09d | |||
| be9bd8c76d |
@@ -31,6 +31,7 @@ HTTP 接口:
|
||||
{
|
||||
"name": "房间名",
|
||||
"game_type": "chengdu",
|
||||
"total_rounds": 8,
|
||||
"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(
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">-->
|
||||
|
||||
Reference in New Issue
Block a user