diff --git a/backend/src/bootstrap.ts b/backend/src/bootstrap.ts new file mode 100644 index 0000000..7a4ccf7 --- /dev/null +++ b/backend/src/bootstrap.ts @@ -0,0 +1,20 @@ +/** + * Bootstrap module — must be imported first in index.ts. + * Loads .env before any other module reads process.env. + */ +import { resolve } from 'path'; + +import { config } from 'dotenv'; + +import { logger } from './utils/logger'; + +const result = config({ path: resolve(__dirname, '../.env') }); + +if (result.error) { + logger.warn( + 'Bootstrap', + 'Warning: .env file not found, using default values' + ); +} else { + logger.log('Bootstrap', 'Environment variables loaded successfully'); +} diff --git a/backend/src/constants/config.ts b/backend/src/constants/config.ts index 33e58bb..ee8eacf 100644 --- a/backend/src/constants/config.ts +++ b/backend/src/constants/config.ts @@ -29,7 +29,7 @@ export const DRUM_CONFIG = { /** Game duration (ms) */ GAME_DURATION_MS: 10000, /** Max taps to win instantly */ - MAX_TAPS: 30, + MAX_TAPS: 60, } as const; export const VERDICT_CONFIG = { diff --git a/backend/src/controllers/ws-controller.ts b/backend/src/controllers/ws-controller.ts index 20e454a..2070ef8 100644 --- a/backend/src/controllers/ws-controller.ts +++ b/backend/src/controllers/ws-controller.ts @@ -47,6 +47,7 @@ import type { IJoinRoomMessage, IChatSendMessage, IDrumTapMessage, + IDrumStartRequestMessage, IASRTextPushMessage, IEmojiSendMessage, ISpeechTurnEndMessage, @@ -57,7 +58,7 @@ import type { } from '../types/websocket'; import { EWSMessageType, EWSErrorCode, EGamePhase } from '../types/websocket'; import { ERoomStatus } from '../models/entities/room'; -import { DRUM_CONFIG, WAITING_ROOM_CONFIG } from '../constants/config'; +import { DRUM_CONFIG } from '../constants/config'; import { logger } from '../utils/logger'; export class WebSocketController { @@ -93,6 +94,13 @@ export class WebSocketController { ); break; + case EWSMessageType.DrumStartRequest: + WebSocketController.handleDrumStartRequestMessage( + connectionId, + message as IDrumStartRequestMessage + ); + break; + case EWSMessageType.AsrTextPush: WebSocketController.handleASRTextPushMessage( connectionId, @@ -185,11 +193,10 @@ export class WebSocketController { timestamp: Date.now(), }); - // If room is ready (2 players), start drum game + // If room is ready (2 players), initialize drum game and wait for + // frontend to send DRUM_START_REQUEST before launching if (result.room.status === ERoomStatus.Ready) { - setTimeout(() => { - WebSocketController.startDrumGame(result.room.roomId); - }, WAITING_ROOM_CONFIG.COUNTDOWN_MS); + WebSocketController.initDrumGame(result.room.roomId); } } @@ -372,10 +379,11 @@ export class WebSocketController { } /** - * Start drum game flow - * Called when room is ready (2 players joined) + * Initialize drum game state and broadcast DRUM_READY + * Called when room reaches READY status (2 players joined). + * Does NOT start the countdown — waits for DRUM_START_REQUEST from client. */ - private static startDrumGame(roomId: string): void { + private static initDrumGame(roomId: string): void { const room = roomManager.getRoomById(roomId); if (!room) { logger.error('WSController', `Room ${roomId} not found`); @@ -410,6 +418,35 @@ export class WebSocketController { timestamp: Date.now(), }); + logger.log( + 'WSController', + `Drum game ${roomId} initialized, waiting for DRUM_START_REQUEST` + ); + } + + /** + * Launch drum game countdown and scheduling + * Called when frontend sends DRUM_START_REQUEST. + */ + private static launchDrumGame(roomId: string): void { + const game = drumGameManager.getGame(roomId); + if (!game) { + logger.error( + 'WSController', + `Cannot launch: game ${roomId} not initialized` + ); + return; + } + + // Guard: only launch from Waiting phase + if (game.phase !== EGamePhase.Waiting) { + logger.log( + 'WSController', + `Game ${roomId} already launched (phase: ${game.phase}), ignoring` + ); + return; + } + // Set countdown phase drumGameManager.setPhase(roomId, EGamePhase.Countdown); @@ -441,8 +478,41 @@ export class WebSocketController { logger.log( 'WSController', - `Started drum game ${roomId} (start: ${startAtMs}, end: ${endAtMs})` + `Launched drum game ${roomId} (start: ${startAtMs}, end: ${endAtMs})` + ); + } + + /** + * Handle DRUM_START_REQUEST message + * Called when a player signals they are ready to start the drum game. + * Broadcasts DRUM_PLAYER_READY after each signal, then launches the game + * once both players are ready. + */ + private static handleDrumStartRequestMessage( + _connectionId: string, + message: IDrumStartRequestMessage + ): void { + const { roomId, userId } = message.data; + + const bothReady: boolean = drumGameManager.markPlayerReady( + roomId, + userId ); + const readyCount: number = drumGameManager.getReadyCount(roomId); + + // Broadcast ready state to all participants + connectionManager.broadcastToRoom(roomId, { + type: EWSMessageType.DrumPlayerReady, + data: { + roomId, + readyCount, + }, + timestamp: Date.now(), + }); + + if (bothReady) { + WebSocketController.launchDrumGame(roomId); + } } /** diff --git a/backend/src/index.ts b/backend/src/index.ts index 00a5d83..5314fe7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,18 +1,15 @@ -// Now import modules that depend on environment variables +import './bootstrap'; // Must be first — loads .env before other modules read process.env + import http from 'http'; -import { loadEnv, validateEnv } from './utils/env-loader'; +import app from './app'; +import { APP_CONFIG } from './constants/config'; import { logger } from './utils/logger'; -let server: http.Server; +import { initWebSocket } from './ws'; -async function bootstrap() { - loadEnv(); - validateEnv(); - - const { default: app } = await import('./app'); - const { initWebSocket } = await import('./ws'); - const { APP_CONFIG } = await import('./constants/config'); +let server: http.Server; +function bootstrap(): void { server = http.createServer(app); initWebSocket(server); @@ -22,10 +19,7 @@ async function bootstrap() { }); } -bootstrap().catch(e => { - logger.error('Server', 'Bootstrap failed:', e); - process.exit(1); -}); +bootstrap(); // Graceful shutdown handler function handleShutdown(signal: string): void { diff --git a/backend/src/services/websocket/drum-game-manager.ts b/backend/src/services/websocket/drum-game-manager.ts index 8f21fb2..d74bda5 100644 --- a/backend/src/services/websocket/drum-game-manager.ts +++ b/backend/src/services/websocket/drum-game-manager.ts @@ -9,6 +9,7 @@ import type { IRoom } from '../../models/entities/room'; import type { IUser } from '../../models/entities/user'; import { EPlayerRole, EGamePhase } from '../../types/websocket/drum'; +import { DRUM_CONFIG } from '../../constants/config'; import { logger } from '../../utils/logger'; /** @@ -24,6 +25,8 @@ interface IDrumGameState { joinerScore: number; startAtMs: number; endAtMs: number; + readyUserIds: Set; + firstToMaxRole?: EPlayerRole; } /** @@ -77,6 +80,7 @@ export class DrumGameManager { joinerScore: 0, startAtMs: 0, endAtMs: 0, + readyUserIds: new Set(), }; this.games.set(roomId, game); @@ -96,6 +100,36 @@ export class DrumGameManager { return this.games.get(roomId); } + /** + * Get the number of players who have signalled ready. + */ + getReadyCount(roomId: string): number { + return this.games.get(roomId)?.readyUserIds.size ?? 0; + } + + /** + * Mark a player as ready to start the drum game. + * Returns true when both players have signalled ready. + */ + markPlayerReady(roomId: string, userId: string): boolean { + const game = this.games.get(roomId); + if (!game) { + return false; + } + + game.readyUserIds.add(userId); + + const totalPlayers: number = 2; + const bothReady: boolean = game.readyUserIds.size >= totalPlayers; + + logger.log( + 'DrumGameManager', + `Game ${roomId} ready: ${game.readyUserIds.size}/${totalPlayers} (userId: ${userId})` + ); + + return bothReady; + } + /** * Update game phase */ @@ -157,6 +191,15 @@ export class DrumGameManager { game.joinerScore += delta; } + // Record who first reaches MAX_TAPS (only once) + if (game.firstToMaxRole === undefined) { + if (game.organizerScore >= DRUM_CONFIG.MAX_TAPS) { + game.firstToMaxRole = EPlayerRole.Organizer; + } else if (game.joinerScore >= DRUM_CONFIG.MAX_TAPS) { + game.firstToMaxRole = EPlayerRole.Joiner; + } + } + logger.log( 'DrumGameManager', `Game ${roomId} tap: ${role} +${delta} (Organizer: ${game.organizerScore}, Joiner: ${game.joinerScore})` @@ -177,7 +220,10 @@ export class DrumGameManager { let winnerRole: EPlayerRole; - if (game.organizerScore > game.joinerScore) { + if (game.firstToMaxRole !== undefined) { + // Someone reached MAX_TAPS — first to reach it wins + winnerRole = game.firstToMaxRole; + } else if (game.organizerScore > game.joinerScore) { winnerRole = EPlayerRole.Organizer; } else if (game.joinerScore > game.organizerScore) { winnerRole = EPlayerRole.Joiner; diff --git a/backend/src/types/websocket/base.ts b/backend/src/types/websocket/base.ts index e26e959..3d7634b 100644 --- a/backend/src/types/websocket/base.ts +++ b/backend/src/types/websocket/base.ts @@ -23,6 +23,8 @@ export enum EWSMessageType { // Drum Game (Bidirectional) DrumReady = 'DRUM_READY', DrumStart = 'DRUM_START', + DrumStartRequest = 'DRUM_START_REQUEST', // Client → Server + DrumPlayerReady = 'DRUM_PLAYER_READY', // Server → Client DrumTap = 'DRUM_TAP', DrumFinish = 'DRUM_FINISH', DrumResult = 'DRUM_RESULT', diff --git a/backend/src/types/websocket/drum.ts b/backend/src/types/websocket/drum.ts index 637ad98..9cf6a91 100644 --- a/backend/src/types/websocket/drum.ts +++ b/backend/src/types/websocket/drum.ts @@ -83,6 +83,34 @@ export interface IDrumResultData { winnerRole: EPlayerRole; } +// ==================== Client → Server ==================== + +/** + * DRUM_START_REQUEST: Client requests to start the drum game + * Sent after both players have navigated to the drum room + */ +export interface IDrumStartRequestMessage extends IWSMessage { + type: EWSMessageType.DrumStartRequest; +} + +export interface IDrumStartRequestData { + roomId: string; + userId: string; +} + +/** + * DRUM_PLAYER_READY: A player has signalled ready (but game not yet started) + * Broadcast to all participants so UI can show waiting state + */ +export interface IDrumPlayerReadyMessage extends IWSMessage { + type: EWSMessageType.DrumPlayerReady; +} + +export interface IDrumPlayerReadyData { + roomId: string; + readyCount: number; // How many players are ready so far (1 or 2) +} + // ==================== Bidirectional ==================== /** diff --git a/docs/backend/features/06-drum-game.md b/docs/backend/features/06-drum-game.md index 1e397cc..93a5fab 100644 --- a/docs/backend/features/06-drum-game.md +++ b/docs/backend/features/06-drum-game.md @@ -31,17 +31,21 @@ WAITING → COUNTDOWN → RUNNING → FINISHED │ ↓ │ │ 服务器发送 DRUM_READY(时间同步 + 玩家信息) │ │ ↓ │ -│ 服务器发送 DRUM_START(游戏开始时间戳) │ +│ 双方各自点击「开始游戏」,发送 DRUM_START_REQUEST │ +│ ↓ │ +│ 每有一方就绪,服务器广播 DRUM_PLAYER_READY(readyCount) │ +│ ↓ │ +│ 双方均就绪后,服务器发送 DRUM_START(游戏开始时间戳) │ │ ↓ │ │ 倒计时 3 秒 (COUNTDOWN 阶段) │ │ ↓ │ -│ 游戏开始 (RUNNING 阶段,持续 10 秒) │ +│ 游戏开始 (RUNNING 阶段,持续 10 秒,上限 60 次点击) │ │ ↓ │ │ 服务器发送 DRUM_FINISH(游戏结束) │ │ ↓ │ │ 服务器发送 DRUM_RESULT(最终结果) │ │ ↓ │ -│ 游戏清理 (FINISHED 阶段) │ +│ 结果展示 (RESULT 阶段,持续 5 秒后跳转聊天室) │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -49,11 +53,13 @@ WAITING → COUNTDOWN → RUNNING → FINISHED ``` T+0s : 房间满员,触发等待室倒计时 (3s) -T+3s : 发送 DRUM_READY + DRUM_START -T+3s~6s : 游戏倒计时 (3s) -T+6s : 游戏开始 (RUNNING) -T+6s~16s : 游戏进行中 (10s) -T+16s : 游戏结束,发送 DRUM_FINISH + DRUM_RESULT +T+3s : 发送 DRUM_READY;双方看到「开始游戏」按钮 +T+3s~? : 等待双方均点击「开始游戏」(DRUM_PLAYER_READY 广播就绪数) +T+N : 双方均就绪,发送 DRUM_START +T+N~N+3s : 游戏倒计时 (3s) +T+N+3s : 游戏开始 (RUNNING) +T+N+13s : 游戏结束,发送 DRUM_FINISH + DRUM_RESULT +T+N+13s~N+18s : 结果展示 (5s),跳转聊天室 ``` --- @@ -64,11 +70,13 @@ T+16s : 游戏结束,发送 DRUM_FINISH + DRUM_RESULT ```typescript enum EWSMessageType { - DrumReady = 'DRUM_READY', // 服务器 → 客户端:房间就绪 - DrumStart = 'DRUM_START', // 服务器 → 客户端:游戏开始 - DrumTap = 'DRUM_TAP', // 双向:点击事件 - DrumFinish = 'DRUM_FINISH', // 服务器 → 客户端:游戏结束 - DrumResult = 'DRUM_RESULT', // 服务器 → 客户端:最终结果 + DrumReady = 'DRUM_READY', // 服务器 → 客户端:房间就绪 + DrumPlayerReady = 'DRUM_PLAYER_READY', // 服务器 → 客户端:玩家就绪广播 + DrumStartRequest = 'DRUM_START_REQUEST',// 客户端 → 服务器:玩家请求开始 + DrumStart = 'DRUM_START', // 服务器 → 客户端:游戏开始 + DrumTap = 'DRUM_TAP', // 双向:点击事件 + DrumFinish = 'DRUM_FINISH', // 服务器 → 客户端:游戏结束 + DrumResult = 'DRUM_RESULT', // 服务器 → 客户端:最终结果 } ``` @@ -114,6 +122,46 @@ interface IDrumReadyMessage { --- +### DRUM_PLAYER_READY(服务器 → 客户端) + +**触发时机**: 每有一名玩家发送 `DRUM_START_REQUEST` 时广播 + +**用途**: 通知双方当前就绪人数,用于更新「等待对方准备」状态文案 + +**消息格式**: +```typescript +interface IDrumPlayerReadyMessage { + type: 'DRUM_PLAYER_READY'; + data: { + roomId: string; + readyCount: number; // 当前已就绪玩家数(1 或 2) + }; + timestamp: number; +} +``` + +--- + +### DRUM_START_REQUEST(客户端 → 服务器) + +**触发时机**: 玩家点击「开始游戏」按钮 + +**用途**: 告知服务器本玩家已就绪,等双方均就绪后服务器发送 DRUM_START + +**消息格式**: +```typescript +interface IDrumStartRequestMessage { + type: 'DRUM_START_REQUEST'; + data: { + roomId: string; + userId: string; + }; + timestamp: number; +} +``` + +--- + ### DRUM_START(服务器 → 客户端) **触发时机**: DRUM_READY 之后立即发送 @@ -350,6 +398,8 @@ export const DRUM_CONFIG = { | WAITING_ROOM_CONFIG.COUNTDOWN_MS | 3000ms | 房间满员后到游戏开始的等待时间 | | DRUM_CONFIG.COUNTDOWN_MS | 3000ms | 游戏开始前的倒计时 | | DRUM_CONFIG.GAME_DURATION_MS | 10000ms | 游戏进行时间 | +| RESULT_DISPLAY_MS(前端)| 5000ms | 结果展示时长,之后跳转聊天室 | +| MAX_TAPS(前端)| 60 | 单局最大有效点击次数上限 | --- @@ -545,6 +595,12 @@ calculateResult(roomId: string): IDrumGameResult | undefined { │<──DRUM_READY──────│───DRUM_READY─────>│ │ (时间同步) │ │ │ │ │ + │───START_REQUEST──>│ │ + │ │───PLAYER_READY───>│ (readyCount=1) + │<──PLAYER_READY────│ │ + │ │<──START_REQUEST───│ + │<──PLAYER_READY────│───PLAYER_READY───>│ (readyCount=2) + │ │ │ │<──DRUM_START──────│───DRUM_START─────>│ │ (开始时间戳) │ │ │ │ │ @@ -566,6 +622,9 @@ calculateResult(roomId: string): IDrumGameResult | undefined { │<──DRUM_RESULT─────│───DRUM_RESULT────>│ │ (最终结果) │ │ │ │ │ + │ [5秒结果展示] │ │ + │ → 跳转聊天室 │ │ + │ │ │ ``` --- diff --git a/docs/miniprogram/chat-room.md b/docs/miniprogram/chat-room.md index ffb5dce..e70178b 100644 --- a/docs/miniprogram/chat-room.md +++ b/docs/miniprogram/chat-room.md @@ -2,10 +2,10 @@ 基于《Chat Room(对簿公堂)功能 PRD v1.0》梳理的实现文档,对应页面代码位于: -- `miniprogram/pages/chat-room/index.json` -- `miniprogram/pages/chat-room/index.wxml` -- `miniprogram/pages/chat-room/index.wxss` -- `miniprogram/pages/chat-room/index.ts` +- `miniprogram/packageB/pages/chat-room/index.json` +- `miniprogram/packageB/pages/chat-room/index.wxml` +- `miniprogram/packageB/pages/chat-room/index.wxss` +- `miniprogram/packageB/pages/chat-room/index.ts` 本文档用于在产品、设计、前端之间对齐「Chat Room」的目标、布局和交互细节。 @@ -13,15 +13,15 @@ ## 1. 页面基本信息 -| 项目 | 说明 | -| -------- | ------------------------------ | -| 页面名称 | Chat Room(对簿公堂) | -| 页面路径 | `/pages/chat-room/index` | -| 页面类型 | 核心对簿与情绪释放页面 | -| 进入方式 | Drum Room 结束后自动跳转 | -| 退出方式 | 双方完成发言后跳转至 AI 分析页 | -| 设计风格 | 强舞台感、娱乐化、视觉即状态 | -| 优先级 | P0(主流程核心页面) | +| 项目 | 说明 | +| -------- | --------------------------------- | +| 页面名称 | Chat Room(对簿公堂) | +| 页面路径 | `/packageB/pages/chat-room/index` | +| 页面类型 | 核心对簿与情绪释放页面 | +| 进入方式 | Drum Room 结束后自动跳转 | +| 退出方式 | 双方完成发言后跳转至 AI 分析页 | +| 设计风格 | 强舞台感、娱乐化、视觉即状态 | +| 优先级 | P0(主流程核心页面) | --- @@ -291,36 +291,24 @@ type ChatRoomState = ### 9.1 消息类型 -```typescript -// 发言开始 -{ - type: 'speech:start', - userId: string, - timestamp: number, -} - -// 发言结束 -{ - type: 'speech:end', - userId: string, - audioUrl: string, - duration: number, -} - -// 表情发送 -{ - type: 'emoji:send', - userId: string, - emoji: string, -} - -// 状态同步 -{ - type: 'state:sync', - currentSpeaker: string, - remainingTime: number, -} -``` +**客户端 → 服务器**: + +| 消息类型 | 说明 | +| ----------------- | ---------------------------------------------- | +| `CHAT_SEND` | 发送文本消息(调试/测试用) | +| `ASR_TEXT_PUSH` | 推送 ASR 识别文本(partial 节流 + final 即时) | +| `EMOJI_SEND` | 发送表情互动 | +| `SPEECH_TURN_END` | 通知服务器本玩家发言结束 | + +**服务器 → 客户端**: + +| 消息类型 | 说明 | +| -------------------- | ------------------------------ | +| `CHAT_RECEIVE` | 接收对方文本消息 | +| `ASR_TEXT` | 接收对方的 ASR 实时文本 | +| `EMOJI_RECEIVE` | 接收对方发送的表情互动 | +| `SPEECH_TURN_SWITCH` | 第一位发言者结束,通知切换轮次 | +| `CHAT_COMPLETE` | 双方均已结束,触发 AI 判决生成 | ### 9.2 生命周期管理 @@ -523,10 +511,10 @@ this.initSpeechRecognitionCallbacks(); ## 16. 相关文件一览 - **页面实现**: - - 结构: `miniprogram/pages/chat-room/index.wxml` - - 样式: `miniprogram/pages/chat-room/index.wxss` - - 逻辑: `miniprogram/pages/chat-room/index.ts` - - 配置: `miniprogram/pages/chat-room/index.json` + - 结构: `miniprogram/packageB/pages/chat-room/index.wxml` + - 样式: `miniprogram/packageB/pages/chat-room/index.wxss` + - 逻辑: `miniprogram/packageB/pages/chat-room/index.ts` + - 配置: `miniprogram/packageB/pages/chat-room/index.json` - **服务层**: - WebSocket 管理: `miniprogram/services/websocket-manager.ts` - Chat 服务: `miniprogram/services/chat-service.ts` diff --git a/docs/miniprogram/components.md b/docs/miniprogram/components.md index da2b094..dc08720 100644 --- a/docs/miniprogram/components.md +++ b/docs/miniprogram/components.md @@ -364,6 +364,68 @@ if (radarChart) { --- +### 8. Notification(通知弹窗组件) + +- **文件路径**: `miniprogram/components/notification/` +- **组件名称**: `notification` +- **功能**: 带遮罩的居中弹窗组件,支持标题、内容、状态文案和可选操作按钮 + +#### 属性(Properties) + +| 属性名 | 类型 | 默认值 | 必填 | 说明 | +| -------------------- | ------- | ------ | ---- | ------------------------------------ | +| `isOpen` | Boolean | false | 否 | 是否显示弹窗 | +| `title` | String | '' | 否 | 标题文案 | +| `content` | String | '' | 否 | 正文内容 | +| `statusText` | String | '' | 否 | 状态提示文案(如等待信息、结果摘要) | +| `buttonText` | String | '' | 否 | 操作按钮文案;为空时不显示按钮 | +| `disabledAfterClick` | Boolean | false | 否 | 按钮点击后是否禁用(防重复提交) | + +#### 事件(Events) + +| 事件名 | 说明 | 回调参数 | +| ----------- | ------------------ | -------- | +| `close` | 点击关闭按钮时触发 | - | +| `buttonTap` | 点击操作按钮时触发 | - | + +#### 动画效果 + +- 打开:盒子从 `scale(0.9) + opacity(0)` 淡入到正常(300ms,ease-out,`wx.createAnimation`) +- 关闭:反向淡出(300ms,ease-in) + +#### 使用示例 + +```xml + + + + + +``` + +#### 使用场景 + +- Drum Room 游戏规则说明(等待双方就绪) +- Drum Room 游戏结果展示(5 秒后自动跳转) +- 其他需要居中提示并可带操作按钮的场景 + +--- + ## 组件开发规范 ### 文件结构 @@ -421,13 +483,14 @@ Component({ - `miniprogram/components/countdown/` - 倒计时组件 - `miniprogram/components/styled-title/` - 样式化标题 - `miniprogram/components/avatar/` - 头像组件 + - `miniprogram/components/notification/` - 通知弹窗 - `miniprogram/components/radar-chart/` - 六维战力雷达图 - `miniprogram/components/secret-modal/` - 密折弹窗 - `miniprogram/components/post-game-effect/` - 赛后互动特效 - **使用示例**: - `miniprogram/pages/welcome/index.wxml` - 使用 styled-button、styled-title - `miniprogram/packageA/pages/waiting-room/index.wxml` - 使用 styled-button、countdown、avatar - - `miniprogram/packageA/pages/drum-room/index.wxml` - 使用 countdown + - `miniprogram/packageA/pages/drum-room/index.wxml` - 使用 countdown、notification - `miniprogram/packageB/pages/verdict/index.wxml` - 使用 styled-button、radar-chart、secret-modal、post-game-effect - **开发规范**: - `../../CLAUDE.md` - 项目开发规范 diff --git a/docs/miniprogram/services.md b/docs/miniprogram/services.md index 92fa15e..58c2e79 100644 --- a/docs/miniprogram/services.md +++ b/docs/miniprogram/services.md @@ -144,6 +144,7 @@ export const nicknameService = new NicknameService(); - `startListening()`: 提前监听消息(在 waiting-room 调用) - `initialize(options: IDrumServiceOptions)`: 设置回调并处理队列 +- `sendStartRequest(userId)`: 发送 DRUM_START_REQUEST(玩家点击「开始游戏」时调用) - `queueTap()`: 点击入队(节流批量发送) - `flushPendingTaps()`: 立即发送积压点击 - `cleanup()`: 清理计时器与回调 @@ -161,6 +162,7 @@ interface IDrumServiceOptions { joinerName, receivedAtMs ) => void; + onPlayerReady: (readyCount: number) => void; onStart: (startAtMs) => void; onTap: (role, delta) => void; onFinish: () => void; @@ -171,8 +173,8 @@ interface IDrumServiceOptions { **消息类型**: -- 发送: `DRUM_TAP` -- 接收: `DRUM_READY / DRUM_START / DRUM_TAP / DRUM_FINISH / DRUM_RESULT` +- 发送: `DRUM_TAP`, `DRUM_START_REQUEST` +- 接收: `DRUM_READY / DRUM_PLAYER_READY / DRUM_START / DRUM_TAP / DRUM_FINISH / DRUM_RESULT` **消息队列机制**: diff --git a/docs/miniprogram/waiting-room.md b/docs/miniprogram/waiting-room.md index 3873aba..9f81caf 100644 --- a/docs/miniprogram/waiting-room.md +++ b/docs/miniprogram/waiting-room.md @@ -2,10 +2,10 @@ 基于《页面级 PRD|房间创建 & 等待页(Waiting Room)》梳理的实现文档,对应页面代码位于: -- `miniprogram/pages/waiting-room/index.json` -- `miniprogram/pages/waiting-room/index.wxml` -- `miniprogram/pages/waiting-room/index.wxss` -- `miniprogram/pages/waiting-room/index.ts` +- `miniprogram/packageA/pages/waiting-room/index.json` +- `miniprogram/packageA/pages/waiting-room/index.wxml` +- `miniprogram/packageA/pages/waiting-room/index.wxss` +- `miniprogram/packageA/pages/waiting-room/index.ts` 本文档用于在产品、设计、前端之间对齐「等待页」的目标、布局和交互细节。 @@ -16,7 +16,7 @@ | 项目 | 说明 | | -------- | ---------------------------------------- | | 页面名称 | 房间创建 / 等待页(Waiting Room) | -| 页面路径 | `/pages/waiting-room/index` | +| 页面路径 | `/packageA/pages/waiting-room/index` | | 页面类型 | 状态等待页(房间未满) | | 进入方式 | 创建房间成功后自动进入 | | 退出方式 | 取消审判 / 房间失效 / 对方进入后自动跳转 | @@ -438,10 +438,10 @@ roomWebSocketService.initialize((room: IRoom) => { ## 13. 相关文件一览 - **页面实现**: - - 结构:`miniprogram/pages/waiting-room/index.wxml` - - 样式:`miniprogram/pages/waiting-room/index.wxss` - - 逻辑:`miniprogram/pages/waiting-room/index.ts` - - 配置:`miniprogram/pages/waiting-room/index.json` + - 结构:`miniprogram/packageA/pages/waiting-room/index.wxml` + - 样式:`miniprogram/packageA/pages/waiting-room/index.wxss` + - 逻辑:`miniprogram/packageA/pages/waiting-room/index.ts` + - 配置:`miniprogram/packageA/pages/waiting-room/index.json` - **组件**: - 倒计时组件:`miniprogram/components/countdown/` - 样式化按钮:`miniprogram/components/styled-button/` diff --git a/miniprogram/components/notification/index.json b/miniprogram/components/notification/index.json new file mode 100644 index 0000000..c4f60a6 --- /dev/null +++ b/miniprogram/components/notification/index.json @@ -0,0 +1,7 @@ +{ + "component": true, + "usingComponents": { + "styled-button": "../styled-button/index" + }, + "styleIsolation": "apply-shared" +} diff --git a/miniprogram/components/notification/index.ts b/miniprogram/components/notification/index.ts new file mode 100644 index 0000000..0da84df --- /dev/null +++ b/miniprogram/components/notification/index.ts @@ -0,0 +1,91 @@ +// components/notification/index.ts + +export type AnimationResult = WechatMiniprogram.AnimationExportResult; + +const FADE_DURATION: number = 300; + +Component({ + properties: { + isOpen: { + type: Boolean, + value: false, + }, + title: { + type: String, + value: '', + }, + content: { + type: String, + value: '', + }, + statusText: { + type: String, + value: '', + }, + statusTextFontSize: { + type: String, + value: '', + }, + buttonText: { + type: String, + value: '', + }, + disabledAfterClick: { + type: Boolean, + value: false, + }, + }, + + data: { + boxAnimation: {} as AnimationResult, + buttonDisabled: false, + }, + + observers: { + isOpen(val: boolean): void { + if (val) { + this.setData({ buttonDisabled: false }); + this.fadeIn(); + } else { + this.fadeOut(); + } + }, + }, + + methods: { + fadeIn(): void { + const initAnim: WechatMiniprogram.Animation = wx.createAnimation({ + duration: 0, + }); + initAnim.opacity(0).scale(0.9).step(); + this.setData({ boxAnimation: initAnim.export() }, () => { + const anim: WechatMiniprogram.Animation = wx.createAnimation({ + duration: FADE_DURATION, + timingFunction: 'ease-out', + }); + anim.opacity(1).scale(1).step(); + this.setData({ boxAnimation: anim.export() }); + }); + }, + + fadeOut(): void { + const anim: WechatMiniprogram.Animation = wx.createAnimation({ + duration: FADE_DURATION, + timingFunction: 'ease-in', + }); + anim.opacity(0).scale(0.9).step(); + this.setData({ boxAnimation: anim.export() }); + }, + + onCloseTap(): void { + this.triggerEvent('close'); + }, + + onButtonTap(): void { + if (this.properties.disabledAfterClick) { + this.setData({ buttonDisabled: true }); + } + this.triggerEvent('buttonTap'); + }, + }, +}); diff --git a/miniprogram/components/notification/index.wxml b/miniprogram/components/notification/index.wxml new file mode 100644 index 0000000..86c77cb --- /dev/null +++ b/miniprogram/components/notification/index.wxml @@ -0,0 +1,49 @@ + + + + + + × + + + + + {{ title }} + + + + + {{ content }} + + + + + {{ statusText }} + + + + + + + + diff --git a/miniprogram/components/notification/index.wxss b/miniprogram/components/notification/index.wxss new file mode 100644 index 0000000..28dd3f1 --- /dev/null +++ b/miniprogram/components/notification/index.wxss @@ -0,0 +1,83 @@ +/* components/notification/index.wxss */ + +.notification { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.notification__box { + position: relative; + width: 650rpx; + background-color: #ffffff; + border-radius: 32rpx; + border: 8rpx solid #000000; + padding: 60rpx 40rpx; + box-sizing: border-box; + box-shadow: 0 10rpx 0 0 #000000; + display: flex; + flex-direction: column; + align-items: center; +} + +.notification__close { + position: absolute; + top: 20rpx; + right: 30rpx; + width: 60rpx; + height: 60rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.notification__close-icon { + font-size: 56rpx; + color: #666666; + font-weight: 300; + line-height: 1; +} + +.notification__title { + width: 100%; + margin-bottom: 32rpx; +} + +.notification__title-text { + display: block; + text-align: center; + font-size: 56rpx; + font-weight: 700; + color: #e53935; +} + +.notification__content-text { + display: block; + font-size: 32rpx; + color: #555555; + line-height: 1.6; + text-align: center; +} + +.notification__status { + margin-top: 24rpx; +} + +.notification__status-text { + display: block; + text-align: center; + font-size: 28rpx; + color: black; +} + +.notification__button { + margin-top: 40rpx; + width: 100%; +} diff --git a/miniprogram/constants/verdict-waiting.ts b/miniprogram/constants/verdict-waiting.ts index 40ade99..e5464fa 100644 --- a/miniprogram/constants/verdict-waiting.ts +++ b/miniprogram/constants/verdict-waiting.ts @@ -43,8 +43,8 @@ export const TEXT_POOL_SIZE: number = 15; /** 文案出现间隔(毫秒) */ export const TEXT_INTERVAL_MS: number = 3000; -/** 卡片内最大可见文案条数(大于可视区域,让旧文案自然被 overflow:hidden 裁掉) */ -export const MAX_VISIBLE_TEXTS: number = 20; +/** 卡片内最大可见文案条数(滑动窗口:超过后移除第一条,末尾追加新条) */ +export const MAX_VISIBLE_TEXTS: number = 10; /** AI 分析超时时间(毫秒) */ export const ANALYSIS_TIMEOUT_MS: number = 90000; diff --git a/miniprogram/packageA/pages/drum-room/index.json b/miniprogram/packageA/pages/drum-room/index.json index 71a4261..6348d52 100644 --- a/miniprogram/packageA/pages/drum-room/index.json +++ b/miniprogram/packageA/pages/drum-room/index.json @@ -1,6 +1,7 @@ { "usingComponents": { - "countdown": "/components/countdown/index" + "countdown": "/components/countdown/index", + "notification": "/components/notification/index" }, "navigationBarTitleText": "震天鼓抢麦", "disableScroll": true, diff --git a/miniprogram/packageA/pages/drum-room/index.ts b/miniprogram/packageA/pages/drum-room/index.ts index cfd3833..3cc2403 100644 --- a/miniprogram/packageA/pages/drum-room/index.ts +++ b/miniprogram/packageA/pages/drum-room/index.ts @@ -16,6 +16,7 @@ import { DEFAULT_JOINER_NAME, } from '../../../constants/defaultValue'; import { drumService } from '../../../services/drum-service'; +import { nicknameService } from '../../../services/nickname-service'; import { EPlayerRole } from '../../../types/websocket-common'; import { playDrumSound, destroyAudioPool } from '../../../utils/audio'; import { vibrateShort, vibrateLong } from '../../../utils/haptic'; @@ -83,7 +84,18 @@ interface IDrumPageData { winnerRole: EPlayerRole | null; resultTitle: string; resultSubtitle: string; + resultScoreText: string; resultVisible: boolean; + + // Rule notification + showRuleNotification: boolean; + selfReady: boolean; + readyCount: number; + + // Hint notification + showHintNotification: boolean; + hintNotificationContent: string; + maxTaps: number; } /** Page options interface (from waiting-room navigation) */ @@ -102,6 +114,7 @@ interface PrivateState { _runningTimer: ReturnType | null; _flyTextTimer: ReturnType | null; _resultTimer: ReturnType | null; + _hintTimer: ReturnType | null; _lastShakeTime: number; } @@ -114,7 +127,7 @@ function getScoreKey(role: EPlayerRole): 'organizerScore' | 'joinerScore' { /** Game timing constants */ // const PREPARE_DURATION_MS: number = 3000; const RUNNING_DURATION_MS: number = 10000; -const RESULT_DISPLAY_MS: number = 2000; +const RESULT_DISPLAY_MS: number = 3000; const FLY_TEXT_DURATION_MS: number = 800; const MAX_TAPS: number = 60; const MAX_SCORE_FOR_PROGRESS: number = MAX_TAPS; @@ -149,7 +162,16 @@ Page({ winnerRole: null, resultTitle: '', resultSubtitle: '', + resultScoreText: '', resultVisible: false, + + showRuleNotification: false, + selfReady: false, + readyCount: 0, + + showHintNotification: true, + hintNotificationContent: '等待开始...', + maxTaps: MAX_TAPS, }, // Private state (not in data) @@ -158,6 +180,7 @@ Page({ _runningTimer: null, _flyTextTimer: null, _resultTimer: null, + _hintTimer: null, _lastShakeTime: 0, /** @@ -166,9 +189,6 @@ Page({ onLoad(options: IDrumPageOptions): void { logger.log('DrumRoom', 'onLoad', options); - // TODO:Initialize audio pool - // initAudioPool(); - // Parse options from previous page (waiting-room) const roomId: string = options.roomId || 'room-001'; const selfRole: EPlayerRole = options.selfRole || EPlayerRole.Organizer; @@ -218,6 +238,9 @@ Page({ receivedAtMs ); }, + onPlayerReady: (readyCount: number) => { + this._handlePlayerReady(readyCount); + }, onStart: (startAtMs: number) => { this._handleDrumStart(startAtMs); }, @@ -269,9 +292,44 @@ Page({ timeSinceStart: nowServerMs() - this._startAtMs, remainingToEnd: getTimeRemainingMs(this._endAtMs), }); + this.setData({ showRuleNotification: false }); this._startRunningPhase(); }, + onRuleNotificationClose(): void { + this.setData({ showRuleNotification: false }); + }, + + onHintNotificationClose(): void { + this._clearTimer('_hintTimer'); + this.setData({ showHintNotification: false }); + }, + + onResultNotificationClose(): void { + // Close is ignored — navigation will happen automatically after delay + }, + + /** + * Handle "开始游戏" button tap in rule notification + * Sends DRUM_START_REQUEST; game starts when both players tap + */ + onStartGameTap(): void { + if (this.data.selfReady) { + return; + } + const userId: string = nicknameService.getUserId(); + drumService.sendStartRequest(userId); + this.setData({ selfReady: true }); + }, + + /** + * Handle DRUM_PLAYER_READY broadcast from server + * Updates readyCount so statusText reflects how many players are ready + */ + _handlePlayerReady(readyCount: number): void { + this.setData({ readyCount }); + }, + /** * Start 10-second running (tapping) phase */ @@ -291,8 +349,16 @@ Page({ tapEnabled: true, runningLeftSec: Math.ceil(RUNNING_DURATION_MS / 1000), runningLeftMs: RUNNING_DURATION_MS, + showHintNotification: true, + hintNotificationContent: + '击鼓抢麦:在接下来的10秒内请疯狂点击!谁点得多谁先发言!', + maxTaps: MAX_TAPS, }); + this._hintTimer = setTimeout(() => { + this.setData({ showHintNotification: false }); + }, 2500); + // Track previous second to detect changes let prevSec: number = Math.ceil(RUNNING_DURATION_MS / 1000); @@ -358,6 +424,9 @@ Page({ ? '你先申冤!' : '先听对方说吧'; + const { organizerScore, joinerScore } = this.data; + const resultScoreText: string = ` ${organizerScore} vs ${joinerScore}`; + vibrateLong(); this.setData({ @@ -365,6 +434,7 @@ Page({ winnerRole, resultTitle, resultSubtitle, + resultScoreText, resultVisible: true, }); @@ -382,6 +452,7 @@ Page({ const url: string = `/packageB/pages/chat-room/index?roomCode=${roomId}` + `&role=${chatRole}` + + `&originalRole=${selfRole}` + `&opponentName=${encodeURIComponent(opponentName)}`; wx.redirectTo({ url, @@ -564,7 +635,11 @@ Page({ * Clear a specific timer */ _clearTimer( - timerName: '_runningTimer' | '_flyTextTimer' | '_resultTimer' + timerName: + | '_runningTimer' + | '_flyTextTimer' + | '_resultTimer' + | '_hintTimer' ): void { if (this[timerName] !== null) { clearInterval(this[timerName]); @@ -580,6 +655,7 @@ Page({ this._clearTimer('_runningTimer'); this._clearTimer('_flyTextTimer'); this._clearTimer('_resultTimer'); + this._clearTimer('_hintTimer'); }, /** @@ -605,12 +681,15 @@ Page({ // This avoids queue delay affecting the offset calculation setServerTimeOffset(serverTimeMs, receivedAtMs); - // Update player info from server + // Update player info from server and show rule notification this.setData({ hostRole, organizerName, joinerName, phase: 'PREPARE_COUNTDOWN', + showRuleNotification: true, + selfReady: false, + showHintNotification: false, }); logger.log('DrumRoom', 'Server time synced, waiting for DRUM_START...'); @@ -632,6 +711,9 @@ Page({ this._startAtMs = startAtMs; this._endAtMs = startAtMs + RUNNING_DURATION_MS; + // Both players ready — close rule notification and start countdown + this.setData({ showRuleNotification: false }); + // Start countdown component const countdown: WechatMiniprogram.Component.TrivialInstance | null = this.selectComponent('#countdown'); diff --git a/miniprogram/packageA/pages/drum-room/index.wxml b/miniprogram/packageA/pages/drum-room/index.wxml index 8e32ca9..ea55bb7 100644 --- a/miniprogram/packageA/pages/drum-room/index.wxml +++ b/miniprogram/packageA/pages/drum-room/index.wxml @@ -17,9 +17,6 @@ - - 👤 - {{ organizerName }} {{ organizerScore }} @@ -35,9 +32,6 @@ - - 👤 - {{ joinerName }} {{ joinerScore }} @@ -72,16 +66,6 @@ - - - - 10秒内疯狂点击!谁点得多谁先冤! - - - 等待开始... - - - - - - - - {{ resultTitle }} - {{ resultSubtitle }} - - {{ organizerName }}: {{ organizerScore }} - - - {{ joinerName }}: {{ joinerScore }} - - - + + + + + diff --git a/miniprogram/packageA/pages/drum-room/index.wxss b/miniprogram/packageA/pages/drum-room/index.wxss index f51ebf9..55dd7b8 100644 --- a/miniprogram/packageA/pages/drum-room/index.wxss +++ b/miniprogram/packageA/pages/drum-room/index.wxss @@ -70,7 +70,7 @@ .drum-header__timer-value { font-size: 56rpx; font-weight: 900; - color: #FFE66D; + color: #ffe66d; text-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.3); } @@ -82,7 +82,7 @@ .drum-header__timer-value--urgent { font-size: 72rpx; - color: #FF3B3B; + color: #ff3b3b; text-shadow: 0 0 20rpx rgba(255, 59, 59, 0.8), 0 4rpx 8rpx rgba(0, 0, 0, 0.4); @@ -97,7 +97,8 @@ align-items: flex-start; justify-content: space-between; width: 100%; - padding: 0 20rpx;} + padding: 0 20rpx; +} .drum-score__player { display: flex; @@ -114,22 +115,6 @@ align-items: flex-end; } -.drum-score__avatar { - width: 80rpx; - height: 80rpx; - border-radius: 50%; - background: rgba(255, 255, 255, 0.25); - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 12rpx; - border: 4rpx solid rgba(255, 255, 255, 0.4); -} - -.drum-score__avatar-emoji { - font-size: 40rpx; -} - .drum-score__name { font-size: 26rpx; color: #ffffff; @@ -163,11 +148,11 @@ } .drum-score__progress-bar--a { - background: linear-gradient(90deg, #4D96FF, #6DB3F2); + background: linear-gradient(90deg, #4d96ff, #6db3f2); } .drum-score__progress-bar--b { - background: linear-gradient(90deg, #FF006E, #FF4D8D); + background: linear-gradient(90deg, #ff006e, #ff4d8d); } .drum-score__vs { @@ -180,7 +165,7 @@ .drum-score__vs-text { font-size: 36rpx; font-weight: 900; - color: #FFE66D; + color: #ffe66d; text-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.4); } @@ -205,7 +190,7 @@ .drum-fly-text__content { font-size: 36rpx; font-weight: 700; - color: #FFE66D; + color: #ffe66d; text-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.5); } @@ -224,8 +209,13 @@ width: 280rpx; height: 280rpx; border-radius: 50%; - background: radial-gradient(circle at 30% 30%, #FF5252, #FF3B3B 40%, #B80000 100%); - border: 10rpx solid #FFD93D; + background: radial-gradient( + circle at 30% 30%, + #ff5252, + #ff3b3b 40%, + #b80000 100% + ); + border: 10rpx solid #ffd93d; display: flex; align-items: center; justify-content: center; @@ -247,8 +237,10 @@ .drum-button__text { font-size: 96rpx; font-weight: 900; - color: #FFD93D; - text-shadow: 0 6rpx 0 #8B0000, 0 8rpx 16rpx rgba(0, 0, 0, 0.4); + color: #ffd93d; + text-shadow: + 0 6rpx 0 #8b0000, + 0 8rpx 16rpx rgba(0, 0, 0, 0.4); } /* ======================================== @@ -307,91 +299,8 @@ .drum-overlay__hint { font-size: 32rpx; - color: #FFE66D; + color: #ffe66d; letter-spacing: 4rpx; margin-top: 30rpx; text-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.3); } - -/* ======================================== - Result Overlay - ======================================== */ -.drum-result { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.65); - display: flex; - align-items: center; - justify-content: center; - z-index: 200; - opacity: 0; - visibility: hidden; -} - -.drum-result--visible { - opacity: 1; - visibility: visible; -} - -.drum-result__card { - display: flex; - flex-direction: column; - align-items: center; - padding: 60rpx 80rpx; - background: linear-gradient(135deg, #2D2D2D 0%, #1A1A1A 100%); - border-radius: 32rpx; - border: 4rpx solid #FFD93D; - position: relative; - overflow: hidden; - box-shadow: - 0 0 60rpx rgba(255, 217, 61, 0.4), - 0 20rpx 40rpx rgba(0, 0, 0, 0.5); -} - -.drum-result__glow { - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: radial-gradient(circle, rgba(255, 217, 61, 0.15) 0%, transparent 50%); - pointer-events: none; -} - -.drum-result__title { - font-size: 48rpx; - font-weight: 900; - color: #FFD93D; - text-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.4); - margin-bottom: 16rpx; - z-index: 1; -} - -.drum-result__subtitle { - font-size: 32rpx; - color: rgba(255, 255, 255, 0.9); - margin-bottom: 30rpx; - z-index: 1; -} - -.drum-result__scores { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - z-index: 1; -} - -.drum-result__score { - font-size: 28rpx; - color: rgba(255, 255, 255, 0.8); -} - -.drum-result__score-divider { - font-size: 28rpx; - color: rgba(255, 255, 255, 0.5); - margin: 0 20rpx; -} diff --git a/miniprogram/packageB/pages/chat-room/index.json b/miniprogram/packageB/pages/chat-room/index.json index 6cfbfe3..1c5098d 100644 --- a/miniprogram/packageB/pages/chat-room/index.json +++ b/miniprogram/packageB/pages/chat-room/index.json @@ -1,4 +1,7 @@ { "navigationBarTitleText": "对簿公堂", - "usingComponents": {} + "disableScroll": true, + "usingComponents": { + "notification": "../../../components/notification/index" + } } diff --git a/miniprogram/packageB/pages/chat-room/index.ts b/miniprogram/packageB/pages/chat-room/index.ts index c1c275e..d7fe6e1 100644 --- a/miniprogram/packageB/pages/chat-room/index.ts +++ b/miniprogram/packageB/pages/chat-room/index.ts @@ -3,6 +3,7 @@ */ import { asrService } from '../../../services/asr-service'; +import { nicknameService } from '../../../services/nickname-service'; import { stsService } from '../../../services/sts-service'; import { wsManager } from '../../../services/websocket-manager'; import type { IEmojiReceiveData } from '../../../types/emoji-websocket'; @@ -40,6 +41,11 @@ interface IReaction { } interface IChatRoomPageData { + // 告状须知弹窗 + showNotification: boolean; + // 提前结束确认弹窗 + showEndEarlyNotification: boolean; + // 房间标识 roomCode: string; @@ -51,7 +57,9 @@ interface IChatRoomPageData { // 派生权限 canSpeak: boolean; - canReact: boolean; + + // 切换提示 + showSwitchNotification: boolean; // 倒计时状态 countdownClass: 'normal' | 'warn' | 'danger'; @@ -85,6 +93,10 @@ interface IChatRoomPageData { listenerHint: string; // 对方昵称 opponentName: string; + // 原告(Organizer)昵称 + hostName: string; + // 被告(Joiner)昵称 + guestName: string; } type TSpeakerFinal = Pick; @@ -94,33 +106,36 @@ type TSpeakerLive = Pick; interface IChatRoomCustomOption extends WechatMiniprogram.Page.CustomOption { timerId: number | null; listenerHintTimerId: number | null; + switchNotificationTimerId: number | null; reactionIdCounter: number; myReactionTimeouts: number[]; opponentReactionTimeouts: number[]; rpxToPx: number; asrManager: AsrManager; // 语音识别管理器 stsCredentials: ISTSCredentials | null; // STS 临时凭证 + _originalRole: EPlayerRole; // Room creator role (for verdict navigation) } const EMOJI_LIST = [ '😠', '😢', '❤️', - '🤔', + '💩', '😂', '😅', '🥺', - '💔', + '💨', '👍', '👎', - '🙄', - '😤', - '🤯', - '😭', + '🧎', + '💣', + '👄', + '🌹', + '🧧', ]; const MAX_REACTIONS = 3; -const REACTION_DURATION_MIN = 3000; -const REACTION_DURATION_MAX = 5000; +const REACTION_DURATION_MIN = 5000; +const REACTION_DURATION_MAX = 7000; const TOTAL_PER_TURN = 60; const PHASE_TRANSITION: Record = { [EPhase.SpeakerA]: EPhase.SpeakerB, @@ -143,6 +158,7 @@ function buildListenerHints(name: string): string[] { } const LISTENER_HINT_INTERVAL_MS = 10000; +const SWITCH_NOTIFICATION_DURATION_MS = 2000; /** * ASR 语音识别配置 @@ -156,6 +172,9 @@ const ASR_CONFIG = { Page({ data: { + showNotification: true, + showEndEarlyNotification: false, + roomCode: '', phase: EPhase.SpeakerA, @@ -164,7 +183,8 @@ Page({ totalPerTurn: TOTAL_PER_TURN, canSpeak: true, - canReact: false, + + showSwitchNotification: false, countdownClass: 'normal', @@ -194,16 +214,21 @@ Page({ listenerHint: '', // 对方昵称 opponentName: '', + // 原告/被告昵称 + hostName: '小美', + guestName: '大壮', }, timerId: null, listenerHintTimerId: null, + switchNotificationTimerId: null, reactionIdCounter: 0, myReactionTimeouts: [], opponentReactionTimeouts: [], rpxToPx: 0.5, asrManager: null, stsCredentials: null, + _originalRole: EPlayerRole.Organizer, onLoad(options): void { // 解析页面参数 @@ -212,9 +237,20 @@ Page({ options.role === EPlayerRole.Joiner ? EPlayerRole.Joiner : EPlayerRole.Organizer; + // originalRole: room creator role (independent of drum game result) + // Used for verdict navigation to match backend host/guest classification + this._originalRole = + options.originalRole === EPlayerRole.Joiner + ? EPlayerRole.Joiner + : EPlayerRole.Organizer; const opponentName: string = options.opponentName ? decodeURIComponent(options.opponentName) : DEFAULT_OPPONENT_NAME; + const selfName: string = nicknameService.getNickName(); + const hostName: string = + localRole === EPlayerRole.Organizer ? selfName : opponentName; + const guestName: string = + localRole === EPlayerRole.Joiner ? selfName : opponentName; // 校验 roomCode if (!roomCode) { void wx.showToast({ title: '房间号无效', icon: 'error' }); @@ -229,7 +265,6 @@ Page({ EPhase.SpeakerA, localRole ); - const canReact: boolean = !canSpeak; // 计算 rpx → px 换算比例 const sysInfo = wx.getSystemInfoSync(); @@ -250,11 +285,12 @@ Page({ remaining: TOTAL_PER_TURN, phase: EPhase.SpeakerA, canSpeak, - canReact, countdownClass: this.getCountdownClass(TOTAL_PER_TURN), emojiAnimations, listenerHint: canSpeak ? '' : this.pickListenerHint(opponentName), opponentName, + hostName, + guestName, }); // 非发言者启动提示文案轮播 @@ -304,6 +340,12 @@ Page({ // 清理提示文案轮播定时器 this.stopListenerHintRotation(); + // 清理切换提示定时器 + if (this.switchNotificationTimerId) { + clearTimeout(this.switchNotificationTimerId); + this.switchNotificationTimerId = null; + } + // 停止语音识别 if (this.asrManager && this.data.isRecording) { this.asrManager.stop(); @@ -386,6 +428,32 @@ Page({ } }, + /** + * 阻止触摸事件穿透 + */ + preventTouchMove(): void { + // 空函数,仅用于阻止事件冒泡 + }, + + /** + * 显示切换提示("下一位") + * 2 秒后自动隐藏 + */ + async showSwitchNotification(): Promise { + // 清理之前的定时器 + if (this.switchNotificationTimerId) { + clearTimeout(this.switchNotificationTimerId); + this.switchNotificationTimerId = null; + } + await wx.vibrateLong(); + this.setData({ showSwitchNotification: true }); + + this.switchNotificationTimerId = setTimeout(() => { + this.setData({ showSwitchNotification: false }); + this.switchNotificationTimerId = null; + }, SWITCH_NOTIFICATION_DURATION_MS) as unknown as number; + }, + /** * 初始化 ASR WebSocket 服务 * 处理 ASR 文本的 WebSocket 同步 @@ -740,8 +808,8 @@ Page({ tick(): void { const { remaining } = this.data; - // ≤10 秒震动 - if (remaining <= 10 && remaining > 0) { + // 最后 5 秒每秒震动 + if (remaining <= 5 && remaining > 0) { void wx.vibrateShort({ type: 'medium' }); } @@ -791,7 +859,6 @@ Page({ this.setData({ phase: EPhase.Done, canSpeak: false, - canReact: false, remaining: 0, isRecording: false, [liveKey]: '', // 仅清 live,final 保留 @@ -799,7 +866,6 @@ Page({ // 等待 CHAT_COMPLETE 消息触发跳转 } else { const nextCanSpeak = this.computeCanSpeak(nextPhase, localRole); - const canReact: boolean = !nextCanSpeak; // 不再发言时停止倒计时(由新发言者自行启动) if (!nextCanSpeak && this.timerId) { @@ -807,11 +873,13 @@ Page({ this.timerId = null; } + // 显示"下一位"切换提示 + this.showSwitchNotification(); + this.setData({ phase: nextPhase, remaining: totalPerTurn, canSpeak: nextCanSpeak, - canReact, countdownClass: this.getCountdownClass(totalPerTurn), isRecording: false, [liveKey]: '', // 仅清结束阶段的 live @@ -869,17 +937,18 @@ Page({ logger.log('ChatRoom', 'SPEECH_TURN_SWITCH → SpeakerB'); + // 显示"下一位"切换提示 + this.showSwitchNotification(); + const nextCanSpeak: boolean = this.computeCanSpeak( EPhase.SpeakerB, localRole ); - const canReact: boolean = !nextCanSpeak; this.setData({ phase: EPhase.SpeakerB, remaining: totalPerTurn, canSpeak: nextCanSpeak, - canReact, countdownClass: this.getCountdownClass(totalPerTurn), isRecording: false, speakerALive: '', // 清 Phase A live,final 保留 @@ -920,15 +989,16 @@ Page({ phase: EPhase.Done, isCompleted: true, canSpeak: false, - canReact: false, remaining: 0, isRecording: false, }); // 直接跳转到 verdict-waiting const roomId: string = this.data.roomCode; + // Use _originalRole (room creator role) so currentRole on verdict page + // matches backend host/guest classification (based on room.hostUserId) const role: string = - this.data.localRole === EPlayerRole.Organizer ? 'host' : 'guest'; + this._originalRole === EPlayerRole.Organizer ? 'host' : 'guest'; void wx.redirectTo({ url: `/packageB/pages/verdict-waiting/index` + @@ -936,6 +1006,13 @@ Page({ }); }, + /** + * 关闭告状须知弹窗 + */ + onNotificationDismiss(): void { + this.setData({ showNotification: false }); + }, + /** * 麦克风按下 * 首次按下时检查权限,权限通过后启动倒计时并开始录音 @@ -974,16 +1051,31 @@ Page({ }, /** - * 提前结束发言:跳过剩余倒计时,立即切换阶段 + * 提前结束发言:弹出确认弹窗 */ onEndEarlyTap(): void { if (!this.data.canSpeak) { return; } void wx.vibrateShort({ type: 'medium' }); + this.setData({ showEndEarlyNotification: true }); + }, + + /** + * 确认提前结束发言:关闭弹窗并切换阶段 + */ + onEndEarlyConfirm(): void { + this.setData({ showEndEarlyNotification: false }); this.switchPhase(); }, + /** + * 取消提前结束发言:关闭弹窗 + */ + onEndEarlyCancel(): void { + this.setData({ showEndEarlyNotification: false }); + }, + /** * 开始录音和语音识别 * 使用 QCloudAIVoice 插件的 start 方法,同时启动录音和识别 @@ -1077,7 +1169,7 @@ Page({ * 发送表情(自己点击表情时) */ onEmojiTap(e: WechatMiniprogram.TouchEvent): void { - if (!this.data.canReact) { + if (this.data.canSpeak) { return; } @@ -1191,6 +1283,8 @@ Page({ * 处理收到对方的表情 */ handleEmojiReceive(emoji: string): void { + void wx.vibrateShort({ type: 'heavy' }); + // 对方表情同屏最多 3 个 if (this.data.opponentReactions.length >= MAX_REACTIONS) { return; diff --git a/miniprogram/packageB/pages/chat-room/index.wxml b/miniprogram/packageB/pages/chat-room/index.wxml index d1e0920..324bb1a 100644 --- a/miniprogram/packageB/pages/chat-room/index.wxml +++ b/miniprogram/packageB/pages/chat-room/index.wxml @@ -3,7 +3,7 @@ Phase: SPEAKER_A -> SPEAKER_B -> DONE --> - + @@ -14,26 +14,6 @@ {{listenerHint}} - - - - - - 按住麦克风开始申冤 - - - 等待对方陈述... - - - - - - - - - - - @@ -68,49 +48,56 @@ scroll-with-animation="{{true}}" > - + - - {{speakerAFinal}}{{speakerALive}} + + {{hostName}}的诉状 - - 原告 + + {{speakerAFinal}}{{speakerALive}} - + + + ------------------我是分割线------------------ + + + - - {{speakerBFinal}}{{speakerBLive}} + + {{guestName}}的诉状 - - 被告 + + {{speakerBFinal}}{{speakerBLive}} - - + + 🎤 - @@ -118,22 +105,56 @@ - {{isRecording ? '松开结束' : (canSpeak ? '按住说话' : '等待发言')}} + {{isRecording ? '松开结束' : '按住说话'}} - - - - {{item}} + + + 用表情包宣泄你的不满吧,大老爷看不到的哦~ + + + {{item}} + + + + + + + + + + + + + + 下一位 diff --git a/miniprogram/packageB/pages/chat-room/index.wxss b/miniprogram/packageB/pages/chat-room/index.wxss index 7c17c82..5654c60 100644 --- a/miniprogram/packageB/pages/chat-room/index.wxss +++ b/miniprogram/packageB/pages/chat-room/index.wxss @@ -142,98 +142,49 @@ font-size: 80rpx; } -/* ==================== Stage Area ==================== */ - -.chat-room__stage { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding: 20rpx 40rpx; - min-height: 100rpx; -} - -.chat-room__stage-hint { - font-size: 28rpx; - color: rgba(255, 255, 255, 0.7); - text-align: center; - padding: 32rpx 48rpx; - background: rgba(255, 255, 255, 0.05); - border-radius: 16rpx; -} - -/* Speech Text - 实时语音识别文字 */ -.chat-room__speech-text { - max-width: 600rpx; - padding: 24rpx 32rpx; - background: rgba(74, 222, 128, 0.15); - border: 2rpx solid rgba(74, 222, 128, 0.3); - border-radius: 16rpx; - margin-bottom: 20rpx; -} - -.chat-room__speech-text--opponent { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); -} - -.chat-room__speech-text-content { - font-size: 32rpx; - line-height: 1.6; - color: rgba(255, 255, 255, 0.9); - word-wrap: break-word; -} - -.chat-room__speech-text-placeholder { - font-size: 28rpx; - color: rgba(255, 255, 255, 0.5); - font-style: italic; -} - -/* Recording Wave Animation */ -.chat-room__recording-wave { +/* Mic Wave Animation (inside mic button when recording) */ +.chat-room__mic-wave { display: flex; align-items: center; justify-content: center; - gap: 12rpx; - height: 120rpx; + gap: 6rpx; } -.chat-room__wave-bar { - width: 12rpx; - height: 40rpx; - background: #4ade80; - border-radius: 6rpx; +.chat-room__mic-wave-bar { + width: 8rpx; + height: 20rpx; + background: #ffffff; + border-radius: 4rpx; animation: wave-pulse 0.8s ease-in-out infinite; } -.chat-room__wave-bar:nth-child(1) { +.chat-room__mic-wave-bar:nth-child(1) { animation-delay: 0s; } -.chat-room__wave-bar:nth-child(2) { +.chat-room__mic-wave-bar:nth-child(2) { animation-delay: 0.1s; } -.chat-room__wave-bar:nth-child(3) { +.chat-room__mic-wave-bar:nth-child(3) { animation-delay: 0.2s; } -.chat-room__wave-bar:nth-child(4) { +.chat-room__mic-wave-bar:nth-child(4) { animation-delay: 0.3s; } -.chat-room__wave-bar:nth-child(5) { +.chat-room__mic-wave-bar:nth-child(5) { animation-delay: 0.4s; } @keyframes wave-pulse { 0%, 100% { - height: 40rpx; + height: 20rpx; } 50% { - height: 100rpx; + height: 56rpx; } } @@ -269,74 +220,51 @@ padding: 24rpx 0; } -/* Empty State */ -.chat-room__dialog-empty { - display: flex; - justify-content: center; - align-items: center; - padding: 60rpx 0; -} - -.chat-room__dialog-empty-text { - font-size: 28rpx; - color: rgba(255, 255, 255, 0.4); -} - -/* Message Item */ -.chat-room__message { +/* Section (per-speaker block) */ +.chat-room__section { display: flex; flex-direction: column; - max-width: 80%; -} - -/* Self Message - Right Aligned */ -.chat-room__message--self { - align-self: flex-end; - align-items: flex-end; -} - -/* Other Message - Left Aligned */ -.chat-room__message--other { - align-self: flex-start; - align-items: flex-start; -} - -/* Message Bubble */ -.chat-room__message-bubble { - padding: 24rpx 32rpx; - border-radius: 24rpx; - max-width: 100%; - word-wrap: break-word; -} - -.chat-room__message--self .chat-room__message-bubble { - background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%); - border-bottom-right-radius: 8rpx; + gap: 16rpx; } -.chat-room__message--other .chat-room__message-bubble { - background: rgba(255, 255, 255, 0.15); - border-bottom-left-radius: 8rpx; +/* Section Title - Centered speaker label */ +.chat-room__section-title { + text-align: center; + font-size: 28rpx; + font-weight: bold; + color: rgba(255, 255, 255, 0.6); + letter-spacing: 2rpx; } -.chat-room__message-text { +/* Section Text - Transcribed speech */ +.chat-room__section-text { font-size: 32rpx; - line-height: 1.5; + line-height: 1.6; + color: rgba(255, 255, 255, 0.9); + word-wrap: break-word; + padding: 24rpx 32rpx; + background: rgba(255, 255, 255, 0.08); + border-radius: 16rpx; } -.chat-room__message--self .chat-room__message-text { - color: #0f0f23; +/* Live (in-progress) text - visually distinct from finalized text */ +.chat-room__section-text--live { + color: rgba(255, 255, 255, 0.45); + font-style: italic; } -.chat-room__message--other .chat-room__message-text { - color: rgba(255, 255, 255, 0.9); +/* Divider between two speakers */ +.chat-room__divider { + display: flex; + justify-content: center; + align-items: center; + padding: 8rpx 0; } -/* Message Role Label */ -.chat-room__message-role { - margin-top: 8rpx; +.chat-room__divider-text { font-size: 24rpx; - color: rgba(255, 255, 255, 0.5); + color: rgba(255, 255, 255, 0.35); + letter-spacing: 2rpx; } /* ==================== Microphone Area ==================== */ @@ -353,7 +281,7 @@ flex-direction: row; align-items: center; justify-content: center; - gap: 40rpx; + gap: 100rpx; } .chat-room__mic { @@ -363,7 +291,9 @@ display: flex; justify-content: center; align-items: center; - transition: transform 0.15s ease-out, background 0.15s ease-out; + transition: + transform 0.15s ease-out, + background 0.15s ease-out; } .chat-room__mic-icon { @@ -424,22 +354,29 @@ .chat-room__emoji-panel { display: flex; - justify-content: center; - flex-wrap: wrap; - gap: 20rpx; + flex-direction: column; + gap: 16rpx; padding: 24rpx 32rpx; background: rgba(0, 0, 0, 0.3); border-top: 1rpx solid rgba(255, 255, 255, 0.1); } -.chat-room__emoji-panel--disabled { - opacity: 0.4; - pointer-events: none; +.chat-room__emoji-hint { + text-align: center; + font-size: 24rpx; + font-weight: bold; + color: rgba(255, 255, 255, 0.6); + letter-spacing: 2rpx; +} + +.chat-room__emoji-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 16rpx; } .chat-room__emoji-btn { - width: 80rpx; - height: 80rpx; + height: 88rpx; display: flex; justify-content: center; align-items: center; @@ -455,6 +392,46 @@ font-size: 40rpx; } +/* ==================== Switch Notification ==================== */ + +.chat-room__switch-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.chat-room__switch-content { + display: flex; + flex-direction: column; + align-items: center; +} + +.chat-room__switch-text { + font-size: 120rpx; + font-weight: 900; + color: #ffd200; + text-shadow: + /* 描边效果 */ + -8rpx -8rpx 0 #000000, + 8rpx -8rpx 0 #000000, + -8rpx 8rpx 0 #000000, + 8rpx 8rpx 0 #000000, + 0 -10rpx 0 #000000, + 0 10rpx 0 #000000, + -10rpx 0 0 #000000, + 10rpx 0 0 #000000, + /* 立体投影效果 */ + 12rpx 12rpx 0 rgba(0, 0, 0, 0.6); + line-height: 1; +} + /* ==================== Safe Area ==================== */ .chat-room__safe-area { diff --git a/miniprogram/packageB/pages/verdict-waiting/index.wxml b/miniprogram/packageB/pages/verdict-waiting/index.wxml index d2bdd99..d58d2a7 100644 --- a/miniprogram/packageB/pages/verdict-waiting/index.wxml +++ b/miniprogram/packageB/pages/verdict-waiting/index.wxml @@ -73,31 +73,33 @@ - - + + - ⚙️ + + ⚙️ + + {{ item.text }}{{ dots }} - {{ item.text }}{{ dots }} - - - - - - - - 🔔 判决已出!准备宣判... - - - + + + + + + + 🔔 判决已出!准备宣判... + + + + diff --git a/miniprogram/packageB/pages/verdict-waiting/index.wxss b/miniprogram/packageB/pages/verdict-waiting/index.wxss index f7dc3c7..85c7dc8 100644 --- a/miniprogram/packageB/pages/verdict-waiting/index.wxss +++ b/miniprogram/packageB/pages/verdict-waiting/index.wxss @@ -147,11 +147,15 @@ flex-direction: column; } +.card__wrapper { + display: grid; + height: 100%; + grid-template-rows: repeat(10, 1fr); +} + .card__line { display: flex; align-items: center; - margin-bottom: 16rpx; - flex-shrink: 0; opacity: 0; } diff --git a/miniprogram/packageB/pages/verdict/index.ts b/miniprogram/packageB/pages/verdict/index.ts index 81d2e1f..e0f4bca 100644 --- a/miniprogram/packageB/pages/verdict/index.ts +++ b/miniprogram/packageB/pages/verdict/index.ts @@ -452,7 +452,7 @@ Page({ } const width: number = 750; - const height: number = 1334; + const height: number = 1334; // fixed screen height const dpr: number = 2; canvas.width = width * dpr; @@ -463,18 +463,105 @@ Page({ ) as ICanvas2DContext; ctx.scale(dpr, dpr); + // Layout constants + const headerH: number = 200; + const footerH: number = 60; + const outerPadX: number = 40; + const sectionW: number = width - outerPadX * 2; + const innerPadX: number = 30; + const innerPadY: number = 28; + const textX: number = outerPadX + innerPadX; + const textMaxW: number = sectionW - innerPadX * 2; + + // Word-wrap helper — modifies ctx.font as a side-effect + const wrapText = ( + text: string, + maxW: number, + font: string + ): string[] => { + ctx.font = font; + const lines: string[] = []; + let line: string = ''; + for (const ch of [...text]) { + const test: string = line + ch; + if (ctx.measureText(test).width > maxW && line) { + lines.push(line); + line = ch; + } else { + line = test; + } + } + if (line) lines.push(line); + return lines; + }; + + // Draw a full-width section panel with left accent bar + const drawPanel = (panelY: number, panelH: number): void => { + ctx.fillStyle = 'rgba(212, 56, 13, 0.06)'; + ctx.fillRect(outerPadX, panelY, sectionW, panelH); + ctx.fillStyle = '#D4380D'; + ctx.fillRect(outerPadX, panelY, 6, panelH); + }; + + // ── Pre-calculate section heights ────────────────────────────── + const { hostNickName, guestNickName } = this.data; + const thirdPartyLines: string[] = verdict.responsibility.thirdParty.map( + f => `${f.emoji}${f.reason}: ${f.percentage}%` + ); + + const respTitleH: number = 56; + const respNameH: number = 64; + const respThirdH: number = 44; + const respPanelH: number = + innerPadY * 2 + + respTitleH + + respNameH * 2 + + respThirdH * thirdPartyLines.length; + + const summaryFont: string = '28px sans-serif'; + const summaryLines: string[] = wrapText( + verdict.verdictSummary, + textMaxW, + summaryFont + ); + const summaryLineH: number = 44; + const summaryTitleH: number = 56; + const summaryPanelH: number = + innerPadY * 2 + summaryTitleH + summaryLines.length * summaryLineH; + + const punishFont: string = 'bold 30px sans-serif'; + const punishLines: string[] = wrapText( + verdict.punishmentTask.task, + textMaxW, + punishFont + ); + const punishLineH: number = 46; + const punishTitleH: number = 56; + const deadlineH: number = 44; + const punishPanelH: number = + innerPadY * 2 + + punishTitleH + + punishLines.length * punishLineH + + deadlineH; + + // Distribute remaining space evenly as 4 gaps + // (top gap + 2 between sections + bottom gap before footer) + const totalSectionH: number = respPanelH + summaryPanelH + punishPanelH; + const contentAvail: number = height - headerH - footerH; + const gap: number = Math.max( + 20, + Math.floor((contentAvail - totalSectionH) / 4) + ); + + // ── Draw ────────────────────────────────────────────────────── + // Background ctx.fillStyle = '#FFFEF7'; ctx.fillRect(0, 0, width, height); - // Red border - ctx.strokeStyle = '#D4380D'; - ctx.lineWidth = 8; - ctx.strokeRect(4, 4, width - 8, height - 8); - // Header ctx.fillStyle = '#D4380D'; - ctx.fillRect(0, 0, width, 200); + ctx.fillRect(0, 0, width, headerH); ctx.fillStyle = '#FFD93D'; ctx.font = 'bold 48px sans-serif'; @@ -485,87 +572,103 @@ Page({ ctx.font = '24px sans-serif'; ctx.fillText(`案件编号: NO.${verdict.caseNumber}`, width / 2, 160); - // Responsibility - let y: number = 250; + let y: number = headerH + gap; + + // ===== 责任分布 ===== + drawPanel(y, respPanelH); + + let iy: number = y + innerPadY; + ctx.fillStyle = '#D4380D'; - ctx.font = 'bold 36px sans-serif'; - ctx.fillText('责任分布', width / 2, y); + ctx.font = 'bold 34px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('责任分布', width / 2, iy + 32); + iy += respTitleH; - y += 60; - ctx.font = 'bold 48px sans-serif'; ctx.fillStyle = '#333333'; - const { hostNickName, guestNickName } = this.data; + ctx.font = 'bold 44px sans-serif'; ctx.textAlign = 'left'; - ctx.fillText(`${hostNickName}: ${verdict.responsibility.host}%`, 80, y); - ctx.textAlign = 'right'; + ctx.fillText( + `${hostNickName}: ${verdict.responsibility.host}%`, + textX, + iy + 46 + ); + iy += respNameH; + ctx.fillText( `${guestNickName}: ${verdict.responsibility.guest}%`, - width - 80, - y + textX, + iy + 46 ); + iy += respNameH; - // Third party factors - y += 50; - ctx.font = '24px sans-serif'; ctx.fillStyle = '#666666'; - ctx.textAlign = 'center'; - const factorText: string = verdict.responsibility.thirdParty - .map(f => `${f.emoji}${f.reason} ${f.percentage}%`) - .join(' '); - ctx.fillText(factorText, width / 2, y); + ctx.font = '26px sans-serif'; + for (const tpLine of thirdPartyLines) { + ctx.fillText(tpLine, textX, iy + 30); + iy += respThirdH; + } + + y += respPanelH + gap; + + // ===== 大老爷赠言 ===== + drawPanel(y, summaryPanelH); + iy = y + innerPadY; - // Verdict summary - y += 80; ctx.fillStyle = '#D4380D'; - ctx.font = 'bold 36px sans-serif'; - ctx.fillText('大老爷赠言', width / 2, y); + ctx.font = 'bold 34px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('大老爷赠言', width / 2, iy + 32); + iy += summaryTitleH; - y += 50; ctx.fillStyle = '#333333'; - ctx.font = '28px sans-serif'; - // Word wrap - const maxLineWidth: number = width - 160; - const summaryChars: string[] = [...verdict.verdictSummary]; - let line: string = ''; - for (const char of summaryChars) { - const testLine: string = line + char; - const metrics: { width: number } = ctx.measureText(testLine); - if (metrics.width > maxLineWidth && line) { - ctx.fillText(line, width / 2, y); - line = char; - y += 40; - } else { - line = testLine; - } - } - if (line) { - ctx.fillText(line, width / 2, y); + ctx.font = summaryFont; + ctx.textAlign = 'left'; + for (const sLine of summaryLines) { + ctx.fillText(sLine, textX, iy + 32); + iy += summaryLineH; } - // Punishment - y += 80; + y += summaryPanelH + gap; + + // ===== 惩罚令牌 ===== + drawPanel(y, punishPanelH); + iy = y + innerPadY; + ctx.fillStyle = '#D4380D'; - ctx.font = 'bold 36px sans-serif'; - ctx.fillText('惩罚令牌', width / 2, y); + ctx.font = 'bold 34px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('惩罚令牌', width / 2, iy + 32); + iy += punishTitleH; - y += 50; ctx.fillStyle = '#333333'; - ctx.font = 'bold 30px sans-serif'; - ctx.fillText(verdict.punishmentTask.task, width / 2, y); + ctx.font = punishFont; + ctx.textAlign = 'left'; + for (const pLine of punishLines) { + ctx.fillText(pLine, textX, iy + 36); + iy += punishLineH; + } - y += 40; ctx.fillStyle = '#999999'; ctx.font = '24px sans-serif'; - ctx.fillText(verdict.punishmentTask.deadline, width / 2, y); + ctx.fillText(verdict.punishmentTask.deadline, textX, iy + 28); + + y += punishPanelH + gap; // Footer ctx.fillStyle = '#CCCCCC'; ctx.font = '20px sans-serif'; - ctx.fillText('清汤大老爷 · 判决书', width / 2, height - 60); + ctx.textAlign = 'center'; + ctx.fillText('清汤大老爷 · 判决书', width / 2, y + 20); const dateStr: string = new Date().toLocaleDateString('zh-CN'); - ctx.fillText(dateStr, width / 2, height - 30); + ctx.fillText(dateStr, width / 2, y + 44); + + // Border drawn last to frame full screen height + ctx.strokeStyle = '#D4380D'; + ctx.lineWidth = 8; + ctx.strokeRect(4, 4, width - 8, height - 8); - // Save to album + // Save to album at fixed screen height wx.canvasToTempFilePath({ canvas, x: 0, diff --git a/miniprogram/services/drum-service.ts b/miniprogram/services/drum-service.ts index 8d9f893..b3caae6 100644 --- a/miniprogram/services/drum-service.ts +++ b/miniprogram/services/drum-service.ts @@ -13,6 +13,7 @@ import { EDrumMessageType, EPlayerRole, parseDrumMessage, + createStartRequestMessage, createTapMessage, type TDrumMessage, } from '../types/drum-websocket'; @@ -44,11 +45,15 @@ type DrumStartHandler = (startAtMs: number) => void; /** Handler for DRUM_FINISH events */ type DrumFinishHandler = () => void; +/** Handler for DRUM_PLAYER_READY events */ +type DrumPlayerReadyHandler = (readyCount: number) => void; + /** Options for initializing drum service */ interface IDrumServiceOptions { roomId: string; selfRole: EPlayerRole; onReady: DrumReadyHandler; + onPlayerReady: DrumPlayerReadyHandler; onStart: DrumStartHandler; onTap: DrumTapHandler; onFinish: DrumFinishHandler; @@ -64,6 +69,7 @@ class DrumService { private resultHandler: DrumResultHandler | null = null; private errorHandler: DrumErrorHandler | null = null; private readyHandler: DrumReadyHandler | null = null; + private playerReadyHandler: DrumPlayerReadyHandler | null = null; private startHandler: DrumStartHandler | null = null; private finishHandler: DrumFinishHandler | null = null; @@ -110,6 +116,7 @@ class DrumService { this.currentRoomId = options.roomId; this.currentRole = options.selfRole; this.readyHandler = options.onReady; + this.playerReadyHandler = options.onPlayerReady; this.startHandler = options.onStart; this.tapHandler = options.onTap; this.finishHandler = options.onFinish; @@ -143,6 +150,7 @@ class DrumService { this.resultHandler = null; this.errorHandler = null; this.readyHandler = null; + this.playerReadyHandler = null; this.startHandler = null; this.finishHandler = null; @@ -183,6 +191,20 @@ class DrumService { } } + /** + * Send DRUM_START_REQUEST to server + * Call this when the local player clicks "开始游戏" + */ + sendStartRequest(userId: string): void { + if (!wsManager.isConnected()) { + logger.error('Drum', 'Not connected, cannot send start request'); + return; + } + const msg = createStartRequestMessage(this.currentRoomId, userId); + wsManager.send(msg); + logger.log('Drum', 'Sent DRUM_START_REQUEST for user:', userId); + } + /** * Queue a tap for batched sending */ @@ -287,6 +309,10 @@ class DrumService { ); break; + case EDrumMessageType.DrumPlayerReady: + this.handlePlayerReady(message.data.readyCount); + break; + case EDrumMessageType.DrumStart: this.handleStart(message.data.startAtMs); break; @@ -329,6 +355,16 @@ class DrumService { } } + /** + * Handle DRUM_PLAYER_READY event + */ + private handlePlayerReady(readyCount: number): void { + console.log(readyCount); + if (this.playerReadyHandler) { + this.playerReadyHandler(readyCount); + } + } + /** * Handle DRUM_START event */ diff --git a/miniprogram/types/drum-websocket.ts b/miniprogram/types/drum-websocket.ts index 33f5b77..f5feaa9 100644 --- a/miniprogram/types/drum-websocket.ts +++ b/miniprogram/types/drum-websocket.ts @@ -17,6 +17,12 @@ export enum EDrumMessageType { // Server -> Client: Room ready, sync time DrumReady = 'DRUM_READY', + // Client -> Server: Player signals ready to start + DrumStartRequest = 'DRUM_START_REQUEST', + + // Server -> Client: A player has signalled ready (broadcast readyCount) + DrumPlayerReady = 'DRUM_PLAYER_READY', + // Server -> Client: Game start signal with timing DrumStart = 'DRUM_START', @@ -51,10 +57,36 @@ export interface IDrumReadyData { joinerName: string; } +/** + * DRUM_PLAYER_READY: A player has signalled ready + * Server -> Client (broadcast to all participants) + */ +export interface IDrumPlayerReadyData { + roomId: string; + readyCount: number; +} + +export interface IDrumPlayerReadyMessage extends IDrumMessage { + type: EDrumMessageType.DrumPlayerReady; +} + export interface IDrumReadyMessage extends IDrumMessage { type: EDrumMessageType.DrumReady; } +/** + * DRUM_START_REQUEST: Player signals ready to start + * Client -> Server + */ +export interface IDrumStartRequestData { + roomId: string; + userId: string; +} + +export interface IDrumStartRequestMessage extends IDrumMessage { + type: EDrumMessageType.DrumStartRequest; +} + /** * DRUM_START: Game start signal * Server -> Client @@ -116,6 +148,7 @@ export interface IDrumResultMessage extends IDrumMessage { */ export type TDrumMessage = | IDrumReadyMessage + | IDrumPlayerReadyMessage | IDrumStartMessage | IDrumTapMessage | IDrumFinishMessage @@ -144,6 +177,23 @@ export function parseDrumMessage(rawData: string): TDrumMessage | null { } } +/** + * Create drum start request message payload + * @param roomId - Room ID + * @param userId - Current user ID + * @returns Message object ready to send + */ +export function createStartRequestMessage( + roomId: string, + userId: string +): IDrumStartRequestMessage { + return { + type: EDrumMessageType.DrumStartRequest, + data: { roomId, userId }, + timestamp: Date.now(), + }; +} + /** * Create drum tap message payload * @param roomId - Room ID