Compare commits

...

8 Commits

Author SHA1 Message Date
9b3e3fdb90 feat(game): 结算倒计时展示+自动下一局
- 解析后端settlement_deadline_ms,展示5秒结算倒计时
- 下一局按钮显示倒计时秒数(如"下一局 (3s)")
- 倒计时归零自动发送next_round(可提前点击)
- 新局开始时清除结算状态

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

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

View File

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

View File

@@ -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 }, 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,6 +44,7 @@ export async function createRoom(
body: { body: {
name: input.name, name: input.name,
game_type: input.gameType, game_type: input.gameType,
total_rounds: input.totalRounds,
max_players: input.maxPlayers, max_players: input.maxPlayers,
}, },
}) })

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,6 +29,20 @@ export interface RoomTrusteePayload {
reason?: string 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' type: 'ROOM_TRUSTEE'
payload: RoomTrusteePayload payload: RoomTrusteePayload
} }
| {
type: 'PLAYER_TURN'
payload: PlayerTurnPayload
}

View File

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

View File

@@ -4,10 +4,35 @@ import {
type GameState, type GameState,
type PendingClaimState, type PendingClaimState,
} from '../types/state' } from '../types/state'
import type { RoomPlayerUpdatePayload, RoomTrusteePayload } from '../game/actions' import type { PlayerTurnPayload, RoomPlayerUpdatePayload, RoomTrusteePayload } from '../game/actions'
import { readStoredAuth } from '../utils/auth-storage'
import type { Tile } from '../types/tile' import type { Tile } from '../types/tile'
function parseBooleanish(value: unknown): boolean | null {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
if (value === 1) {
return true
}
if (value === 0) {
return false
}
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'true' || normalized === '1') {
return true
}
if (normalized === 'false' || normalized === '0') {
return false
}
}
return null
}
export const useGameStore = defineStore('game', { export const useGameStore = defineStore('game', {
state: (): GameState => ({ state: (): GameState => ({
roomId: '', roomId: '',
@@ -16,6 +41,7 @@ export const useGameStore = defineStore('game', {
dealerIndex: 0, dealerIndex: 0,
currentTurn: 0, currentTurn: 0,
currentPlayerId: '',
needDraw: false, needDraw: false,
players: {}, players: {},
@@ -27,6 +53,10 @@ export const useGameStore = defineStore('game', {
winners: [], winners: [],
scores: {}, scores: {},
currentRound: 0,
totalRounds: 0,
}), }),
actions: { actions: {
@@ -34,7 +64,7 @@ export const useGameStore = defineStore('game', {
this.$reset() this.$reset()
}, },
// 初始 // 初始<EFBFBD>?
initGame(data: GameState) { initGame(data: GameState) {
Object.assign(this, data) Object.assign(this, data)
}, },
@@ -53,8 +83,9 @@ export const useGameStore = defineStore('game', {
// 剩余牌数减少 // 剩余牌数减少
this.remainingTiles = Math.max(0, this.remainingTiles - 1) this.remainingTiles = Math.max(0, this.remainingTiles - 1)
// 更新回合seatIndex // 更新回合seatIndex<EFBFBD>?
this.currentTurn = player.seatIndex this.currentTurn = player.seatIndex
this.currentPlayerId = player.playerId
// 清除操作窗口 // 清除操作窗口
this.pendingClaim = undefined this.pendingClaim = undefined
@@ -84,7 +115,7 @@ export const useGameStore = defineStore('game', {
} }
player.handCount = Math.max(0, player.handCount - 1) player.handCount = Math.max(0, player.handCount - 1)
// 加入出牌 // 加入出牌<EFBFBD>?
player.discardTiles.push(data.tile) player.discardTiles.push(data.tile)
// 更新回合 // 更新回合
@@ -95,7 +126,7 @@ export const useGameStore = defineStore('game', {
this.phase = GAME_PHASE.ACTION this.phase = GAME_PHASE.ACTION
}, },
// 触发操作窗口(碰/杠/胡) // 触发操作窗口(碰/<EFBFBD>?胡)
onPendingClaim(data: PendingClaimState) { onPendingClaim(data: PendingClaimState) {
this.pendingClaim = data this.pendingClaim = data
this.needDraw = false this.needDraw = false
@@ -142,6 +173,7 @@ export const useGameStore = defineStore('game', {
const seatIndex = const seatIndex =
typeof seatRaw === 'number' && Number.isFinite(seatRaw) ? seatRaw : index typeof seatRaw === 'number' && Number.isFinite(seatRaw) ? seatRaw : index
const readyRaw = raw.Ready ?? raw.ready const readyRaw = raw.Ready ?? raw.ready
const ready = parseBooleanish(readyRaw)
const displayNameRaw = raw.PlayerName ?? raw.player_name const displayNameRaw = raw.PlayerName ?? raw.player_name
const avatarUrlRaw = raw.AvatarUrl ?? raw.avatar_url const avatarUrlRaw = raw.AvatarUrl ?? raw.avatar_url
const missingSuitRaw = raw.MissingSuit ?? raw.missing_suit const missingSuitRaw = raw.MissingSuit ?? raw.missing_suit
@@ -169,8 +201,8 @@ export const useGameStore = defineStore('game', {
hasHu: previous?.hasHu ?? false, hasHu: previous?.hasHu ?? false,
score: previous?.score ?? 0, score: previous?.score ?? 0,
isReady: isReady:
typeof readyRaw === 'boolean' ready !== null
? readyRaw ? ready
: (previous?.isReady ?? false), : (previous?.isReady ?? false),
} }
}) })
@@ -220,14 +252,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() { clearPendingClaim() {
this.pendingClaim = undefined this.pendingClaim = undefined
this.phase = GAME_PHASE.PLAYING this.phase = GAME_PHASE.PLAYING
}, },
// 获取当前玩家ID后续建议放userStore // 获取当前玩家ID后续建议放<EFBFBD>?userStore<EFBFBD>?
getMyPlayerId(): string { getMyPlayerId(): string {
return Object.keys(this.players)[0] || '' const auth = readStoredAuth()
const source = auth?.user as Record<string, unknown> | undefined
const rawId =
source?.id ??
source?.userID ??
source?.user_id
if (typeof rawId === 'string' && rawId.trim()) {
return rawId
}
if (typeof rawId === 'number') {
return String(rawId)
}
return ''
}, },
}, },
}) })

View File

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

File diff suppressed because it is too large Load Diff

View File

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