fix: polish bark battle creation flow
This commit is contained in:
@@ -15,11 +15,67 @@ export const DEFAULT_BARK_BATTLE_CONFIG: BarkBattleConfig = {
|
||||
roundDurationMs: 30_000,
|
||||
countdownMs: 3_000,
|
||||
drawThreshold: 12,
|
||||
barkThreshold: 0.5,
|
||||
minBarkGapMs: 300,
|
||||
barkThreshold: 0.35,
|
||||
minBarkGapMs: 150,
|
||||
minBarkDurationMs: 90,
|
||||
maxBarkDurationMs: 900,
|
||||
balanceFactor: 32,
|
||||
calibrationMaxWaitMs: 4_000,
|
||||
opponentBasePower: 0.22,
|
||||
};
|
||||
|
||||
const BASE_ONOMATOPOEIA = [
|
||||
'轰!',
|
||||
'炸场!',
|
||||
'冲啊!',
|
||||
'破阵!',
|
||||
'爆发!',
|
||||
'燃起来!',
|
||||
'顶上去!',
|
||||
'压过去!',
|
||||
'震翻全场!',
|
||||
'声浪拉满!',
|
||||
] as const;
|
||||
|
||||
const DOG_ONOMATOPOEIA = ['轰汪!', '汪爆!', '嗷呜!'] as const;
|
||||
const TECH_ONOMATOPOEIA = ['能量爆裂!', '超频!', '电光轰鸣!'] as const;
|
||||
const FANTASY_ONOMATOPOEIA = ['龙吼!', '雷鸣!', '战鼓!'] as const;
|
||||
|
||||
type BarkBattleOnomatopoeiaSeed = {
|
||||
themeDescription?: string;
|
||||
playerImageDescription?: string;
|
||||
opponentImageDescription?: string;
|
||||
};
|
||||
|
||||
function pushUnique(target: string[], words: readonly string[]) {
|
||||
for (const word of words) {
|
||||
if (!target.includes(word)) {
|
||||
target.push(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBarkBattleDefaultOnomatopoeia(
|
||||
seed: BarkBattleOnomatopoeiaSeed = {},
|
||||
) {
|
||||
const joined = [
|
||||
seed.themeDescription,
|
||||
seed.playerImageDescription,
|
||||
seed.opponentImageDescription,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const words: string[] = [];
|
||||
|
||||
if (/狗|犬|汪|柴犬|柯基|哈士奇|shiba|husky|corgi|dog/u.test(joined)) {
|
||||
pushUnique(words, DOG_ONOMATOPOEIA);
|
||||
}
|
||||
if (/机甲|星舰|星际|机器人|电|赛博|霓虹|超频|laser|robot|mecha|cyber/u.test(joined)) {
|
||||
pushUnique(words, TECH_ONOMATOPOEIA);
|
||||
}
|
||||
if (/龙|魔法|骑士|战鼓|雷|古堡|dragon|knight|magic/u.test(joined)) {
|
||||
pushUnique(words, FANTASY_ONOMATOPOEIA);
|
||||
}
|
||||
pushUnique(words, BASE_ONOMATOPOEIA);
|
||||
return words.slice(0, 16);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ export class BarkBattleController {
|
||||
this.restart();
|
||||
}
|
||||
|
||||
updateConfigForActiveRound(config: BarkBattleConfig) {
|
||||
this.config = config;
|
||||
this.detector = this.createDetector();
|
||||
}
|
||||
|
||||
finishNow() {
|
||||
if (this.session.snapshot.phase !== 'playing') {
|
||||
this.session = this.session.startMockRound();
|
||||
|
||||
@@ -72,4 +72,22 @@ describe('BarkBattleController', () => {
|
||||
|
||||
expect(controller.getSnapshot().player.barkCount).toBe(2);
|
||||
});
|
||||
|
||||
it('默认阈值和冷却降低后,真实输入能快速连续触发声浪', () => {
|
||||
const controller = new BarkBattleController({
|
||||
...DEFAULT_BARK_BATTLE_CONFIG,
|
||||
countdownMs: 0,
|
||||
});
|
||||
|
||||
controller.startWithMockInput();
|
||||
controller.submitInputSample(0.36, 0);
|
||||
controller.submitInputSample(0.38, 150);
|
||||
controller.submitInputSample(0.1, 170);
|
||||
controller.submitInputSample(0.39, 300);
|
||||
controller.submitInputSample(0.1, 320);
|
||||
|
||||
expect(DEFAULT_BARK_BATTLE_CONFIG.barkThreshold).toBeLessThanOrEqual(0.35);
|
||||
expect(DEFAULT_BARK_BATTLE_CONFIG.minBarkGapMs).toBeLessThanOrEqual(150);
|
||||
expect(controller.getSnapshot().player.barkCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,23 +68,11 @@ export class BarkBattleSession {
|
||||
lastEvents: [],
|
||||
};
|
||||
|
||||
if (remainingMs > 0) {
|
||||
if (remainingMs > 0 && !hasEnergyReachedEdge(energy)) {
|
||||
return new BarkBattleSession(this.config, nextSnapshot);
|
||||
}
|
||||
|
||||
const result = buildBarkBattleResult({
|
||||
energy,
|
||||
drawThreshold: this.config.drawThreshold,
|
||||
playerBarkCount: nextSnapshot.player.barkCount,
|
||||
opponentBarkCount: nextSnapshot.opponent.barkCount,
|
||||
});
|
||||
return new BarkBattleSession(this.config, {
|
||||
...nextSnapshot,
|
||||
phase: 'finished',
|
||||
uiState: 'finished',
|
||||
winner: result.winner,
|
||||
result,
|
||||
});
|
||||
return this.finishWithSnapshot(nextSnapshot);
|
||||
}
|
||||
|
||||
applyPlayerBark(event: BarkBattleEvent) {
|
||||
@@ -93,15 +81,22 @@ export class BarkBattleSession {
|
||||
}
|
||||
|
||||
const playerPower = Math.min(1, Math.max(this.snapshot.player.power, event.peakVolume));
|
||||
return new BarkBattleSession(this.config, {
|
||||
const energy = clampEnergy(this.snapshot.energy + event.peakVolume * 12);
|
||||
const nextSnapshot: BarkBattleSnapshot = {
|
||||
...this.snapshot,
|
||||
energy: clampEnergy(this.snapshot.energy + event.peakVolume * 12),
|
||||
energy,
|
||||
player: {
|
||||
barkCount: this.snapshot.player.barkCount + 1,
|
||||
power: playerPower,
|
||||
},
|
||||
lastEvents: [event],
|
||||
});
|
||||
};
|
||||
|
||||
if (hasEnergyReachedEdge(energy)) {
|
||||
return this.finishWithSnapshot(nextSnapshot);
|
||||
}
|
||||
|
||||
return new BarkBattleSession(this.config, nextSnapshot);
|
||||
}
|
||||
|
||||
failMicrophone(reason: BarkBattleSnapshot['errorReason']) {
|
||||
@@ -121,6 +116,26 @@ export class BarkBattleSession {
|
||||
lastEvents,
|
||||
});
|
||||
}
|
||||
|
||||
private finishWithSnapshot(snapshot: BarkBattleSnapshot) {
|
||||
const result = buildBarkBattleResult({
|
||||
energy: snapshot.energy,
|
||||
drawThreshold: this.config.drawThreshold,
|
||||
playerBarkCount: snapshot.player.barkCount,
|
||||
opponentBarkCount: snapshot.opponent.barkCount,
|
||||
});
|
||||
return new BarkBattleSession(this.config, {
|
||||
...snapshot,
|
||||
phase: 'finished',
|
||||
uiState: 'finished',
|
||||
winner: result.winner,
|
||||
result,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hasEnergyReachedEdge(energy: number) {
|
||||
return Math.abs(energy) >= 100;
|
||||
}
|
||||
|
||||
const MICROPHONE_STATUS_KEYS = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig';
|
||||
import { decideBarkBattleWinner } from '../BarkBattleScoring';
|
||||
import { createBarkBattleSession } from '../BarkBattleSession';
|
||||
import { BarkBattleSession, createBarkBattleSession } from '../BarkBattleSession';
|
||||
|
||||
describe('BarkBattleSession', () => {
|
||||
it('能从校准完成进入倒计时、playing 并在归零后结算', () => {
|
||||
@@ -38,6 +38,50 @@ describe('BarkBattleSession', () => {
|
||||
expect(session.snapshot.player.barkCount).toBe(before.player.barkCount);
|
||||
expect(session.snapshot.energy).toBe(before.energy);
|
||||
});
|
||||
|
||||
it('顶部能量条被玩家推到边界时立刻结算', () => {
|
||||
const config = {
|
||||
...DEFAULT_BARK_BATTLE_CONFIG,
|
||||
roundDurationMs: 10_000,
|
||||
countdownMs: 0,
|
||||
balanceFactor: 200,
|
||||
opponentBasePower: 0,
|
||||
};
|
||||
let session = createBarkBattleSession(config).startMockRound();
|
||||
session = new BarkBattleSession(config, {
|
||||
...session.snapshot,
|
||||
energy: 89,
|
||||
});
|
||||
|
||||
session = session.applyPlayerBark({
|
||||
atMs: 0,
|
||||
peakVolume: 1,
|
||||
durationMs: 120,
|
||||
side: 'player',
|
||||
});
|
||||
|
||||
expect(session.snapshot.phase).toBe('finished');
|
||||
expect(session.snapshot.remainingMs).toBe(10_000);
|
||||
expect(session.snapshot.energy).toBe(100);
|
||||
expect(session.snapshot.result?.winner).toBe('player');
|
||||
});
|
||||
|
||||
it('顶部能量条被对手推到边界时立刻结算', () => {
|
||||
let session = createBarkBattleSession({
|
||||
...DEFAULT_BARK_BATTLE_CONFIG,
|
||||
roundDurationMs: 10_000,
|
||||
countdownMs: 0,
|
||||
balanceFactor: 200,
|
||||
opponentBasePower: 1,
|
||||
}).startMockRound();
|
||||
|
||||
session = session.tick(500);
|
||||
|
||||
expect(session.snapshot.phase).toBe('finished');
|
||||
expect(session.snapshot.remainingMs).toBe(9_500);
|
||||
expect(session.snapshot.energy).toBe(-100);
|
||||
expect(session.snapshot.result?.winner).toBe('opponent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideBarkBattleWinner', () => {
|
||||
|
||||
@@ -11,6 +11,22 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bark-battle-runtime__back-button {
|
||||
position: fixed;
|
||||
top: max(12px, env(safe-area-inset-top));
|
||||
left: 12px;
|
||||
z-index: 9;
|
||||
border: 1px solid rgba(255, 247, 237, 0.46);
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
color: #fff7ed;
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.28);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.bark-battle-hud__background-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -116,6 +132,23 @@
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.bark-battle-countdown {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
font-size: clamp(82px, 30vw, 168px);
|
||||
font-weight: 1000;
|
||||
line-height: 1;
|
||||
color: #fff7ed;
|
||||
text-shadow:
|
||||
0 10px 32px rgba(15, 23, 42, 0.66),
|
||||
0 0 36px rgba(250, 204, 21, 0.56);
|
||||
animation: barkBattleCountdownPulse 920ms ease-out infinite;
|
||||
}
|
||||
|
||||
.bark-battle-controls,
|
||||
.bark-battle-result__stats {
|
||||
position: relative;
|
||||
@@ -140,6 +173,20 @@
|
||||
background: linear-gradient(135deg, #facc15, #fb7185);
|
||||
}
|
||||
|
||||
.bark-battle-runtime-alert {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0 auto 10px;
|
||||
width: min(92vw, 420px);
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
color: #fff7ed;
|
||||
background: rgba(127, 29, 29, 0.78);
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.bark-battle-status-card,
|
||||
.bark-battle-result {
|
||||
margin: auto;
|
||||
@@ -153,6 +200,38 @@
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.bark-battle-result-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: max(20px, env(safe-area-inset-top)) 16px max(20px, env(safe-area-inset-bottom));
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.bark-battle-result--modal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bark-battle-result__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.bark-battle-result__actions button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
color: #1f1147;
|
||||
background: #fff7ed;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.bark-battle-result__stats span {
|
||||
min-width: 84px;
|
||||
display: grid;
|
||||
@@ -302,3 +381,10 @@
|
||||
42% { opacity: 1; }
|
||||
to { transform: translateY(-80px) scale(1.14); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes barkBattleCountdownPulse {
|
||||
from { transform: scale(0.84); opacity: 0; }
|
||||
24% { opacity: 1; }
|
||||
78% { transform: scale(1.06); opacity: 1; }
|
||||
to { transform: scale(1.18); opacity: 0; }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ type BarkBattleHudProps = {
|
||||
onMockBark?: () => void;
|
||||
onMockQuiet?: () => void;
|
||||
onRestart?: () => void;
|
||||
enableMockControls?: boolean;
|
||||
runtimeError?: string | null;
|
||||
playerBurstText?: string;
|
||||
opponentBurstText?: string;
|
||||
playerCharacterImageSrc?: string | null;
|
||||
opponentCharacterImageSrc?: string | null;
|
||||
uiBackgroundImageSrc?: string | null;
|
||||
@@ -36,6 +40,10 @@ export function BarkBattleHud({
|
||||
onMockBark,
|
||||
onMockQuiet,
|
||||
onRestart,
|
||||
enableMockControls = true,
|
||||
runtimeError = null,
|
||||
playerBurstText = '汪',
|
||||
opponentBurstText = '反击',
|
||||
playerCharacterImageSrc,
|
||||
opponentCharacterImageSrc,
|
||||
uiBackgroundImageSrc,
|
||||
@@ -43,6 +51,8 @@ export function BarkBattleHud({
|
||||
const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`;
|
||||
const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`;
|
||||
const isUnavailable = snapshot.phase === 'unavailable';
|
||||
const isCountingDown = snapshot.phase === 'countdown';
|
||||
const countdownSeconds = Math.ceil(snapshot.countdownMs / 1000);
|
||||
|
||||
return (
|
||||
<section className="bark-battle-hud" aria-label="汪汪声浪大作战">
|
||||
@@ -80,8 +90,10 @@ export function BarkBattleHud({
|
||||
</div>
|
||||
) : (
|
||||
<div className="bark-battle-arena" aria-label="竖屏声浪竞技场">
|
||||
<div key={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手狗狗面向屏幕">
|
||||
<span className="bark-battle-dog__burst" aria-hidden="true">反击</span>
|
||||
<div key={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手声浪角色面向屏幕">
|
||||
<span className="bark-battle-dog__burst" aria-hidden="true">
|
||||
{opponentBurstText}
|
||||
</span>
|
||||
<span className="bark-battle-dog__body">
|
||||
{opponentCharacterImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
@@ -96,8 +108,10 @@ export function BarkBattleHud({
|
||||
<span className="bark-battle-dog__label">对手 · {snapshot.opponent.barkCount}</span>
|
||||
</div>
|
||||
<div className="bark-battle-vs">VS</div>
|
||||
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家狗狗背对屏幕">
|
||||
<span className="bark-battle-dog__burst" aria-hidden="true">汪</span>
|
||||
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家声浪角色背对屏幕">
|
||||
<span className="bark-battle-dog__burst" aria-hidden="true">
|
||||
{playerBurstText}
|
||||
</span>
|
||||
<span className="bark-battle-dog__body">
|
||||
{playerCharacterImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
@@ -114,15 +128,32 @@ export function BarkBattleHud({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCountingDown ? (
|
||||
<div
|
||||
className="bark-battle-countdown"
|
||||
aria-label={`倒计时 ${countdownSeconds}`}
|
||||
>
|
||||
{countdownSeconds}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{runtimeError ? (
|
||||
<div className="bark-battle-runtime-alert" role="alert">
|
||||
{runtimeError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer className="bark-battle-controls">
|
||||
{snapshot.phase === 'permission' ? (
|
||||
<button className="bark-battle-primary-button" type="button" onClick={onStartMicrophone}>
|
||||
开始声控
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onPointerDown={onMockBark} onPointerUp={onMockQuiet} onClick={onMockBark}>
|
||||
模拟叫声
|
||||
</button>
|
||||
{enableMockControls ? (
|
||||
<button type="button" onPointerDown={onMockBark} onPointerUp={onMockQuiet} onClick={onMockBark}>
|
||||
模拟叫声
|
||||
</button>
|
||||
) : null}
|
||||
{snapshot.phase === 'finished' ? (
|
||||
<button type="button" onClick={onRestart}>再来一局</button>
|
||||
) : null}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { BarkBattlePublishedConfig } from '../../../../packages/shared/src/contracts/barkBattle';
|
||||
import { useResolvedAssetReadUrl } from '../../../hooks/useResolvedAssetReadUrl';
|
||||
import type {
|
||||
BarkBattleDerivedMetrics,
|
||||
BarkBattlePublishedConfig,
|
||||
BarkBattleRuntimeConfig,
|
||||
BarkBattleRunStartResponse,
|
||||
BarkBattleServerResult,
|
||||
} from '../../../../packages/shared/src/contracts/barkBattle';
|
||||
import {
|
||||
finishBarkBattleRun,
|
||||
startBarkBattleRun,
|
||||
} from '../../../services/bark-battle-runtime';
|
||||
import {
|
||||
type BarkBattleConfig,
|
||||
buildBarkBattleDefaultOnomatopoeia,
|
||||
DEFAULT_BARK_BATTLE_CONFIG,
|
||||
} from '../application/BarkBattleConfig';
|
||||
import { BarkBattleController } from '../application/BarkBattleController';
|
||||
@@ -13,12 +23,14 @@ import {
|
||||
startBrowserMicrophoneSampler,
|
||||
} from '../infrastructure/BrowserMicrophoneInput';
|
||||
import { BarkBattleHud } from './BarkBattleHud';
|
||||
import { BarkBattleResultPanel } from './BarkBattleResultPanel';
|
||||
|
||||
type BarkBattleRuntimeMode = 'draft' | 'published';
|
||||
|
||||
type BarkBattleRuntimeShellProps = {
|
||||
title?: string;
|
||||
workId?: string;
|
||||
publishedConfig?: BarkBattlePublishedConfig | null;
|
||||
runtimeMode?: BarkBattleRuntimeMode;
|
||||
onExit?: () => void;
|
||||
};
|
||||
|
||||
@@ -27,6 +39,66 @@ type DebugEvent = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type BarkBattleActiveRun = Pick<
|
||||
BarkBattleRunStartResponse,
|
||||
| 'runId'
|
||||
| 'runToken'
|
||||
| 'workId'
|
||||
| 'configVersion'
|
||||
| 'rulesetVersion'
|
||||
| 'difficultyPreset'
|
||||
| 'serverStartedAt'
|
||||
>;
|
||||
|
||||
type BarkBattleMetricAccumulator = {
|
||||
sampleCount: number;
|
||||
volumeSum: number;
|
||||
maxVolume: number;
|
||||
comboMax: number;
|
||||
currentCombo: number;
|
||||
};
|
||||
|
||||
const BARK_BATTLE_CLIENT_RUNTIME_VERSION = 'bark-battle-web-v1';
|
||||
|
||||
function createMetricAccumulator(): BarkBattleMetricAccumulator {
|
||||
return {
|
||||
sampleCount: 0,
|
||||
volumeSum: 0,
|
||||
maxVolume: 0,
|
||||
comboMax: 0,
|
||||
currentCombo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMetricVolume(volume: number) {
|
||||
if (!Number.isFinite(volume)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
function resolveClientResult(
|
||||
winner: 'player' | 'opponent' | 'draw' | null,
|
||||
): BarkBattleServerResult {
|
||||
if (winner === 'player') {
|
||||
return 'player_win';
|
||||
}
|
||||
if (winner === 'opponent') {
|
||||
return 'opponent_win';
|
||||
}
|
||||
return 'draw';
|
||||
}
|
||||
|
||||
function resolveResultTitle(winner: 'player' | 'opponent' | 'draw' | null) {
|
||||
if (winner === 'player') {
|
||||
return '汪力压制成功';
|
||||
}
|
||||
if (winner === 'opponent') {
|
||||
return '对手声浪更强';
|
||||
}
|
||||
return '势均力敌';
|
||||
}
|
||||
|
||||
const DEBUG_CONFIG_FIELDS: Array<{
|
||||
key: keyof Pick<
|
||||
BarkBattleConfig,
|
||||
@@ -43,7 +115,13 @@ const DEBUG_CONFIG_FIELDS: Array<{
|
||||
max: number;
|
||||
step: number;
|
||||
}> = [
|
||||
{ key: 'roundDurationMs', label: '局长(ms)', min: 1000, max: 60000, step: 1000 },
|
||||
{
|
||||
key: 'roundDurationMs',
|
||||
label: '局长(ms)',
|
||||
min: 1000,
|
||||
max: 60000,
|
||||
step: 1000,
|
||||
},
|
||||
{ key: 'countdownMs', label: '倒计时(ms)', min: 0, max: 5000, step: 500 },
|
||||
{ key: 'drawThreshold', label: '平局阈值', min: 0, max: 40, step: 1 },
|
||||
{ key: 'barkThreshold', label: '叫声阈值', min: 0.1, max: 1, step: 0.05 },
|
||||
@@ -64,8 +142,13 @@ const MICROPHONE_FAILURE_REASONS = new Set<MicrophoneFailureReason>([
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
function isMicrophoneFailureReason(reason: unknown): reason is MicrophoneFailureReason {
|
||||
return typeof reason === 'string' && MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason);
|
||||
function isMicrophoneFailureReason(
|
||||
reason: unknown,
|
||||
): reason is MicrophoneFailureReason {
|
||||
return (
|
||||
typeof reason === 'string' &&
|
||||
MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason)
|
||||
);
|
||||
}
|
||||
|
||||
function buildRuntimeConfigFromPublishedConfig(
|
||||
@@ -79,9 +162,9 @@ function buildRuntimeConfigFromPublishedConfig(
|
||||
BarkBattlePublishedConfig['difficultyPreset'],
|
||||
Partial<BarkBattleConfig>
|
||||
> = {
|
||||
easy: { barkThreshold: 0.42, opponentBasePower: 0.16, drawThreshold: 10 },
|
||||
normal: { barkThreshold: 0.5, opponentBasePower: 0.22, drawThreshold: 12 },
|
||||
hard: { barkThreshold: 0.58, opponentBasePower: 0.3, drawThreshold: 14 },
|
||||
easy: { opponentBasePower: 0.16 },
|
||||
normal: { opponentBasePower: 0.22 },
|
||||
hard: { opponentBasePower: 0.3 },
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -90,10 +173,99 @@ function buildRuntimeConfigFromPublishedConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeConfigFromServerConfig(
|
||||
runtimeConfig: BarkBattleRuntimeConfig,
|
||||
): BarkBattleConfig {
|
||||
const baseConfig = buildRuntimeConfigFromPublishedConfig({
|
||||
workId: runtimeConfig.workId,
|
||||
draftId: null,
|
||||
configVersion: runtimeConfig.configVersion,
|
||||
rulesetVersion: runtimeConfig.rulesetVersion,
|
||||
playTypeId: runtimeConfig.playTypeId,
|
||||
title: '',
|
||||
description: '',
|
||||
themeDescription: runtimeConfig.themeDescription,
|
||||
playerImageDescription: runtimeConfig.playerImageDescription,
|
||||
opponentImageDescription: runtimeConfig.opponentImageDescription,
|
||||
onomatopoeia: runtimeConfig.onomatopoeia,
|
||||
playerCharacterImageSrc: runtimeConfig.playerCharacterImageSrc,
|
||||
opponentCharacterImageSrc: runtimeConfig.opponentCharacterImageSrc,
|
||||
uiBackgroundImageSrc: runtimeConfig.uiBackgroundImageSrc,
|
||||
difficultyPreset: runtimeConfig.difficultyPreset,
|
||||
updatedAt: runtimeConfig.updatedAt,
|
||||
publishedAt: runtimeConfig.updatedAt,
|
||||
});
|
||||
return {
|
||||
...baseConfig,
|
||||
roundDurationMs: runtimeConfig.durationMs,
|
||||
drawThreshold: runtimeConfig.drawThreshold,
|
||||
minBarkGapMs: runtimeConfig.minBarkGapMs,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOnomatopoeiaPool(
|
||||
publishedConfig?: BarkBattlePublishedConfig | null,
|
||||
) {
|
||||
const custom = publishedConfig?.onomatopoeia
|
||||
?.map((word) => word.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 24);
|
||||
if (custom?.length) {
|
||||
return custom;
|
||||
}
|
||||
return buildBarkBattleDefaultOnomatopoeia({
|
||||
themeDescription: publishedConfig?.themeDescription,
|
||||
playerImageDescription: publishedConfig?.playerImageDescription,
|
||||
opponentImageDescription: publishedConfig?.opponentImageDescription,
|
||||
});
|
||||
}
|
||||
|
||||
function buildPublishedConfigFromServerRuntimeConfig(
|
||||
current: BarkBattlePublishedConfig,
|
||||
runtimeConfig: BarkBattleRuntimeConfig,
|
||||
): BarkBattlePublishedConfig {
|
||||
return {
|
||||
...current,
|
||||
workId: runtimeConfig.workId,
|
||||
configVersion: runtimeConfig.configVersion,
|
||||
rulesetVersion: runtimeConfig.rulesetVersion,
|
||||
playTypeId: runtimeConfig.playTypeId,
|
||||
themeDescription: runtimeConfig.themeDescription,
|
||||
playerImageDescription: runtimeConfig.playerImageDescription,
|
||||
opponentImageDescription: runtimeConfig.opponentImageDescription,
|
||||
onomatopoeia: runtimeConfig.onomatopoeia,
|
||||
playerCharacterImageSrc: runtimeConfig.playerCharacterImageSrc,
|
||||
opponentCharacterImageSrc: runtimeConfig.opponentCharacterImageSrc,
|
||||
uiBackgroundImageSrc: runtimeConfig.uiBackgroundImageSrc,
|
||||
difficultyPreset: runtimeConfig.difficultyPreset,
|
||||
updatedAt: runtimeConfig.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function pickRandomOnomatopoeia(
|
||||
pool: readonly string[],
|
||||
previous: string,
|
||||
) {
|
||||
if (!pool.length) {
|
||||
return '炸场!';
|
||||
}
|
||||
if (pool.length === 1) {
|
||||
return pool[0] ?? '炸场!';
|
||||
}
|
||||
const candidates = pool.filter((word) => word !== previous);
|
||||
const activePool = candidates.length ? candidates : pool;
|
||||
const index = Math.min(
|
||||
activePool.length - 1,
|
||||
Math.floor(Math.random() * activePool.length),
|
||||
);
|
||||
return activePool[index] ?? activePool[0] ?? '炸场!';
|
||||
}
|
||||
|
||||
export function BarkBattleRuntimeShell({
|
||||
title = '汪汪声浪大作战',
|
||||
workId,
|
||||
publishedConfig,
|
||||
runtimeMode = 'draft',
|
||||
onExit,
|
||||
}: BarkBattleRuntimeShellProps) {
|
||||
const initialConfig = useMemo(
|
||||
@@ -101,6 +273,7 @@ export function BarkBattleRuntimeShell({
|
||||
[publishedConfig],
|
||||
);
|
||||
const [config, setConfig] = useState(initialConfig);
|
||||
const runtimeConfigRef = useRef(initialConfig);
|
||||
const controllerRef = useRef<BarkBattleController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new BarkBattleController(config);
|
||||
@@ -108,31 +281,52 @@ export function BarkBattleRuntimeShell({
|
||||
const controller = controllerRef.current;
|
||||
const [snapshot, setSnapshot] = useState(() => controller.getSnapshot());
|
||||
const [particleText, setParticleText] = useState('');
|
||||
const [inputMode, setInputMode] = useState<'mock' | 'microphone'>('mock');
|
||||
const replacementConfig = publishedConfig ?? null;
|
||||
const [activePublishedConfig, setActivePublishedConfig] = useState(
|
||||
replacementConfig,
|
||||
);
|
||||
const onomatopoeiaPool = useMemo(
|
||||
() => normalizeOnomatopoeiaPool(activePublishedConfig),
|
||||
[activePublishedConfig],
|
||||
);
|
||||
const [playerBurstText, setPlayerBurstText] = useState(
|
||||
() => onomatopoeiaPool[0] ?? '炸场!',
|
||||
);
|
||||
const isPublishedRuntime =
|
||||
runtimeMode === 'published' && Boolean(replacementConfig?.workId);
|
||||
const [inputMode, setInputMode] = useState<'mock' | 'microphone'>(
|
||||
isPublishedRuntime ? 'microphone' : 'mock',
|
||||
);
|
||||
useEffect(() => {
|
||||
setInputMode(isPublishedRuntime ? 'microphone' : 'mock');
|
||||
}, [isPublishedRuntime]);
|
||||
const [liveInputVolume, setLiveInputVolume] = useState(0);
|
||||
const [isDebugExpanded, setIsDebugExpanded] = useState(false);
|
||||
const [playerPulseKey, setPlayerPulseKey] = useState(0);
|
||||
const [opponentPulseKey, setOpponentPulseKey] = useState(0);
|
||||
const [debugEvents, setDebugEvents] = useState<DebugEvent[]>([]);
|
||||
const barkAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [runtimeError, setRuntimeError] = useState<string | null>(null);
|
||||
const heldRef = useRef(false);
|
||||
const lastPlayerBarkCountRef = useRef(0);
|
||||
const lastOpponentPowerRef = useRef(0);
|
||||
const debugEventIdRef = useRef(0);
|
||||
const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null);
|
||||
const replacementConfig = publishedConfig ?? null;
|
||||
const { resolvedUrl: resolvedBarkSoundSrc } = useResolvedAssetReadUrl(
|
||||
replacementConfig?.barkSoundSrc ?? null,
|
||||
const activeRunRef = useRef<BarkBattleActiveRun | null>(null);
|
||||
const pendingRunStartRef = useRef<Promise<boolean> | null>(null);
|
||||
const runStartedAtRef = useRef<string | null>(null);
|
||||
const submittedRunIdsRef = useRef<Set<string>>(new Set());
|
||||
const autoStartMicrophoneAttemptedRef = useRef(false);
|
||||
const metricAccumulatorRef = useRef<BarkBattleMetricAccumulator>(
|
||||
createMetricAccumulator(),
|
||||
);
|
||||
const lastOnomatopoeiaRef = useRef('');
|
||||
// 中文注释:正式公开 runtime 面向玩家,只保留真实麦克风入口;mock 与调参面板只服务草稿试玩。
|
||||
const shouldShowDebugPanel = !isPublishedRuntime;
|
||||
|
||||
const playBarkSound = useCallback(() => {
|
||||
const audio = barkAudioRef.current;
|
||||
if (!audio || !resolvedBarkSoundSrc) {
|
||||
return;
|
||||
}
|
||||
audio.currentTime = 0;
|
||||
void audio.play().catch(() => {});
|
||||
}, [resolvedBarkSoundSrc]);
|
||||
useEffect(() => {
|
||||
lastOnomatopoeiaRef.current = '';
|
||||
setPlayerBurstText(onomatopoeiaPool[0] ?? '炸场!');
|
||||
}, [onomatopoeiaPool]);
|
||||
|
||||
const appendDebugEvent = useCallback((text: string) => {
|
||||
debugEventIdRef.current += 1;
|
||||
@@ -140,72 +334,296 @@ export function BarkBattleRuntimeShell({
|
||||
setDebugEvents((current) => [event, ...current].slice(0, 5));
|
||||
}, []);
|
||||
|
||||
const flashOnomatopoeia = useCallback(() => {
|
||||
const nextWord = pickRandomOnomatopoeia(
|
||||
onomatopoeiaPool,
|
||||
lastOnomatopoeiaRef.current,
|
||||
);
|
||||
lastOnomatopoeiaRef.current = nextWord;
|
||||
setPlayerBurstText(nextWord);
|
||||
setParticleText(nextWord);
|
||||
window.setTimeout(() => setParticleText(''), 520);
|
||||
}, [onomatopoeiaPool]);
|
||||
|
||||
const resetRuntimeRunState = useCallback(() => {
|
||||
activeRunRef.current = null;
|
||||
pendingRunStartRef.current = null;
|
||||
runStartedAtRef.current = null;
|
||||
submittedRunIdsRef.current = new Set();
|
||||
metricAccumulatorRef.current = createMetricAccumulator();
|
||||
setRuntimeError(null);
|
||||
}, []);
|
||||
|
||||
const recordRuntimeSample = useCallback((volume: number) => {
|
||||
const normalized = normalizeMetricVolume(volume);
|
||||
const metrics = metricAccumulatorRef.current;
|
||||
metrics.sampleCount += 1;
|
||||
metrics.volumeSum += normalized;
|
||||
metrics.maxVolume = Math.max(metrics.maxVolume, normalized);
|
||||
}, []);
|
||||
|
||||
const recordRuntimeTrigger = useCallback((volume: number) => {
|
||||
const normalized = normalizeMetricVolume(volume);
|
||||
const metrics = metricAccumulatorRef.current;
|
||||
metrics.currentCombo += 1;
|
||||
metrics.comboMax = Math.max(metrics.comboMax, metrics.currentCombo);
|
||||
metrics.maxVolume = Math.max(metrics.maxVolume, normalized);
|
||||
}, []);
|
||||
|
||||
const buildDerivedMetrics = useCallback((): BarkBattleDerivedMetrics => {
|
||||
const metrics = metricAccumulatorRef.current;
|
||||
const nextSnapshot = controller.getSnapshot();
|
||||
return {
|
||||
triggerCount: nextSnapshot.player.barkCount,
|
||||
maxVolume: Number(metrics.maxVolume.toFixed(3)),
|
||||
averageVolume: Number(
|
||||
(metrics.sampleCount
|
||||
? metrics.volumeSum / metrics.sampleCount
|
||||
: 0
|
||||
).toFixed(3),
|
||||
),
|
||||
finalEnergy: Number(nextSnapshot.energy.toFixed(2)),
|
||||
comboMax: metrics.comboMax,
|
||||
};
|
||||
}, [controller]);
|
||||
|
||||
const submitFinishedRunIfNeeded = useCallback(
|
||||
(nextSnapshot = controller.getSnapshot()) => {
|
||||
if (!isPublishedRuntime || nextSnapshot.phase !== 'finished') {
|
||||
return;
|
||||
}
|
||||
const activeRun = activeRunRef.current;
|
||||
if (!activeRun || submittedRunIdsRef.current.has(activeRun.runId)) {
|
||||
return;
|
||||
}
|
||||
submittedRunIdsRef.current.add(activeRun.runId);
|
||||
const finishedAt = new Date().toISOString();
|
||||
const startedAt =
|
||||
runStartedAtRef.current ?? activeRun.serverStartedAt ?? finishedAt;
|
||||
const durationMs = Math.max(
|
||||
0,
|
||||
runtimeConfigRef.current.roundDurationMs - nextSnapshot.remainingMs,
|
||||
);
|
||||
void finishBarkBattleRun(activeRun.runId, {
|
||||
runId: activeRun.runId,
|
||||
runToken: activeRun.runToken,
|
||||
workId: activeRun.workId,
|
||||
configVersion: activeRun.configVersion,
|
||||
rulesetVersion: activeRun.rulesetVersion,
|
||||
difficultyPreset: activeRun.difficultyPreset,
|
||||
clientStartedAt: startedAt,
|
||||
clientFinishedAt: finishedAt,
|
||||
durationMs,
|
||||
derivedMetrics: buildDerivedMetrics(),
|
||||
clientResult: resolveClientResult(nextSnapshot.winner),
|
||||
clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION,
|
||||
})
|
||||
.then(() => {
|
||||
appendDebugEvent('正式成绩已提交');
|
||||
})
|
||||
.catch((error) => {
|
||||
setRuntimeError(
|
||||
error instanceof Error ? error.message : '提交正式成绩失败',
|
||||
);
|
||||
appendDebugEvent('正式成绩提交失败');
|
||||
});
|
||||
},
|
||||
[
|
||||
appendDebugEvent,
|
||||
buildDerivedMetrics,
|
||||
controller,
|
||||
isPublishedRuntime,
|
||||
],
|
||||
);
|
||||
|
||||
const startFormalRunIfNeeded = useCallback(async (): Promise<boolean> => {
|
||||
if (!isPublishedRuntime || !replacementConfig?.workId) {
|
||||
return true;
|
||||
}
|
||||
if (activeRunRef.current) {
|
||||
return true;
|
||||
}
|
||||
if (!pendingRunStartRef.current) {
|
||||
pendingRunStartRef.current = (async () => {
|
||||
try {
|
||||
setRuntimeError(null);
|
||||
const started = await startBarkBattleRun(replacementConfig.workId, {
|
||||
// 中文注释:公开广场摘要可能滞后于已发布运行配置;正式开局以服务端当前发布快照为准。
|
||||
sourceRoute:
|
||||
typeof window === 'undefined'
|
||||
? 'bark-battle-runtime'
|
||||
: window.location.pathname,
|
||||
clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION,
|
||||
});
|
||||
const serverRuntimeConfig = buildRuntimeConfigFromServerConfig(
|
||||
started.runtimeConfig,
|
||||
);
|
||||
// 中文注释:公开卡片可能只带摘要;正式开局后用服务端 runtimeConfig 刷新拟声词和素材。
|
||||
setActivePublishedConfig((current) =>
|
||||
buildPublishedConfigFromServerRuntimeConfig(
|
||||
current ?? replacementConfig,
|
||||
started.runtimeConfig,
|
||||
),
|
||||
);
|
||||
runtimeConfigRef.current = serverRuntimeConfig;
|
||||
controller.updateConfigForActiveRound(serverRuntimeConfig);
|
||||
activeRunRef.current = {
|
||||
runId: started.runId,
|
||||
runToken: started.runToken,
|
||||
workId: started.workId,
|
||||
configVersion: started.configVersion,
|
||||
rulesetVersion: started.rulesetVersion,
|
||||
difficultyPreset: started.difficultyPreset,
|
||||
serverStartedAt: started.serverStartedAt,
|
||||
};
|
||||
runStartedAtRef.current = new Date().toISOString();
|
||||
appendDebugEvent(`正式对局已登记:${started.runId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : '启动正式对局失败';
|
||||
setRuntimeError(message);
|
||||
appendDebugEvent(message);
|
||||
return false;
|
||||
} finally {
|
||||
pendingRunStartRef.current = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return pendingRunStartRef.current ?? Promise.resolve(true);
|
||||
}, [appendDebugEvent, controller, isPublishedRuntime, replacementConfig]);
|
||||
|
||||
const syncSnapshot = useCallback(() => {
|
||||
const nextSnapshot = controller.getSnapshot();
|
||||
if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) {
|
||||
setPlayerPulseKey((current) => current + 1);
|
||||
playBarkSound();
|
||||
appendDebugEvent(`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`);
|
||||
recordRuntimeTrigger(nextSnapshot.player.power);
|
||||
flashOnomatopoeia();
|
||||
appendDebugEvent(
|
||||
`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`,
|
||||
);
|
||||
}
|
||||
if (nextSnapshot.phase === 'playing' && Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >= 0.08) {
|
||||
if (
|
||||
nextSnapshot.phase === 'playing' &&
|
||||
Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >=
|
||||
0.08
|
||||
) {
|
||||
setOpponentPulseKey((current) => current + 1);
|
||||
playBarkSound();
|
||||
appendDebugEvent(`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`);
|
||||
appendDebugEvent(
|
||||
`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`,
|
||||
);
|
||||
}
|
||||
lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount;
|
||||
lastOpponentPowerRef.current = nextSnapshot.opponent.power;
|
||||
setSnapshot(nextSnapshot);
|
||||
}, [appendDebugEvent, controller, playBarkSound]);
|
||||
submitFinishedRunIfNeeded(nextSnapshot);
|
||||
}, [
|
||||
appendDebugEvent,
|
||||
controller,
|
||||
flashOnomatopoeia,
|
||||
recordRuntimeTrigger,
|
||||
submitFinishedRunIfNeeded,
|
||||
]);
|
||||
|
||||
const stopMicrophone = useCallback(() => {
|
||||
microphoneSamplerRef.current?.stop();
|
||||
microphoneSamplerRef.current = null;
|
||||
}, []);
|
||||
|
||||
// 中文注释:领域层沿用 startMockRound 表示“进入对局倒计时”,正式/草稿输入差异由外层 sampler 控制。
|
||||
const startRuntimeRound = useCallback(() => {
|
||||
controller.startWithMockInput();
|
||||
}, [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
setConfig(initialConfig);
|
||||
runtimeConfigRef.current = initialConfig;
|
||||
controller.updateConfig(initialConfig);
|
||||
syncSnapshot();
|
||||
}, [controller, initialConfig, syncSnapshot]);
|
||||
setActivePublishedConfig(replacementConfig);
|
||||
}, [controller, initialConfig, replacementConfig]);
|
||||
|
||||
const startMicrophone = useCallback(async () => {
|
||||
stopMicrophone();
|
||||
let shouldAcceptMicrophoneSamples = false;
|
||||
try {
|
||||
controller.startWithMockInput();
|
||||
const sampler = await startBrowserMicrophoneSampler((volume, atMs) => {
|
||||
if (!shouldAcceptMicrophoneSamples) {
|
||||
return;
|
||||
}
|
||||
setLiveInputVolume(volume);
|
||||
recordRuntimeSample(volume);
|
||||
if (volume >= config.barkThreshold) {
|
||||
appendDebugEvent(`麦克风输入 ${(volume * 100).toFixed(0)}%`);
|
||||
}
|
||||
controller.submitInputSample(volume, atMs);
|
||||
controller.submitInputSample(
|
||||
volume,
|
||||
controller.getSampleClockMs() + atMs,
|
||||
);
|
||||
});
|
||||
if (!(await startFormalRunIfNeeded())) {
|
||||
sampler.stop();
|
||||
return;
|
||||
}
|
||||
startRuntimeRound();
|
||||
microphoneSamplerRef.current = sampler;
|
||||
setInputMode('microphone');
|
||||
shouldAcceptMicrophoneSamples = true;
|
||||
appendDebugEvent('真实麦克风已开启');
|
||||
syncSnapshot();
|
||||
} catch (error) {
|
||||
const reason = error && typeof error === 'object' && 'reason' in error ? error.reason : 'unknown';
|
||||
const failureReason = isMicrophoneFailureReason(reason) ? reason : 'unknown';
|
||||
const reason =
|
||||
error && typeof error === 'object' && 'reason' in error
|
||||
? error.reason
|
||||
: 'unknown';
|
||||
const failureReason = isMicrophoneFailureReason(reason)
|
||||
? reason
|
||||
: 'unknown';
|
||||
controller.failMicrophone(failureReason);
|
||||
appendDebugEvent(`麦克风不可用:${failureReason}`);
|
||||
syncSnapshot();
|
||||
}
|
||||
}, [appendDebugEvent, config.barkThreshold, controller, stopMicrophone, syncSnapshot]);
|
||||
}, [
|
||||
appendDebugEvent,
|
||||
config.barkThreshold,
|
||||
controller,
|
||||
recordRuntimeSample,
|
||||
startFormalRunIfNeeded,
|
||||
startRuntimeRound,
|
||||
stopMicrophone,
|
||||
syncSnapshot,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isPublishedRuntime ||
|
||||
snapshot.phase !== 'permission' ||
|
||||
autoStartMicrophoneAttemptedRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 中文注释:公开作品从详情页“启动”进入运行态后立即申请麦克风,授权成功后直接进入倒计时。
|
||||
autoStartMicrophoneAttemptedRef.current = true;
|
||||
void startMicrophone();
|
||||
}, [isPublishedRuntime, snapshot.phase, startMicrophone]);
|
||||
|
||||
useEffect(() => stopMicrophone, [stopMicrophone]);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeConfigRef.current = config;
|
||||
controller.updateConfig(config);
|
||||
syncSnapshot();
|
||||
}, [config, controller, syncSnapshot]);
|
||||
setSnapshot(controller.getSnapshot());
|
||||
}, [config, controller]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
controller.tick(100);
|
||||
if (inputMode === 'mock') {
|
||||
if (inputMode === 'mock' && !isPublishedRuntime) {
|
||||
if (heldRef.current) {
|
||||
recordRuntimeSample(0.88);
|
||||
controller.submitMockSample(0.88);
|
||||
} else {
|
||||
recordRuntimeSample(0.12);
|
||||
controller.submitMockSample(0.12);
|
||||
setLiveInputVolume(0);
|
||||
}
|
||||
@@ -213,31 +631,52 @@ export function BarkBattleRuntimeShell({
|
||||
syncSnapshot();
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [controller, inputMode, syncSnapshot]);
|
||||
}, [
|
||||
controller,
|
||||
inputMode,
|
||||
isPublishedRuntime,
|
||||
recordRuntimeSample,
|
||||
syncSnapshot,
|
||||
]);
|
||||
|
||||
const restart = () => {
|
||||
heldRef.current = false;
|
||||
stopMicrophone();
|
||||
setInputMode('mock');
|
||||
setInputMode(isPublishedRuntime ? 'microphone' : 'mock');
|
||||
setLiveInputVolume(0);
|
||||
controller.restart();
|
||||
setParticleText('');
|
||||
setPlayerBurstText(onomatopoeiaPool[0] ?? '炸场!');
|
||||
setDebugEvents([]);
|
||||
resetRuntimeRunState();
|
||||
autoStartMicrophoneAttemptedRef.current = false;
|
||||
lastPlayerBarkCountRef.current = 0;
|
||||
lastOpponentPowerRef.current = 0;
|
||||
syncSnapshot();
|
||||
};
|
||||
|
||||
const startMock = () => {
|
||||
const startMock = async () => {
|
||||
if (isPublishedRuntime) {
|
||||
const message = '正式对局需要使用真实麦克风';
|
||||
setRuntimeError(message);
|
||||
appendDebugEvent(message);
|
||||
return;
|
||||
}
|
||||
stopMicrophone();
|
||||
setInputMode('mock');
|
||||
setLiveInputVolume(0);
|
||||
controller.startWithMockInput();
|
||||
startRuntimeRound();
|
||||
appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)');
|
||||
syncSnapshot();
|
||||
};
|
||||
|
||||
const finishNow = () => {
|
||||
if (isPublishedRuntime && !activeRunRef.current) {
|
||||
const message = '正式对局需要使用真实麦克风';
|
||||
setRuntimeError(message);
|
||||
appendDebugEvent(message);
|
||||
return;
|
||||
}
|
||||
heldRef.current = false;
|
||||
stopMicrophone();
|
||||
controller.finishNow();
|
||||
@@ -245,89 +684,179 @@ export function BarkBattleRuntimeShell({
|
||||
syncSnapshot();
|
||||
};
|
||||
|
||||
const bark = () => {
|
||||
const bark = async () => {
|
||||
if (isPublishedRuntime) {
|
||||
const message = '正式对局需要使用真实麦克风';
|
||||
setRuntimeError(message);
|
||||
appendDebugEvent(message);
|
||||
return;
|
||||
}
|
||||
recordRuntimeSample(0.9);
|
||||
controller.forcePlayerBark(0.9);
|
||||
syncSnapshot();
|
||||
setParticleText('汪!');
|
||||
window.setTimeout(() => setParticleText(''), 680);
|
||||
};
|
||||
|
||||
const exitRuntime = () => {
|
||||
heldRef.current = false;
|
||||
stopMicrophone();
|
||||
onExit?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="bark-battle-runtime" aria-label={title}>
|
||||
{resolvedBarkSoundSrc ? (
|
||||
<audio ref={barkAudioRef} src={resolvedBarkSoundSrc} preload="auto" />
|
||||
{onExit ? (
|
||||
<button
|
||||
className="bark-battle-runtime__back-button"
|
||||
type="button"
|
||||
onClick={exitRuntime}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
) : null}
|
||||
<BarkBattleHud
|
||||
snapshot={snapshot}
|
||||
playerPulseKey={playerPulseKey}
|
||||
opponentPulseKey={opponentPulseKey}
|
||||
playerCharacterImageSrc={replacementConfig?.playerCharacterImageSrc}
|
||||
opponentCharacterImageSrc={replacementConfig?.opponentCharacterImageSrc}
|
||||
uiBackgroundImageSrc={replacementConfig?.uiBackgroundImageSrc}
|
||||
playerCharacterImageSrc={activePublishedConfig?.playerCharacterImageSrc}
|
||||
opponentCharacterImageSrc={activePublishedConfig?.opponentCharacterImageSrc}
|
||||
uiBackgroundImageSrc={activePublishedConfig?.uiBackgroundImageSrc}
|
||||
onStartMicrophone={startMicrophone}
|
||||
onMockBark={bark}
|
||||
onMockQuiet={() => {
|
||||
heldRef.current = false;
|
||||
}}
|
||||
onRestart={restart}
|
||||
enableMockControls={!isPublishedRuntime}
|
||||
runtimeError={shouldShowDebugPanel ? null : runtimeError}
|
||||
playerBurstText={playerBurstText}
|
||||
opponentBurstText="反击"
|
||||
/>
|
||||
<aside className={`bark-battle-debug-panel${isDebugExpanded ? ' bark-battle-debug-panel--expanded' : ''}`} aria-label="调试面板">
|
||||
<header>
|
||||
<strong>调试面板</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="bark-battle-debug-panel__toggle"
|
||||
aria-expanded={isDebugExpanded}
|
||||
onClick={() => setIsDebugExpanded((current) => !current)}
|
||||
{shouldShowDebugPanel ? (
|
||||
<aside
|
||||
className={`bark-battle-debug-panel${isDebugExpanded ? ' bark-battle-debug-panel--expanded' : ''}`}
|
||||
aria-label="调试面板"
|
||||
>
|
||||
<header>
|
||||
<strong>调试面板</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="bark-battle-debug-panel__toggle"
|
||||
aria-expanded={isDebugExpanded}
|
||||
onClick={() => setIsDebugExpanded((current) => !current)}
|
||||
>
|
||||
{isDebugExpanded ? '收起' : '展开'}
|
||||
</button>
|
||||
<span>{snapshot.phase}</span>
|
||||
</header>
|
||||
<div className="bark-battle-debug-panel__body">
|
||||
<div className="bark-battle-debug-panel__controls">
|
||||
<button type="button" onClick={startMock}>
|
||||
开始
|
||||
</button>
|
||||
<button type="button" onClick={finishNow}>
|
||||
结束
|
||||
</button>
|
||||
<button type="button" onClick={restart}>
|
||||
重置
|
||||
</button>
|
||||
{onExit ? (
|
||||
<button type="button" onClick={onExit}>
|
||||
返回配置
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{workId ? (
|
||||
<p className="bark-battle-debug-panel__work-id">作品:{workId}</p>
|
||||
) : null}
|
||||
{runtimeError ? (
|
||||
<p className="bark-battle-debug-panel__work-id" role="alert">
|
||||
{runtimeError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="bark-battle-debug-metrics" aria-label="触发反馈">
|
||||
<span className="bark-battle-debug-metrics__wide">
|
||||
输入模式:
|
||||
{inputMode === 'microphone' ? '真实麦克风' : 'Mock 输入'}
|
||||
</span>
|
||||
<span>实时音量:{(liveInputVolume * 100).toFixed(0)}%</span>
|
||||
<span>采样时钟:{controller.getSampleClockMs()}ms</span>
|
||||
<span>玩家触发:{snapshot.player.barkCount}</span>
|
||||
<span>玩家强度:{(snapshot.player.power * 100).toFixed(0)}%</span>
|
||||
<span>对手强度:{(snapshot.opponent.power * 100).toFixed(0)}%</span>
|
||||
<span>能量:{Math.round(snapshot.energy)}</span>
|
||||
</div>
|
||||
<ol className="bark-battle-debug-events" aria-label="触发日志">
|
||||
{debugEvents.length ? (
|
||||
debugEvents.map((event) => <li key={event.id}>{event.text}</li>)
|
||||
) : (
|
||||
<li>等待输入触发</li>
|
||||
)}
|
||||
</ol>
|
||||
{DEBUG_CONFIG_FIELDS.map((field) => (
|
||||
<label key={field.key}>
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
aria-label={field.label}
|
||||
type="range"
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
value={config[field.key]}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.currentTarget.value);
|
||||
setConfig((current) => ({ ...current, [field.key]: value }));
|
||||
}}
|
||||
/>
|
||||
<output>{config[field.key]}</output>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
{particleText ? (
|
||||
<div className="bark-battle-particles">{particleText}</div>
|
||||
) : null}
|
||||
{snapshot.result ? (
|
||||
<div className="bark-battle-result-modal" role="presentation">
|
||||
<section
|
||||
className="bark-battle-result bark-battle-result--modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="对战结算"
|
||||
>
|
||||
{isDebugExpanded ? '收起' : '展开'}
|
||||
</button>
|
||||
<span>{snapshot.phase}</span>
|
||||
</header>
|
||||
<div className="bark-battle-debug-panel__body">
|
||||
<div className="bark-battle-debug-panel__controls">
|
||||
<button type="button" onClick={startMock}>开始</button>
|
||||
<button type="button" onClick={finishNow}>结束</button>
|
||||
<button type="button" onClick={restart}>重置</button>
|
||||
{onExit ? <button type="button" onClick={onExit}>返回配置</button> : null}
|
||||
</div>
|
||||
{workId ? (
|
||||
<p className="bark-battle-debug-panel__work-id">作品:{workId}</p>
|
||||
) : null}
|
||||
<div className="bark-battle-debug-metrics" aria-label="触发反馈">
|
||||
<span className="bark-battle-debug-metrics__wide">输入模式:{inputMode === 'microphone' ? '真实麦克风' : 'Mock 输入'}</span>
|
||||
<span>实时音量:{(liveInputVolume * 100).toFixed(0)}%</span>
|
||||
<span>采样时钟:{controller.getSampleClockMs()}ms</span>
|
||||
<span>玩家触发:{snapshot.player.barkCount}</span>
|
||||
<span>玩家强度:{(snapshot.player.power * 100).toFixed(0)}%</span>
|
||||
<span>对手强度:{(snapshot.opponent.power * 100).toFixed(0)}%</span>
|
||||
<span>能量:{Math.round(snapshot.energy)}</span>
|
||||
</div>
|
||||
<ol className="bark-battle-debug-events" aria-label="触发日志">
|
||||
{debugEvents.length ? debugEvents.map((event) => <li key={event.id}>{event.text}</li>) : <li>等待输入触发</li>}
|
||||
</ol>
|
||||
{DEBUG_CONFIG_FIELDS.map((field) => (
|
||||
<label key={field.key}>
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
aria-label={field.label}
|
||||
type="range"
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
value={config[field.key]}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.currentTarget.value);
|
||||
setConfig((current) => ({ ...current, [field.key]: value }));
|
||||
}}
|
||||
/>
|
||||
<output>{config[field.key]}</output>
|
||||
</label>
|
||||
))}
|
||||
<p className="bark-battle-result__eyebrow">本局结束</p>
|
||||
<h2>{resolveResultTitle(snapshot.result.winner)}</h2>
|
||||
<div className="bark-battle-result__stats">
|
||||
<span>
|
||||
<strong>{snapshot.result.playerBarkCount}</strong>
|
||||
玩家叫声
|
||||
</span>
|
||||
<span>
|
||||
<strong>{snapshot.result.opponentBarkCount}</strong>
|
||||
对手压制
|
||||
</span>
|
||||
<span>
|
||||
<strong>{snapshot.result.score}</strong>
|
||||
声浪分
|
||||
</span>
|
||||
</div>
|
||||
<div className="bark-battle-result__actions">
|
||||
{onExit ? (
|
||||
<button type="button" onClick={exitRuntime}>
|
||||
返回
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="bark-battle-primary-button"
|
||||
type="button"
|
||||
onClick={restart}
|
||||
>
|
||||
再来一局
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
{particleText ? <div className="bark-battle-particles">{particleText}</div> : null}
|
||||
{snapshot.result ? <BarkBattleResultPanel result={snapshot.result} onRestart={restart} /> : null}
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
BarkBattlePublishedConfig,
|
||||
BarkBattleRunStartResponse,
|
||||
} from '../../../../../packages/shared/src/contracts/barkBattle';
|
||||
import { BarkBattleRuntimeShell } from '../BarkBattleRuntimeShell';
|
||||
|
||||
vi.mock('../../../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: (source: string | null | undefined) => ({
|
||||
resolvedUrl: source ?? '',
|
||||
isResolving: false,
|
||||
shouldResolve: false,
|
||||
}),
|
||||
const runtimeClientMock = vi.hoisted(() => ({
|
||||
startBarkBattleRun: vi.fn(),
|
||||
finishBarkBattleRun: vi.fn(),
|
||||
}));
|
||||
|
||||
const microphoneInputMock = vi.hoisted(() => ({
|
||||
startBrowserMicrophoneSampler: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../services/bark-battle-runtime', () => runtimeClientMock);
|
||||
vi.mock('../../infrastructure/BrowserMicrophoneInput', () => microphoneInputMock);
|
||||
|
||||
vi.mock('../../../../components/ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
@@ -25,35 +33,192 @@ vi.mock('../../../../components/ResolvedAssetImage', () => ({
|
||||
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
|
||||
}));
|
||||
|
||||
function createPublishedConfig(
|
||||
overrides: Partial<BarkBattlePublishedConfig> = {},
|
||||
): BarkBattlePublishedConfig {
|
||||
return {
|
||||
workId: 'work-bark-1',
|
||||
draftId: 'draft-bark-1',
|
||||
configVersion: 2,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
playTypeId: 'bark-battle',
|
||||
title: '周末狗狗杯',
|
||||
description: '公开汪汪声浪作品',
|
||||
themeDescription: '霓虹城市公园里的声浪擂台',
|
||||
playerImageDescription: '戴红围巾的柴犬主角',
|
||||
opponentImageDescription: '戴蓝色头带的哈士奇对手',
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
|
||||
difficultyPreset: 'hard',
|
||||
updatedAt: '2026-05-13T03:00:00.000Z',
|
||||
publishedAt: '2026-05-13T03:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRunStartResponse(
|
||||
overrides: Partial<BarkBattleRunStartResponse> = {},
|
||||
): BarkBattleRunStartResponse {
|
||||
const publishedConfig = createPublishedConfig();
|
||||
return {
|
||||
runId: 'run-bark-1',
|
||||
runToken: 'token-bark-1',
|
||||
workId: publishedConfig.workId,
|
||||
configVersion: publishedConfig.configVersion,
|
||||
rulesetVersion: publishedConfig.rulesetVersion,
|
||||
difficultyPreset: publishedConfig.difficultyPreset,
|
||||
runtimeConfig: {
|
||||
workId: publishedConfig.workId,
|
||||
configVersion: publishedConfig.configVersion,
|
||||
rulesetVersion: publishedConfig.rulesetVersion,
|
||||
playTypeId: 'bark-battle',
|
||||
durationMs: 30000,
|
||||
energyMin: 0,
|
||||
energyMax: 100,
|
||||
drawThreshold: 12,
|
||||
minBarkGapMs: 150,
|
||||
difficultyPreset: publishedConfig.difficultyPreset,
|
||||
themeDescription: publishedConfig.themeDescription,
|
||||
playerImageDescription: publishedConfig.playerImageDescription,
|
||||
opponentImageDescription: publishedConfig.opponentImageDescription,
|
||||
playerCharacterImageSrc: publishedConfig.playerCharacterImageSrc,
|
||||
opponentCharacterImageSrc: publishedConfig.opponentCharacterImageSrc,
|
||||
uiBackgroundImageSrc: publishedConfig.uiBackgroundImageSrc,
|
||||
updatedAt: publishedConfig.updatedAt,
|
||||
},
|
||||
serverStartedAt: '2026-05-13T03:00:00.000Z',
|
||||
expiresAt: '2026-05-13T03:10:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BarkBattleRuntimeShell 调试面板', () => {
|
||||
it('从发布配置加载自定义狗叫音效资源', () => {
|
||||
afterEach(() => {
|
||||
runtimeClientMock.startBarkBattleRun.mockReset();
|
||||
runtimeClientMock.finishBarkBattleRun.mockReset();
|
||||
microphoneInputMock.startBrowserMicrophoneSampler.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('发布配置只渲染视觉素材', async () => {
|
||||
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
|
||||
stop: vi.fn(),
|
||||
});
|
||||
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
|
||||
createRunStartResponse(),
|
||||
);
|
||||
|
||||
render(
|
||||
<BarkBattleRuntimeShell
|
||||
publishedConfig={{
|
||||
workId: 'work-bark-1',
|
||||
draftId: 'draft-bark-1',
|
||||
configVersion: 2,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
playTypeId: 'bark-battle',
|
||||
title: '周末狗狗杯',
|
||||
themePreset: 'neon-park',
|
||||
playerDogSkinPreset: 'shiba',
|
||||
opponentDogSkinPreset: 'husky',
|
||||
playerCharacterImageSrc: '/generated-bark-battle/player.png',
|
||||
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
|
||||
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
|
||||
barkSoundSrc: '/generated-bark-battle/bark.mp3',
|
||||
difficultyPreset: 'hard',
|
||||
leaderboardEnabled: true,
|
||||
updatedAt: '2026-05-13T03:00:00.000Z',
|
||||
publishedAt: '2026-05-13T03:00:00.000Z',
|
||||
}}
|
||||
runtimeMode="published"
|
||||
publishedConfig={createPublishedConfig()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('audio[src="/generated-bark-battle/bark.mp3"]')).toBeTruthy();
|
||||
expect(document.querySelector('img[src="/generated-bark-battle/player.png"]')).toBeTruthy();
|
||||
expect(document.querySelector('img[src="/generated-bark-battle/ui.png"]')).toBeTruthy();
|
||||
expect(document.querySelector('audio')).toBeNull();
|
||||
expect(
|
||||
document.querySelector('img[src="/generated-bark-battle/player.png"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector('img[src="/generated-bark-battle/ui.png"]'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByLabelText('调试面板')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '模拟叫声' })).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
microphoneInputMock.startBrowserMicrophoneSampler,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('草稿调试参数中难度只覆盖对手基础力,不改阈值和平局线', async () => {
|
||||
render(
|
||||
<BarkBattleRuntimeShell
|
||||
runtimeMode="draft"
|
||||
publishedConfig={createPublishedConfig({ difficultyPreset: 'hard' })}
|
||||
/>,
|
||||
);
|
||||
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
await userEvent.click(
|
||||
within(debugPanel).getByRole('button', { name: '展开' }),
|
||||
);
|
||||
|
||||
expect((screen.getByLabelText('叫声阈值') as HTMLInputElement).value).toBe(
|
||||
'0.35',
|
||||
);
|
||||
expect((screen.getByLabelText('平局阈值') as HTMLInputElement).value).toBe(
|
||||
'12',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('叫声间隔(ms)') as HTMLInputElement).value,
|
||||
).toBe('150');
|
||||
expect(
|
||||
(screen.getByLabelText('对手基础力') as HTMLInputElement).value,
|
||||
).toBe('0.3');
|
||||
expect(runtimeClientMock.startBarkBattleRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('发布配置使用自定义拟声词池并在连续触发时随机展示', async () => {
|
||||
vi
|
||||
.spyOn(Math, 'random')
|
||||
.mockReturnValueOnce(0.1)
|
||||
.mockReturnValueOnce(0.7);
|
||||
const onomatopoeia = ['炸场!', '冲啊!', '破阵!'];
|
||||
render(
|
||||
<BarkBattleRuntimeShell
|
||||
runtimeMode="draft"
|
||||
publishedConfig={createPublishedConfig({ onomatopoeia })}
|
||||
/>,
|
||||
);
|
||||
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
await userEvent.click(
|
||||
within(debugPanel).getByRole('button', { name: '展开' }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
|
||||
const firstBurst = screen.getByLabelText('玩家声浪角色背对屏幕')
|
||||
.textContent;
|
||||
expect(onomatopoeia.some((word) => firstBurst?.includes(word))).toBe(true);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
|
||||
const nextBurst = screen.getByLabelText('玩家声浪角色背对屏幕')
|
||||
.textContent;
|
||||
expect(onomatopoeia.some((word) => nextBurst?.includes(word))).toBe(true);
|
||||
expect(screen.queryByText('汪!')).toBeNull();
|
||||
});
|
||||
|
||||
it('没有自定义拟声词时根据主题使用更燥的默认拟声词池', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.2);
|
||||
render(
|
||||
<BarkBattleRuntimeShell
|
||||
runtimeMode="draft"
|
||||
publishedConfig={createPublishedConfig({
|
||||
themeDescription: '星舰机甲擂台,等离子音浪爆发',
|
||||
playerImageDescription: '星际猫骑士',
|
||||
opponentImageDescription: '机器人拳手',
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
await userEvent.click(
|
||||
within(debugPanel).getByRole('button', { name: '展开' }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
|
||||
|
||||
const burstText = screen.getByLabelText('玩家声浪角色背对屏幕')
|
||||
.textContent;
|
||||
expect(
|
||||
['能量爆裂!', '超频!', '电光轰鸣!', '雷鸣!'].some((word) =>
|
||||
burstText?.includes(word),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(screen.queryByText('汪!')).toBeNull();
|
||||
});
|
||||
|
||||
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
|
||||
@@ -61,10 +226,16 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
|
||||
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
expect(debugPanel).toBeTruthy();
|
||||
expect(within(debugPanel).getByRole('button', { name: '展开' })).toBeTruthy();
|
||||
expect(
|
||||
within(debugPanel).getByRole('button', { name: '展开' }),
|
||||
).toBeTruthy();
|
||||
|
||||
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' }));
|
||||
expect(within(debugPanel).getByRole('button', { name: '收起' })).toBeTruthy();
|
||||
await userEvent.click(
|
||||
within(debugPanel).getByRole('button', { name: '展开' }),
|
||||
);
|
||||
expect(
|
||||
within(debugPanel).getByRole('button', { name: '收起' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '开始' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '结束' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '重置' })).toBeTruthy();
|
||||
@@ -83,18 +254,321 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
|
||||
});
|
||||
|
||||
it('真实声控入口在不支持麦克风时展示失败原因,mock 开始不请求权限', async () => {
|
||||
microphoneInputMock.startBrowserMicrophoneSampler.mockRejectedValueOnce(
|
||||
Object.assign(new Error('unsupported'), { reason: 'unsupported' }),
|
||||
);
|
||||
|
||||
render(<BarkBattleRuntimeShell />);
|
||||
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' }));
|
||||
await userEvent.click(
|
||||
within(debugPanel).getByRole('button', { name: '展开' }),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始声控' }));
|
||||
|
||||
expect(screen.getByText('当前浏览器不支持麦克风输入')).toBeTruthy();
|
||||
expect(screen.getAllByText(/麦克风不可用:unsupported/u).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByText(/麦克风不可用:unsupported/u).length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始' }));
|
||||
expect(screen.getAllByText(/开始 mock 对局(不会请求浏览器麦克风权限)/u).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByText(/开始 mock 对局(不会请求浏览器麦克风权限)/u).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/输入模式:Mock 输入/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('草稿试玩不会登记正式对局', async () => {
|
||||
render(<BarkBattleRuntimeShell />);
|
||||
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
await userEvent.click(
|
||||
within(debugPanel).getByRole('button', { name: '展开' }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始' }));
|
||||
|
||||
expect(runtimeClientMock.startBarkBattleRun).not.toHaveBeenCalled();
|
||||
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('发布态不渲染 mock 控制和调试面板,并自动申请麦克风权限', async () => {
|
||||
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
|
||||
stop: vi.fn(),
|
||||
});
|
||||
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
|
||||
createRunStartResponse(),
|
||||
);
|
||||
|
||||
render(
|
||||
<BarkBattleRuntimeShell
|
||||
runtimeMode="published"
|
||||
publishedConfig={createPublishedConfig()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('调试面板')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '开始' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '结束' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '模拟叫声' })).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
microphoneInputMock.startBrowserMicrophoneSampler,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
|
||||
});
|
||||
expect(
|
||||
runtimeClientMock.startBarkBattleRun.mock.calls[0]?.[0],
|
||||
).toBe('work-bark-1');
|
||||
expect(createRunStartResponse().runtimeConfig.minBarkGapMs).toBe(150);
|
||||
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('发布态进入运行态后展示可点击的返回按钮', async () => {
|
||||
const handleExit = vi.fn();
|
||||
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
|
||||
stop: vi.fn(),
|
||||
});
|
||||
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
|
||||
createRunStartResponse(),
|
||||
);
|
||||
|
||||
render(
|
||||
<BarkBattleRuntimeShell
|
||||
runtimeMode="published"
|
||||
publishedConfig={createPublishedConfig()}
|
||||
onExit={handleExit}
|
||||
/>,
|
||||
);
|
||||
|
||||
const backButton = screen.getByRole('button', { name: '返回' });
|
||||
await userEvent.click(backButton);
|
||||
|
||||
expect(handleExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('结束后弹出独立结果弹窗,并提供返回和再来一局', async () => {
|
||||
const handleExit = vi.fn();
|
||||
render(<BarkBattleRuntimeShell onExit={handleExit} />);
|
||||
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
await userEvent.click(
|
||||
within(debugPanel).getByRole('button', { name: '展开' }),
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: '结束' }));
|
||||
|
||||
const resultDialog = screen.getByRole('dialog', { name: '对战结算' });
|
||||
expect(resultDialog.getAttribute('aria-modal')).toBe('true');
|
||||
expect(resultDialog.closest('.bark-battle-hud')).toBeNull();
|
||||
expect(within(resultDialog).getByText('本局结束')).toBeTruthy();
|
||||
expect(within(resultDialog).getByText('玩家叫声')).toBeTruthy();
|
||||
expect(within(resultDialog).getByText('对手压制')).toBeTruthy();
|
||||
expect(within(resultDialog).getByText('声浪分')).toBeTruthy();
|
||||
expect(
|
||||
within(resultDialog).getByRole('button', { name: '再来一局' }),
|
||||
).toBeTruthy();
|
||||
|
||||
await userEvent.click(
|
||||
within(resultDialog).getByRole('button', { name: '返回' }),
|
||||
);
|
||||
expect(handleExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('发布态麦克风失败不会登记正式对局', async () => {
|
||||
microphoneInputMock.startBrowserMicrophoneSampler.mockRejectedValueOnce(
|
||||
Object.assign(new Error('permission-denied'), {
|
||||
reason: 'permission-denied',
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<BarkBattleRuntimeShell
|
||||
runtimeMode="published"
|
||||
publishedConfig={createPublishedConfig()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('麦克风授权被拒绝')).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByLabelText('调试面板')).toBeNull();
|
||||
expect(runtimeClientMock.startBarkBattleRun).not.toHaveBeenCalled();
|
||||
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('发布态启动后会直接申请麦克风权限,授权成功后登记 start run 并进入倒计时', async () => {
|
||||
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
|
||||
stop: vi.fn(),
|
||||
});
|
||||
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
|
||||
createRunStartResponse(),
|
||||
);
|
||||
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
|
||||
new Promise(() => {}),
|
||||
);
|
||||
|
||||
render(
|
||||
<BarkBattleRuntimeShell
|
||||
runtimeMode="published"
|
||||
publishedConfig={createPublishedConfig()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalledWith(
|
||||
'work-bark-1',
|
||||
expect.objectContaining({
|
||||
sourceRoute: expect.any(String),
|
||||
clientRuntimeVersion: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/倒计时/u)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '开始声控' })).toBeNull();
|
||||
expect(screen.queryByLabelText('调试面板')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '模拟叫声' })).toBeNull();
|
||||
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('发布态正式对局使用 start run 返回的服务端 runtimeConfig', async () => {
|
||||
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
|
||||
stop: vi.fn(),
|
||||
});
|
||||
const started = createRunStartResponse();
|
||||
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce({
|
||||
...started,
|
||||
runtimeConfig: {
|
||||
...started.runtimeConfig,
|
||||
durationMs: 1000,
|
||||
drawThreshold: 3,
|
||||
minBarkGapMs: 150,
|
||||
},
|
||||
});
|
||||
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
|
||||
new Promise(() => {}),
|
||||
);
|
||||
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<BarkBattleRuntimeShell
|
||||
runtimeMode="published"
|
||||
publishedConfig={createPublishedConfig()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(runtimeClientMock.finishBarkBattleRun).toHaveBeenCalledWith(
|
||||
'run-bark-1',
|
||||
expect.objectContaining({
|
||||
durationMs: 1000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('发布态正式对局使用服务端 runtimeConfig 刷新自定义拟声词和素材', async () => {
|
||||
microphoneInputMock.startBrowserMicrophoneSampler.mockImplementationOnce(
|
||||
async (onSample: (volume: number, atMs: number) => void) => {
|
||||
onSample(0.9, 0);
|
||||
return { stop: vi.fn() };
|
||||
},
|
||||
);
|
||||
const started = createRunStartResponse();
|
||||
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce({
|
||||
...started,
|
||||
runtimeConfig: {
|
||||
...started.runtimeConfig,
|
||||
onomatopoeia: ['喵能爆裂!'],
|
||||
playerCharacterImageSrc: '/server/player.png',
|
||||
opponentCharacterImageSrc: '/server/opponent.png',
|
||||
uiBackgroundImageSrc: '/server/background.png',
|
||||
},
|
||||
});
|
||||
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
|
||||
new Promise(() => {}),
|
||||
);
|
||||
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<BarkBattleRuntimeShell
|
||||
runtimeMode="published"
|
||||
publishedConfig={createPublishedConfig({ onomatopoeia: undefined })}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3100);
|
||||
});
|
||||
|
||||
expect(
|
||||
document.querySelector('img[src="/server/player.png"]'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('喵能爆裂!')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('发布态真实麦克风对局结算后提交派生指标', async () => {
|
||||
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
|
||||
stop: vi.fn(),
|
||||
});
|
||||
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
|
||||
createRunStartResponse(),
|
||||
);
|
||||
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
|
||||
new Promise(() => {}),
|
||||
);
|
||||
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<BarkBattleRuntimeShell
|
||||
runtimeMode="published"
|
||||
publishedConfig={createPublishedConfig()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(34_000);
|
||||
});
|
||||
|
||||
expect(runtimeClientMock.finishBarkBattleRun).toHaveBeenCalledWith(
|
||||
'run-bark-1',
|
||||
expect.objectContaining({
|
||||
runId: 'run-bark-1',
|
||||
runToken: 'token-bark-1',
|
||||
workId: 'work-bark-1',
|
||||
configVersion: 2,
|
||||
rulesetVersion: 'bark-battle-ruleset-v1',
|
||||
difficultyPreset: 'hard',
|
||||
derivedMetrics: expect.objectContaining({
|
||||
triggerCount: expect.any(Number),
|
||||
finalEnergy: expect.any(Number),
|
||||
}),
|
||||
clientResult: expect.any(String),
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user