Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

Two-player real-time interactive WeChat Mini Program ("申冤" app) with a Node.js backend. Users create/join rooms, compete in a drum-tapping game to decide speaking order, then take turns voicing grievances with real-time ASR transcription.

**User Flow**: Welcome → Waiting Room → Drum Room (10s tap competition) → Chat Room (turn-based voice chat with ASR) → Verdict Waiting (AI analysis loading) → Verdict
**User Flow**: Welcome (nickname modal on first visit if no name set) → Waiting Room → Drum Room (10s tap competition) → Chat Room (turn-based voice chat with ASR) → Verdict Waiting (AI analysis loading) → Verdict

**Welcome Page Nickname Flow**:

- Returning users (nickname stored) → navigate directly to waiting room
- First-time users → bottom-sheet modal ("堂下何人,报上名来!") with:
- `type="nickname"` input (WeChat nickname picker, max 12 chars)
- Confirm ("击鼓申冤!") — saves via `nicknameService.saveNickName()`
- Skip ("稍后再说") — uses default name "申冤人" without saving

**Tech Stack**:

Expand Down Expand Up @@ -99,11 +107,11 @@ All services are classes exported as singleton instances:
| `room-service` | `roomService` | Room creation via HTTP API |
| `room-websocket-service` | `roomWebSocketService` | Room join/leave via WebSocket |
| `drum-service` | `drumService` | Drum game message sending/receiving |
| `chat-service` | `chatService` | Chat message handling |
| `asr-service` | `asrService` | ASR text sync via WebSocket (throttled) |
| `sts-service` | `stsService` | Tencent Cloud STS token fetching |
| `verdict-service` | `verdictService` | Verdict result (WS listen + HTTP fallback + format mapping + caching) |
| `post-game-service` | `postGameService` | Post-game interactions (effects, leave together) |
| `post-game-service` | `postGameService` | Post-game interactions (execute punishment / beg for mercy) |
| `nickname-service` | `nicknameService` | User identity: nickname + userId storage, retrieval, validation |

### Dual Type System for WebSocket Messages

Expand All @@ -112,6 +120,7 @@ The frontend uses **two separate type hierarchies** for WebSocket messages:
1. **General messages** (`types/websocket-common.ts`): `EWSMessageType` + `IWSMessage<T>` — for `JOIN_ROOM`, `CHAT_SEND`, `ASR_TEXT_PUSH`, `SPEECH_TURN_END`, `VERDICT_RESULT`, etc.
2. **Drum messages** (`types/drum-websocket.ts`): `EDrumMessageType` + `IDrumMessage<T>` — for `DRUM_READY`, `DRUM_START`, `DRUM_TAP`, `DRUM_FINISH`, `DRUM_RESULT`
3. **Verdict messages** (`types/verdict-ws.ts`): Verdict-specific payload types — `IBackendVerdictResult`, `IVerdictResultPayload`, `IVerdictFailedPayload`, etc.
4. **Emoji messages** (`types/emoji-websocket.ts`): `IEmojiReceiveData` — for `EMOJI_RECEIVE` server→client payload

The backend uses a single unified `EWSMessageType` enum for all message types.

Expand Down Expand Up @@ -236,10 +245,11 @@ Husky + lint-staged runs ESLint + Prettier on `.ts`, `.js`, `.json`, `.md`. Comm
| `DRUM_TAP` | Record drum tap during game |
| `CHAT_SEND` | Send a chat message |
| `ASR_TEXT_PUSH` | Push ASR transcription text (throttled partials + immediate finals) |
| `EMOJI_SEND` | Send emoji reaction to opponent during chat |
| `SPEECH_TURN_END` | Notify server that player's speech turn is done |
| `VERDICT_RETRY` | Request retry after verdict generation failure |
| `POST_GAME_EFFECT` | Send post-game effect (execute punishment / beg for mercy) |
| `LEAVE_TOGETHER` | Request mutual leave from verdict page |
| `POST_GAME_ACTION` | Send post-game action (execute_punishment / beg_for_mercy) |
| `LEAVE_ROOM` | Request to leave the room from verdict page |

**Server → Client**:

Expand All @@ -253,12 +263,13 @@ Husky + lint-staged runs ESLint + Prettier on `.ts`, `.js`, `.json`, `.md`. Comm
| `DRUM_RESULT` | Final game results (scores + winner) |
| `CHAT_RECEIVE` | Receive chat message (broadcast) |
| `ASR_TEXT` | ASR transcription result (broadcast to other participant) |
| `EMOJI_RECEIVE` | Emoji reaction from opponent during chat |
| `SPEECH_TURN_SWITCH` | First speaker done, notify turn switch |
| `CHAT_COMPLETE` | Both speakers done, triggers verdict generation |
| `VERDICT_RESULT` | AI verdict result push (success) |
| `VERDICT_FAILED` | AI verdict generation failed (with canRetry flag) |
| `POST_GAME_EFFECT` | Post-game effect broadcast (stamp/emoji) |
| `LEAVE_TOGETHER_ACK` | Acknowledge mutual leave (with `allReady` flag) |
| `POST_GAME_EFFECT` | Post-game effect broadcast from opponent (stamp/emoji) |
| `LEAVE_ROOM_ACK` | Acknowledge leave room request |
| `ERROR` | Error notification |

### Room Flow
Expand Down
2 changes: 2 additions & 0 deletions backend/src/constants/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export const DRUM_CONFIG = {
COUNTDOWN_MS: 3000,
/** Game duration (ms) */
GAME_DURATION_MS: 10000,
/** Max taps to win instantly */
MAX_TAPS: 30,
} as const;

export const VERDICT_CONFIG = {
Expand Down
20 changes: 20 additions & 0 deletions backend/src/controllers/ws-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,17 @@ export class WebSocketController {
},
connectionId
);

// Early finish: if a player reached MAX_TAPS, end the game immediately
const game = drumGameManager.getGame(result.roomId);
if (
game &&
game.phase === EGamePhase.Running &&
(game.organizerScore >= DRUM_CONFIG.MAX_TAPS ||
game.joinerScore >= DRUM_CONFIG.MAX_TAPS)
) {
WebSocketController.finishDrumGame(result.roomId, game.endAtMs);
}
}

/**
Expand Down Expand Up @@ -438,6 +449,15 @@ export class WebSocketController {
* Finish drum game and broadcast result
*/
private static finishDrumGame(roomId: string, endAtMs: number): void {
// Guard: game may have already been finished early
if (!drumGameManager.getGame(roomId)) {
logger.log(
'WSController',
`Game ${roomId} already finished, skipping`
);
return;
}

// Broadcast DRUM_FINISH
connectionManager.broadcastToRoom(roomId, {
type: EWSMessageType.DrumFinish,
Expand Down
4 changes: 4 additions & 0 deletions miniprogram/components/styled-button/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ Component({
type: String,
value: '',
},
openType: {
type: String,
value: '',
},
},

methods: {
Expand Down
1 change: 1 addition & 0 deletions miniprogram/components/styled-button/index.wxml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
class="styled-button styled-button--{{ color }} {{ disabled ? 'styled-button--disabled' : '' }}"
hover-class="{{ disabled ? '' : 'styled-button--pressed' }}"
bindtap="handleTap"
open-type="{{ openType }}"
style="{{ buttonStyle }}"
>
<!-- Light sweep effect layer -->
Expand Down
11 changes: 9 additions & 2 deletions miniprogram/packageA/pages/drum-room/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ function getScoreKey(role: EPlayerRole): 'organizerScore' | 'joinerScore' {
const RUNNING_DURATION_MS: number = 10000;
const RESULT_DISPLAY_MS: number = 2000;
const FLY_TEXT_DURATION_MS: number = 800;
const MAX_SCORE_FOR_PROGRESS: number = 100;
const MAX_TAPS: number = 30;
const MAX_SCORE_FOR_PROGRESS: number = MAX_TAPS;

Page<IDrumPageData, WechatMiniprogram.Page.CustomOption & PrivateState>({
data: {
Expand Down Expand Up @@ -403,8 +404,14 @@ Page<IDrumPageData, WechatMiniprogram.Page.CustomOption & PrivateState>({
const scoreKey: 'organizerScore' | 'joinerScore' = getScoreKey(
this.data.selfRole
);
const newScore: number = this.data[scoreKey] + 1;
const currentScore: number = this.data[scoreKey];

// Ignore taps beyond the cap
if (currentScore >= MAX_TAPS) {
return;
}

const newScore: number = currentScore + 1;
this._updateScore(this.data.selfRole, newScore);

// Trigger feedback
Expand Down
45 changes: 44 additions & 1 deletion miniprogram/packageA/pages/waiting-room/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,32 @@ Page<IWaitingRoomPageData, IWaitingRoomCustomOption>({
isCreatingRoom: false,
isJoiningRoom: false,

onLoad(): void {
onLoad(options: Record<string, string | undefined>): void {
this.initAnimations();
this.initWebSocket();
this.initUser();

wx.enableAlertBeforeUnload({
message: '退出后房间将失效,确定要离开吗?',
});

const roomId = options['room_id'];
if (roomId) {
const value = roomId.replace(/\D/g, '').slice(0, 6);
const display: string[] = [];
for (let i = 0; i < 6; i++) {
display.push(value[i] || '');
}
this.setData({
showJoinModal: true,
roomCodeInput: value,
roomCodeDisplay: display,
inputFocus: false,
errorType: null,
errorMessage: '',
isJoinButtonDisabled: value.length !== 6,
});
}
},

onShow(): void {
Expand Down Expand Up @@ -631,6 +653,27 @@ Page<IWaitingRoomPageData, IWaitingRoomCustomOption>({
}
},

/**
* 触发转发好友菜单
*/
async handleForwardRoom(): Promise<void> {
await wx.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage'],
});
},

/**
* 页面分享配置
*/
onShareAppMessage(): WechatMiniprogram.Page.ICustomShareContent {
console.log('roomCode', this.data.roomCode);
return {
title: '快来公堂对簿!清汤大老爷等你很久了!',
path: `/packageA/pages/waiting-room/index?room_id=${this.data.roomCode}`,
};
},

/**
* 阻止遮罩层事件穿透
*/
Expand Down
9 changes: 8 additions & 1 deletion miniprogram/packageA/pages/waiting-room/index.wxml
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,15 @@
<text>{{ currentWaitingText }}</text>
</view>

<!-- 取消按钮 -->
<!-- 转发和取消按钮 -->
<view class="waiting-room__cancel-wrapper">
<styled-button
text="转发给好友"
color="yellow"
icon="📨"
bindtap="handleForwardRoom"
openType="share"
></styled-button>
<styled-button
text="取消审判"
color="grey"
Expand Down
23 changes: 13 additions & 10 deletions miniprogram/packageA/pages/waiting-room/index.wxss
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,6 @@
flex: 1;
}

/* 按钮包装器 - 用于动画绑定 */
.waiting-room__button-wrapper {
width: 800rpx;
display: flex;
justify-content: center;
}

/* ========================================
Styled Button 在 Waiting Room 中的样式覆盖
使用更具体的选择器来覆盖组件默认样式
Expand All @@ -73,8 +66,19 @@
width: 600rpx;
}

/* 取消按钮包装器 */
/* 按钮包装器 - 用于动画绑定 */
.waiting-room__button-wrapper {
width: 800rpx;
display: flex;
justify-content: center;
}

/* 转发 + 取消按钮包装器 */
.waiting-room__cancel-wrapper {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 32rpx;
margin-top: 120rpx;
}

Expand All @@ -88,7 +92,7 @@
width: 100%;
padding: 0 50rpx;
box-sizing: border-box;
flex:1
flex: 1;
}

/* 房间信息 */
Expand Down Expand Up @@ -322,4 +326,3 @@
.waiting-room__participant-name--me {
color: #ffd000;
}

12 changes: 11 additions & 1 deletion miniprogram/packageB/pages/chat-room/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,6 @@ Page<IChatRoomPageData, IChatRoomCustomOption>({
const opponentName: string = options.opponentName
? decodeURIComponent(options.opponentName)
: DEFAULT_OPPONENT_NAME;

// 校验 roomCode
if (!roomCode) {
void wx.showToast({ title: '房间号无效', icon: 'error' });
Expand Down Expand Up @@ -974,6 +973,17 @@ Page<IChatRoomPageData, IChatRoomCustomOption>({
this.stopRecording();
},

/**
* 提前结束发言:跳过剩余倒计时,立即切换阶段
*/
onEndEarlyTap(): void {
if (!this.data.canSpeak) {
return;
}
void wx.vibrateShort({ type: 'medium' });
this.switchPhase();
},

/**
* 开始录音和语音识别
* 使用 QCloudAIVoice 插件的 start 方法,同时启动录音和识别
Expand Down
23 changes: 16 additions & 7 deletions miniprogram/packageB/pages/chat-room/index.wxml
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,22 @@

<!-- 麦克风按钮 -->
<view class="chat-room__mic-area">
<view
class="chat-room__mic {{canSpeak ? (isRecording ? 'chat-room__mic--recording' : 'chat-room__mic--active') : 'chat-room__mic--disabled'}}"
bindtouchstart="onMicTouchStart"
bindtouchend="onMicTouchEnd"
bindtouchcancel="onMicTouchCancel"
>
<text class="chat-room__mic-icon">🎤</text>
<view class="chat-room__mic-buttons">
<view
class="chat-room__mic {{canSpeak ? (isRecording ? 'chat-room__mic--recording' : 'chat-room__mic--active') : 'chat-room__mic--disabled'}}"
bindtouchstart="onMicTouchStart"
bindtouchend="onMicTouchEnd"
bindtouchcancel="onMicTouchCancel"
>
<text class="chat-room__mic-icon">🎤</text>
</view>
<view
wx:if="{{canSpeak}}"
class="chat-room__end-btn"
bindtap="onEndEarlyTap"
>
<text class="chat-room__end-btn-icon">✕</text>
</view>
</view>
<text class="chat-room__mic-label">
{{isRecording ? '松开结束' : (canSpeak ? '按住说话' : '等待发言')}}
Expand Down
Loading
Loading