import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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'; import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes'; import { type BrowserMicrophoneSampler, startBrowserMicrophoneSampler, } from '../infrastructure/BrowserMicrophoneInput'; import { BarkBattleHud } from './BarkBattleHud'; type BarkBattleRuntimeMode = 'draft' | 'published'; type BarkBattleRuntimeShellProps = { title?: string; workId?: string; publishedConfig?: BarkBattlePublishedConfig | null; runtimeMode?: BarkBattleRuntimeMode; onExit?: () => void; }; type DebugEvent = { id: number; 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, | 'roundDurationMs' | 'countdownMs' | 'drawThreshold' | 'barkThreshold' | 'minBarkGapMs' | 'balanceFactor' | 'opponentBasePower' >; label: string; min: number; max: number; step: number; }> = [ { 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 }, { key: 'minBarkGapMs', label: '叫声间隔(ms)', min: 100, max: 1200, step: 50 }, { key: 'balanceFactor', label: '拉锯速度', min: 5, max: 80, step: 1 }, { key: 'opponentBasePower', label: '对手基础力', min: 0, max: 1, step: 0.05 }, ]; const MICROPHONE_FAILURE_REASONS = new Set([ 'unsupported', 'permission-denied', 'non-secure-context', 'not-found', 'not-readable', 'audio-context-blocked', 'calibration-timeout', 'calibration-sample-unreadable', 'unknown', ]); function isMicrophoneFailureReason( reason: unknown, ): reason is MicrophoneFailureReason { return ( typeof reason === 'string' && MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason) ); } function buildRuntimeConfigFromPublishedConfig( publishedConfig?: BarkBattlePublishedConfig | null, ): BarkBattleConfig { if (!publishedConfig) { return DEFAULT_BARK_BATTLE_CONFIG; } const difficultyOverrides: Record< BarkBattlePublishedConfig['difficultyPreset'], Partial > = { easy: { opponentBasePower: 0.16 }, normal: { opponentBasePower: 0.22 }, hard: { opponentBasePower: 0.3 }, }; return { ...DEFAULT_BARK_BATTLE_CONFIG, ...difficultyOverrides[publishedConfig.difficultyPreset], }; } 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( () => buildRuntimeConfigFromPublishedConfig(publishedConfig), [publishedConfig], ); const [config, setConfig] = useState(initialConfig); const runtimeConfigRef = useRef(initialConfig); const controllerRef = useRef(null); if (!controllerRef.current) { controllerRef.current = new BarkBattleController(config); } const controller = controllerRef.current; const [snapshot, setSnapshot] = useState(() => controller.getSnapshot()); const [particleText, setParticleText] = useState(''); 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([]); const [runtimeError, setRuntimeError] = useState(null); const heldRef = useRef(false); const lastPlayerBarkCountRef = useRef(0); const lastOpponentPowerRef = useRef(0); const debugEventIdRef = useRef(0); const microphoneSamplerRef = useRef(null); const activeRunRef = useRef(null); const pendingRunStartRef = useRef | null>(null); const runStartedAtRef = useRef(null); const submittedRunIdsRef = useRef>(new Set()); const autoStartMicrophoneAttemptedRef = useRef(false); const metricAccumulatorRef = useRef( createMetricAccumulator(), ); const lastOnomatopoeiaRef = useRef(''); // 中文注释:正式公开 runtime 面向玩家,只保留真实麦克风入口;mock 与调参面板只服务草稿试玩。 const shouldShowDebugPanel = !isPublishedRuntime; useEffect(() => { lastOnomatopoeiaRef.current = ''; setPlayerBurstText(onomatopoeiaPool[0] ?? '炸场!'); }, [onomatopoeiaPool]); const appendDebugEvent = useCallback((text: string) => { debugEventIdRef.current += 1; const event = { id: debugEventIdRef.current, text }; 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 => { 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); 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 ) { setOpponentPulseKey((current) => current + 1); appendDebugEvent( `对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`, ); } lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount; lastOpponentPowerRef.current = nextSnapshot.opponent.power; setSnapshot(nextSnapshot); 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); setActivePublishedConfig(replacementConfig); }, [controller, initialConfig, replacementConfig]); const startMicrophone = useCallback(async () => { stopMicrophone(); let shouldAcceptMicrophoneSamples = false; try { 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, 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'; controller.failMicrophone(failureReason); appendDebugEvent(`麦克风不可用:${failureReason}`); 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); setSnapshot(controller.getSnapshot()); }, [config, controller]); useEffect(() => { const timer = window.setInterval(() => { controller.tick(100); if (inputMode === 'mock' && !isPublishedRuntime) { if (heldRef.current) { recordRuntimeSample(0.88); controller.submitMockSample(0.88); } else { recordRuntimeSample(0.12); controller.submitMockSample(0.12); setLiveInputVolume(0); } } syncSnapshot(); }, 100); return () => window.clearInterval(timer); }, [ controller, inputMode, isPublishedRuntime, recordRuntimeSample, syncSnapshot, ]); const restart = () => { heldRef.current = false; stopMicrophone(); 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 = async () => { if (isPublishedRuntime) { const message = '正式对局需要使用真实麦克风'; setRuntimeError(message); appendDebugEvent(message); return; } stopMicrophone(); setInputMode('mock'); setLiveInputVolume(0); startRuntimeRound(); appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)'); syncSnapshot(); }; const finishNow = () => { if (isPublishedRuntime && !activeRunRef.current) { const message = '正式对局需要使用真实麦克风'; setRuntimeError(message); appendDebugEvent(message); return; } heldRef.current = false; stopMicrophone(); controller.finishNow(); appendDebugEvent('人工结束对局'); syncSnapshot(); }; const bark = async () => { if (isPublishedRuntime) { const message = '正式对局需要使用真实麦克风'; setRuntimeError(message); appendDebugEvent(message); return; } recordRuntimeSample(0.9); controller.forcePlayerBark(0.9); syncSnapshot(); }; const exitRuntime = () => { heldRef.current = false; stopMicrophone(); onExit?.(); }; return (
{onExit ? ( ) : null} { heldRef.current = false; }} onRestart={restart} enableMockControls={!isPublishedRuntime} runtimeError={shouldShowDebugPanel ? null : runtimeError} playerBurstText={playerBurstText} opponentBurstText="反击" /> {shouldShowDebugPanel ? ( ) : null} {particleText ? (
{particleText}
) : null} {snapshot.result ? (

本局结束

{resolveResultTitle(snapshot.result.winner)}

{snapshot.result.playerBarkCount} 玩家叫声 {snapshot.result.opponentBarkCount} 对手压制 {snapshot.result.score} 声浪分
{onExit ? ( ) : null}
) : null}
); }