feat: complete bark battle playable demo

This commit is contained in:
2026-05-12 14:42:58 +08:00
parent 22810245f5
commit 33c9079d3b
16 changed files with 639 additions and 196 deletions

View File

@@ -5,6 +5,11 @@ import {
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';
@@ -42,6 +47,22 @@ const DEBUG_CONFIG_FIELDS: Array<{
{ key: 'opponentBasePower', label: '对手基础力', min: 0, max: 1, step: 0.05 },
];
const MICROPHONE_FAILURE_REASONS = new Set<MicrophoneFailureReason>([
'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<BarkBattleController | null>(null);
@@ -51,6 +72,9 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
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<DebugEvent[]>([]);
@@ -58,6 +82,7 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
const lastPlayerBarkCountRef = useRef(0);
const lastOpponentPowerRef = useRef(0);
const debugEventIdRef = useRef(0);
const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null);
const appendDebugEvent = useCallback((text: string) => {
debugEventIdRef.current += 1;
@@ -80,6 +105,37 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
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();
@@ -88,18 +144,24 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
useEffect(() => {
const timer = window.setInterval(() => {
controller.tick(100);
if (heldRef.current) {
controller.submitMockSample(0.88);
} else {
controller.submitMockSample(0.12);
if (inputMode === 'mock') {
if (heldRef.current) {
controller.submitMockSample(0.88);
} else {
controller.submitMockSample(0.12);
setLiveInputVolume(0);
}
}
syncSnapshot();
}, 100);
return () => window.clearInterval(timer);
}, [controller, syncSnapshot]);
}, [controller, inputMode, syncSnapshot]);
const restart = () => {
heldRef.current = false;
stopMicrophone();
setInputMode('mock');
setLiveInputVolume(0);
controller.restart();
setParticleText('');
setDebugEvents([]);
@@ -109,22 +171,25 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
};
const startMock = () => {
stopMicrophone();
setInputMode('mock');
setLiveInputVolume(0);
controller.startWithMockInput();
appendDebugEvent('开始 mock 对局');
appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)');
syncSnapshot();
};
const finishNow = () => {
heldRef.current = false;
stopMicrophone();
controller.finishNow();
appendDebugEvent('人工结束对局');
syncSnapshot();
};
const bark = () => {
heldRef.current = true;
setPlayerPulseKey((current) => current + 1);
appendDebugEvent('按下模拟叫声按钮');
controller.forcePlayerBark(0.9);
syncSnapshot();
setParticleText('汪!');
window.setTimeout(() => setParticleText(''), 680);
};
@@ -135,50 +200,63 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
snapshot={snapshot}
playerPulseKey={playerPulseKey}
opponentPulseKey={opponentPulseKey}
onStartMicrophone={startMock}
onStartMicrophone={startMicrophone}
onMockBark={bark}
onMockQuiet={() => {
heldRef.current = false;
}}
onRestart={restart}
/>
<aside className="bark-battle-debug-panel" aria-label="调试面板">
<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__controls">
<button type="button" onClick={startMock}></button>
<button type="button" onClick={finishNow}></button>
<button type="button" onClick={restart}></button>
<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>
</div>
<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>
<div className="bark-battle-debug-metrics" aria-label="触发反馈">
<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>
))}
</aside>
{particleText ? <div className="bark-battle-particles">{particleText}</div> : null}
{snapshot.result ? <BarkBattleResultPanel result={snapshot.result} onRestart={restart} /> : null}