import { useCallback, useEffect, useRef, useState } from 'react'; import { type BarkBattleConfig, 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'; import { BarkBattleResultPanel } from './BarkBattleResultPanel'; type BarkBattleRuntimeShellProps = { title?: string; }; type DebugEvent = { id: number; text: string; }; 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); } export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: BarkBattleRuntimeShellProps) { const [config, setConfig] = useState(DEFAULT_BARK_BATTLE_CONFIG); 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 [inputMode, setInputMode] = useState<'mock' | 'microphone'>('mock'); 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 heldRef = useRef(false); const lastPlayerBarkCountRef = useRef(0); const lastOpponentPowerRef = useRef(0); const debugEventIdRef = useRef(0); const microphoneSamplerRef = useRef(null); const appendDebugEvent = useCallback((text: string) => { debugEventIdRef.current += 1; const event = { id: debugEventIdRef.current, text }; setDebugEvents((current) => [event, ...current].slice(0, 5)); }, []); const syncSnapshot = useCallback(() => { const nextSnapshot = controller.getSnapshot(); if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) { setPlayerPulseKey((current) => current + 1); 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); }, [appendDebugEvent, controller]); const stopMicrophone = useCallback(() => { microphoneSamplerRef.current?.stop(); microphoneSamplerRef.current = null; }, []); const startMicrophone = useCallback(async () => { stopMicrophone(); try { controller.startWithMockInput(); const sampler = await startBrowserMicrophoneSampler((volume, atMs) => { setLiveInputVolume(volume); if (volume >= config.barkThreshold) { appendDebugEvent(`麦克风输入 ${(volume * 100).toFixed(0)}%`); } controller.submitInputSample(volume, atMs); }); microphoneSamplerRef.current = sampler; setInputMode('microphone'); 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, stopMicrophone, syncSnapshot]); useEffect(() => stopMicrophone, [stopMicrophone]); useEffect(() => { controller.updateConfig(config); syncSnapshot(); }, [config, controller, syncSnapshot]); useEffect(() => { const timer = window.setInterval(() => { controller.tick(100); if (inputMode === 'mock') { if (heldRef.current) { controller.submitMockSample(0.88); } else { controller.submitMockSample(0.12); setLiveInputVolume(0); } } syncSnapshot(); }, 100); return () => window.clearInterval(timer); }, [controller, inputMode, syncSnapshot]); const restart = () => { heldRef.current = false; stopMicrophone(); setInputMode('mock'); setLiveInputVolume(0); controller.restart(); setParticleText(''); setDebugEvents([]); lastPlayerBarkCountRef.current = 0; lastOpponentPowerRef.current = 0; syncSnapshot(); }; const startMock = () => { stopMicrophone(); setInputMode('mock'); setLiveInputVolume(0); controller.startWithMockInput(); appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)'); syncSnapshot(); }; const finishNow = () => { heldRef.current = false; stopMicrophone(); controller.finishNow(); appendDebugEvent('人工结束对局'); syncSnapshot(); }; const bark = () => { controller.forcePlayerBark(0.9); syncSnapshot(); setParticleText('汪!'); window.setTimeout(() => setParticleText(''), 680); }; return (
{ heldRef.current = false; }} onRestart={restart} /> {particleText ?
{particleText}
: null} {snapshot.result ? : null}
); }