fix: polish bark battle creation flow

This commit is contained in:
kdletters
2026-05-22 05:00:07 +08:00
parent 01da85a577
commit bf82f04b64
73 changed files with 9362 additions and 2663 deletions

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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);
});
});

View File

@@ -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 = {

View File

@@ -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', () => {

View File

@@ -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; }
}

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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();
});
});