feat(game): 实现出牌选择与计时功能
- 添加 PlayerTurnPayload 接口定义和 PLAYER_TURN 动作类型 - 实现选牌、出牌确认逻辑和相关状态管理 - 添加客户端出牌限制检查和错误提示 - 集成 PLAYER_TURN WebSocket 消息处理 - 添加房间状态面板显示游戏信息 - 优化桌面背景图片和样式布局 - 添加马蹄形动画样式文件 - 配置 Vite 别名和端口设置
This commit is contained in:
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
BIN
src/assets/images/desk/desk_01_1920_945.png
Normal file
BIN
src/assets/images/desk/desk_01_1920_945.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1006 KiB |
@@ -1,8 +1,7 @@
|
||||
.picture-scene {
|
||||
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: 18px;
|
||||
padding: 0;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(116, 58, 41, 0.28), transparent 20%),
|
||||
linear-gradient(180deg, #3f2119 0%, #27140f 100%);
|
||||
@@ -10,44 +9,51 @@
|
||||
|
||||
.picture-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
min-height: calc(100vh - 36px);
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.table-stage {
|
||||
.picture-scene .table-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: start;
|
||||
align-content: stretch;
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 36px);
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-desk,
|
||||
.table-felt {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: calc(100dvh - 72px);
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.table-desk {
|
||||
grid-area: 1 / 1;
|
||||
.picture-scene .table-desk {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
margin-top: 18px;
|
||||
border-radius: 26px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 24px 44px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.table-felt {
|
||||
grid-area: 1 / 1;
|
||||
position: relative;
|
||||
margin-top: 18px;
|
||||
border-radius: 26px;
|
||||
.picture-scene .table-felt {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
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;
|
||||
justify-self: stretch;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.table-surface {
|
||||
@@ -312,10 +318,57 @@
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.action-countdown {
|
||||
.room-status-panel {
|
||||
position: absolute;
|
||||
top: 92px;
|
||||
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;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255, 219, 131, 0.22);
|
||||
@@ -713,6 +766,8 @@
|
||||
background: transparent;
|
||||
appearance: none;
|
||||
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 {
|
||||
@@ -738,6 +793,21 @@
|
||||
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 {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.18));
|
||||
@@ -1142,6 +1212,18 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1357,126 +1439,9 @@
|
||||
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) {
|
||||
.picture-scene {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.table-desk,
|
||||
.table-felt {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wall-right {
|
||||
@@ -1529,14 +1494,15 @@
|
||||
right: 20px;
|
||||
min-width: 164px;
|
||||
}
|
||||
|
||||
.room-status-panel {
|
||||
top: 92px;
|
||||
right: 20px;
|
||||
width: min(300px, calc(100% - 40px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.table-desk,
|
||||
.table-felt {
|
||||
aspect-ratio: 9 / 16;
|
||||
}
|
||||
|
||||
.inner-outline.mid {
|
||||
inset: 92px 34px 190px;
|
||||
}
|
||||
@@ -1596,8 +1562,23 @@
|
||||
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 {
|
||||
top: 62px;
|
||||
top: 176px;
|
||||
right: 16px;
|
||||
min-width: 0;
|
||||
width: calc(100% - 32px);
|
||||
|
||||
151
src/assets/styles/windowSquare.css
Normal file
151
src/assets/styles/windowSquare.css
Normal 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;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import squareIcon from '../../assets/images/icons/square.svg'
|
||||
import '@src/assets/styles/windowSquare.css'
|
||||
|
||||
defineProps<{
|
||||
seatWinds: {
|
||||
@@ -8,12 +9,20 @@ defineProps<{
|
||||
bottom: string
|
||||
left: string
|
||||
}
|
||||
activePosition?: 'top' | 'right' | 'bottom' | 'left' | ''
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wind-square">
|
||||
<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-b"></div>
|
||||
|
||||
@@ -31,98 +40,3 @@ defineProps<{
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
24
src/config/deskImageMap.ts
Normal file
24
src/config/deskImageMap.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
@@ -29,6 +29,20 @@ export interface RoomTrusteePayload {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 游戏动作定义(只描述“发生了什么”)
|
||||
@@ -92,3 +106,8 @@ export type GameAction =
|
||||
type: 'ROOM_TRUSTEE'
|
||||
payload: RoomTrusteePayload
|
||||
}
|
||||
|
||||
| {
|
||||
type: 'PLAYER_TURN'
|
||||
payload: PlayerTurnPayload
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ export function dispatchGameAction(action: GameAction) {
|
||||
store.onRoomTrustee(action.payload)
|
||||
break
|
||||
|
||||
case 'PLAYER_TURN':
|
||||
store.onPlayerTurn(action.payload)
|
||||
break
|
||||
|
||||
|
||||
default:
|
||||
throw new Error('Invalid game action')
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
type GameState,
|
||||
type PendingClaimState,
|
||||
} 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'
|
||||
|
||||
@@ -16,6 +17,7 @@ export const useGameStore = defineStore('game', {
|
||||
|
||||
dealerIndex: 0,
|
||||
currentTurn: 0,
|
||||
currentPlayerId: '',
|
||||
needDraw: false,
|
||||
|
||||
players: {},
|
||||
@@ -34,7 +36,7 @@ export const useGameStore = defineStore('game', {
|
||||
this.$reset()
|
||||
},
|
||||
|
||||
// 初始化
|
||||
// 初始<EFBFBD>?
|
||||
initGame(data: GameState) {
|
||||
Object.assign(this, data)
|
||||
},
|
||||
@@ -53,8 +55,9 @@ export const useGameStore = defineStore('game', {
|
||||
// 剩余牌数减少
|
||||
this.remainingTiles = Math.max(0, this.remainingTiles - 1)
|
||||
|
||||
// 更新回合(seatIndex)
|
||||
// 更新回合(seatIndex<EFBFBD>?
|
||||
this.currentTurn = player.seatIndex
|
||||
this.currentPlayerId = player.playerId
|
||||
|
||||
// 清除操作窗口
|
||||
this.pendingClaim = undefined
|
||||
@@ -84,7 +87,7 @@ export const useGameStore = defineStore('game', {
|
||||
}
|
||||
player.handCount = Math.max(0, player.handCount - 1)
|
||||
|
||||
// 加入出牌区
|
||||
// 加入出牌<EFBFBD>?
|
||||
player.discardTiles.push(data.tile)
|
||||
|
||||
// 更新回合
|
||||
@@ -95,7 +98,7 @@ export const useGameStore = defineStore('game', {
|
||||
this.phase = GAME_PHASE.ACTION
|
||||
},
|
||||
|
||||
// 触发操作窗口(碰/杠/胡)
|
||||
// 触发操作窗口(碰/<EFBFBD>?胡)
|
||||
onPendingClaim(data: PendingClaimState) {
|
||||
this.pendingClaim = data
|
||||
this.needDraw = false
|
||||
@@ -220,14 +223,49 @@ 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() {
|
||||
this.pendingClaim = undefined
|
||||
this.phase = GAME_PHASE.PLAYING
|
||||
},
|
||||
|
||||
// 获取当前玩家ID(后续建议放到 userStore)
|
||||
// 获取当前玩家ID(后续建议放<EFBFBD>?userStore<EFBFBD>?
|
||||
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 ''
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface GameState {
|
||||
|
||||
// 当前操作玩家(座位)
|
||||
currentTurn: number
|
||||
// 当前操作玩家ID
|
||||
currentPlayerId: string
|
||||
|
||||
// 当前回合是否需要先摸牌
|
||||
needDraw: boolean
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import deskImage from '../assets/images/desk/desk_01.png'
|
||||
import deskImage from '../assets/images/desk/desk_01_1920_945.png'
|
||||
import robotIcon from '../assets/images/icons/robot.svg'
|
||||
import exitIcon from '../assets/images/icons/exit.svg'
|
||||
import '../assets/styles/room.css'
|
||||
@@ -16,7 +16,7 @@ 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 type {SeatKey} from '../game/seat'
|
||||
import type {GameAction, RoomPlayerUpdatePayload, RoomTrusteePayload} from '../game/actions'
|
||||
import type {GameAction, PlayerTurnPayload, RoomPlayerUpdatePayload, RoomTrusteePayload} from '../game/actions'
|
||||
import {dispatchGameAction} from '../game/dispatcher'
|
||||
import {refreshAccessToken} from '../api/auth'
|
||||
import {AuthExpiredError, type AuthSession} from '../api/authed-request'
|
||||
@@ -96,6 +96,7 @@ const startGamePending = ref(false)
|
||||
const dingQuePending = ref(false)
|
||||
const discardPending = ref(false)
|
||||
const claimActionPending = ref(false)
|
||||
const selectedDiscardTileId = ref<number | null>(null)
|
||||
let clockTimer: number | null = null
|
||||
let discardPendingTimer: number | null = null
|
||||
let unsubscribe: (() => void) | null = null
|
||||
@@ -293,8 +294,6 @@ const seatWinds = computed<Record<SeatKey, string>>(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
const rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
|
||||
|
||||
const currentPhaseText = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
waiting: '等待中',
|
||||
@@ -386,26 +385,91 @@ const showDingQueChooser = computed(() => {
|
||||
return player.handTiles.length > 0 && !player.missingSuit
|
||||
})
|
||||
|
||||
const canDiscardTiles = computed(() => {
|
||||
const selectedDiscardTile = computed(() => {
|
||||
const player = myPlayer.value
|
||||
if (!player || selectedDiscardTileId.value === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return player.handTiles.find((tile) => tile.id === selectedDiscardTileId.value) ?? null
|
||||
})
|
||||
|
||||
const hasMissingSuitTiles = computed(() => {
|
||||
const player = myPlayer.value
|
||||
const missingSuit = player?.missingSuit as Tile['suit'] | null | undefined
|
||||
if (!player || !missingSuit) {
|
||||
return false
|
||||
}
|
||||
|
||||
return player.handTiles.some((tile) => tile.suit === missingSuit)
|
||||
})
|
||||
|
||||
const discardBlockedReason = computed(() => {
|
||||
const player = myPlayer.value
|
||||
if (!player || !gameStore.roomId) {
|
||||
return false
|
||||
return '未进入房间'
|
||||
}
|
||||
|
||||
if (wsStatus.value !== 'connected') {
|
||||
return false
|
||||
return 'WebSocket 未连接'
|
||||
}
|
||||
|
||||
if (showDingQueChooser.value) {
|
||||
return '请先完成定缺'
|
||||
}
|
||||
|
||||
if (gameStore.phase !== 'playing') {
|
||||
return '当前不是出牌阶段'
|
||||
}
|
||||
|
||||
if (player.seatIndex !== gameStore.currentTurn) {
|
||||
return '未轮到你出牌'
|
||||
}
|
||||
|
||||
if (gameStore.needDraw) {
|
||||
return '请先摸牌'
|
||||
}
|
||||
|
||||
if (gameStore.pendingClaim) {
|
||||
return '等待当前操作结算'
|
||||
}
|
||||
|
||||
if (player.handTiles.length === 0) {
|
||||
return false
|
||||
return '当前没有可出的手牌'
|
||||
}
|
||||
|
||||
if (discardPending.value) {
|
||||
return false
|
||||
return '正在提交出牌'
|
||||
}
|
||||
|
||||
// 交给后端做最终合法性校验,前端只避免明显无效点击。
|
||||
return true
|
||||
return ''
|
||||
})
|
||||
|
||||
function discardTileBlockedReason(tile: Tile): string {
|
||||
if (discardBlockedReason.value) {
|
||||
return discardBlockedReason.value
|
||||
}
|
||||
|
||||
const player = 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
|
||||
if (!tile) {
|
||||
return false
|
||||
}
|
||||
return !discardTileBlockedReason(tile)
|
||||
})
|
||||
|
||||
const confirmDiscardLabel = computed(() => {
|
||||
const tile = selectedDiscardTile.value
|
||||
return tile ? `出牌 ${formatTile(tile)}` : '出牌'
|
||||
})
|
||||
|
||||
const canDrawTile = computed(() => {
|
||||
@@ -559,6 +623,14 @@ function readNumber(source: Record<string, unknown>, ...keys: string[]): number
|
||||
return null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function readStringArray(source: Record<string, unknown>, ...keys: string[]): string[] {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
@@ -579,6 +651,15 @@ function readBoolean(source: Record<string, unknown>, ...keys: string[]): boolea
|
||||
return null
|
||||
}
|
||||
|
||||
function readPlayerTurnPlayerId(payload: PlayerTurnPayload): string {
|
||||
return (
|
||||
(typeof payload.player_id === 'string' && payload.player_id) ||
|
||||
(typeof payload.playerId === 'string' && payload.playerId) ||
|
||||
(typeof payload.PlayerID === 'string' && payload.PlayerID) ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
function readMissingSuit(source: Record<string, unknown> | null | undefined): string | null {
|
||||
if (!source) {
|
||||
return null
|
||||
@@ -1567,13 +1648,13 @@ function handlePlayerHandResponse(message: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
function handleRoomCountdown(message: unknown): void {
|
||||
function handleDingQueCountdown(message: unknown): void {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizeWsType(source.type) !== 'ROOM_COUNTDOWN') {
|
||||
if (normalizeWsType(source.type) !== 'DING_QUE_COUNTDOWN') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1612,6 +1693,58 @@ function handleRoomCountdown(message: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
function applyPlayerTurnCountdown(payload: PlayerTurnPayload): 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) {
|
||||
roomCountdown.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const startAtMs = normalizeTimestampMs(startAtRaw)
|
||||
const deadlineAtMs = startAtMs !== null ? startAtMs + timeout * 1000 : null
|
||||
const remaining = deadlineAtMs !== null
|
||||
? Math.max(0, Math.ceil((deadlineAtMs - now.value) / 1000))
|
||||
: timeout
|
||||
|
||||
roomCountdown.value = {
|
||||
playerIds: [playerId],
|
||||
actionDeadlineAt: deadlineAtMs !== null ? new Date(deadlineAtMs).toISOString() : null,
|
||||
countdownSeconds: timeout,
|
||||
duration: timeout,
|
||||
remaining,
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlayerTurn(message: unknown): void {
|
||||
const source = asRecord(message)
|
||||
if (!source || typeof source.type !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizeWsType(source.type) !== 'PLAYER_TURN') {
|
||||
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
|
||||
}
|
||||
|
||||
applyPlayerTurnCountdown(payload as PlayerTurnPayload)
|
||||
}
|
||||
|
||||
function toGameAction(message: unknown): GameAction | null {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return null
|
||||
@@ -1674,6 +1807,14 @@ function toGameAction(message: unknown): GameAction | null {
|
||||
type: 'ROOM_TRUSTEE',
|
||||
payload: source as unknown as GameActionPayload<'ROOM_TRUSTEE'>,
|
||||
}
|
||||
case 'PLAYER_TURN':
|
||||
if (payload && typeof payload === 'object') {
|
||||
return {type: 'PLAYER_TURN', payload: payload as GameActionPayload<'PLAYER_TURN'>}
|
||||
}
|
||||
return {
|
||||
type: 'PLAYER_TURN',
|
||||
payload: source as unknown as GameActionPayload<'PLAYER_TURN'>,
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -1979,22 +2120,6 @@ async function ensureWsConnected(forceRefresh = false): Promise<void> {
|
||||
wsClient.connect(buildWsUrl(), token)
|
||||
}
|
||||
|
||||
async function reconnectWsInternal(forceRefresh = false): Promise<boolean> {
|
||||
const token = await resolveWsToken(forceRefresh, false)
|
||||
if (!token) {
|
||||
wsError.value = '未找到登录凭证,无法建立连接'
|
||||
return false
|
||||
}
|
||||
|
||||
wsError.value = ''
|
||||
wsClient.reconnect(buildWsUrl(), token)
|
||||
return true
|
||||
}
|
||||
|
||||
function reconnectWs(): void {
|
||||
void reconnectWsInternal()
|
||||
}
|
||||
|
||||
function backHall(): void {
|
||||
leaveRoomPending.value = true
|
||||
const roomId = gameStore.roomId
|
||||
@@ -2072,6 +2197,7 @@ function clearDiscardPendingTimer(): void {
|
||||
function markDiscardCompleted(): void {
|
||||
clearDiscardPendingTimer()
|
||||
discardPending.value = false
|
||||
selectedDiscardTileId.value = null
|
||||
}
|
||||
|
||||
function markDiscardPendingWithFallback(): void {
|
||||
@@ -2079,15 +2205,40 @@ function markDiscardPendingWithFallback(): void {
|
||||
discardPending.value = true
|
||||
discardPendingTimer = window.setTimeout(() => {
|
||||
discardPending.value = false
|
||||
selectedDiscardTileId.value = null
|
||||
discardPendingTimer = null
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function discardTile(tile: Tile): void {
|
||||
if (!canDiscardTiles.value || !gameStore.roomId) {
|
||||
function selectDiscardTile(tile: Tile): void {
|
||||
const blockedReason = discardTileBlockedReason(tile)
|
||||
if (blockedReason) {
|
||||
wsError.value = blockedReason
|
||||
wsMessages.value.push(`[client-blocked] select ${formatTile(tile)}: ${blockedReason}`)
|
||||
selectedDiscardTileId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
wsError.value = ''
|
||||
selectedDiscardTileId.value = selectedDiscardTileId.value === tile.id ? null : tile.id
|
||||
}
|
||||
|
||||
function confirmDiscard(): void {
|
||||
const tile = selectedDiscardTile.value
|
||||
if (!tile) {
|
||||
return
|
||||
}
|
||||
|
||||
const blockedReason = discardTileBlockedReason(tile)
|
||||
if (blockedReason || !gameStore.roomId) {
|
||||
if (blockedReason) {
|
||||
wsError.value = blockedReason
|
||||
wsMessages.value.push(`[client-blocked] discard ${formatTile(tile)}: ${blockedReason}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
wsError.value = ''
|
||||
markDiscardPendingWithFallback()
|
||||
sendWsMessage({
|
||||
type: 'discard',
|
||||
@@ -2228,7 +2379,8 @@ onMounted(() => {
|
||||
handleRoomInfoResponse(msg)
|
||||
handleRoomStateResponse(msg)
|
||||
handlePlayerHandResponse(msg)
|
||||
handleRoomCountdown(msg)
|
||||
handlePlayerTurn(msg)
|
||||
handleDingQueCountdown(msg)
|
||||
handleReadyStateResponse(msg)
|
||||
handlePlayerDingQueResponse(msg)
|
||||
const gameAction = toGameAction(msg)
|
||||
@@ -2236,10 +2388,14 @@ onMounted(() => {
|
||||
dispatchGameAction(gameAction)
|
||||
if (gameAction.type === 'GAME_START') {
|
||||
startGamePending.value = false
|
||||
roomCountdown.value = null
|
||||
}
|
||||
if (gameAction.type === 'PLAY_TILE' && gameAction.payload.playerId === loggedInUserId.value) {
|
||||
markDiscardCompleted()
|
||||
}
|
||||
if (gameAction.type === 'PLAY_TILE' || gameAction.type === 'PENDING_CLAIM' || gameAction.type === 'CLAIM_RESOLVED') {
|
||||
roomCountdown.value = null
|
||||
}
|
||||
if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
|
||||
syncReadyStatesFromRoomUpdate(gameAction.payload)
|
||||
readyTogglePending.value = false
|
||||
@@ -2250,6 +2406,9 @@ onMounted(() => {
|
||||
if (gameAction.type === 'ROOM_TRUSTEE') {
|
||||
syncTrusteeState(gameAction.payload)
|
||||
}
|
||||
if (gameAction.type === 'PLAYER_TURN' && readPlayerTurnPlayerId(gameAction.payload) !== loggedInUserId.value) {
|
||||
selectedDiscardTileId.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
wsClient.onError((message: string) => {
|
||||
@@ -2372,6 +2531,28 @@ onBeforeUnmount(() => {
|
||||
<span>{{ formattedClock }}</span>
|
||||
</div>
|
||||
|
||||
<div class="room-status-panel">
|
||||
<div class="room-status-grid">
|
||||
<div class="room-status-item">
|
||||
<span>房间</span>
|
||||
<strong>{{ roomState.name || roomName || '未命名' }}</strong>
|
||||
</div>
|
||||
<div class="room-status-item">
|
||||
<span>阶段</span>
|
||||
<strong>{{ currentPhaseText }}</strong>
|
||||
</div>
|
||||
<div class="room-status-item">
|
||||
<span>人数</span>
|
||||
<strong>{{ roomState.playerCount }}/{{ roomState.maxPlayers }}</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>
|
||||
@@ -2479,11 +2660,13 @@ onBeforeUnmount(() => {
|
||||
: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="!canDiscardTiles || discardPending"
|
||||
@click="discardTile(tile.tile)"
|
||||
:disabled="Boolean(discardBlockedReason)"
|
||||
:title="discardTileBlockedReason(tile.tile) || formatTile(tile.tile)"
|
||||
@click="selectDiscardTile(tile.tile)"
|
||||
>
|
||||
<span v-if="tile.showLackTag" class="wall-live-tile-lack-tag">缺</span>
|
||||
<img
|
||||
@@ -2539,7 +2722,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
|
||||
<div class="bottom-control-panel">
|
||||
<div v-if="showDingQueChooser || showReadyToggle || showStartGameButton" class="bottom-action-bar">
|
||||
<div v-if="showDingQueChooser || showReadyToggle || showStartGameButton || selectedDiscardTile" class="bottom-action-bar">
|
||||
<div v-if="showDingQueChooser" class="ding-que-bar">
|
||||
<button
|
||||
class="ding-que-button"
|
||||
@@ -2570,6 +2753,17 @@ onBeforeUnmount(() => {
|
||||
</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="confirmDiscard"
|
||||
>
|
||||
<span class="ready-toggle-label">{{ confirmDiscardLabel }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showReadyToggle"
|
||||
class="ready-toggle ready-toggle-inline"
|
||||
@@ -2620,41 +2814,6 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="ws-sidebar">
|
||||
<div class="sidebar-head">
|
||||
<div>
|
||||
<p class="sidebar-title">WebSocket 消息</p>
|
||||
<small>{{ networkLabel }} · {{ loggedInUserName || '未登录昵称' }}</small>
|
||||
</div>
|
||||
<button class="sidebar-btn" type="button" @click="reconnectWs">重连</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-stats">
|
||||
<div class="sidebar-stat">
|
||||
<span>房间</span>
|
||||
<strong>{{ roomState.name || roomName || '未命名' }}</strong>
|
||||
</div>
|
||||
<div class="sidebar-stat">
|
||||
<span>阶段</span>
|
||||
<strong>{{ currentPhaseText }}</strong>
|
||||
</div>
|
||||
<div class="sidebar-stat">
|
||||
<span>人数</span>
|
||||
<strong>{{ roomState.playerCount }}/{{ roomState.maxPlayers }}</strong>
|
||||
</div>
|
||||
<div class="sidebar-stat">
|
||||
<span>状态</span>
|
||||
<strong>{{ roomStatusText }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="wsError" class="sidebar-error">{{ wsError }}</p>
|
||||
|
||||
<div class="sidebar-log">
|
||||
<p v-if="rightMessages.length === 0" class="sidebar-empty">等待服务器消息...</p>
|
||||
<p v-for="(line, index) in rightMessages" :key="index" class="sidebar-line">{{ line }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import {defineConfig, loadEnv} from 'vite'
|
||||
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 apiProxyTarget = (env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:19000').replace(/\/$/, '')
|
||||
const wsProxyTarget = (env.VITE_WS_PROXY_TARGET || apiProxyTarget).replace(/\/$/, '')
|
||||
|
||||
return {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@src': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 8080,
|
||||
proxy: {
|
||||
'/ws': {
|
||||
target: wsProxyTarget,
|
||||
|
||||
Reference in New Issue
Block a user