266 lines
9.8 KiB
TypeScript
266 lines
9.8 KiB
TypeScript
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<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);
|
||
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<DebugEvent[]>([]);
|
||
const heldRef = useRef(false);
|
||
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;
|
||
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 (
|
||
<main className="bark-battle-runtime" aria-label={title}>
|
||
<BarkBattleHud
|
||
snapshot={snapshot}
|
||
playerPulseKey={playerPulseKey}
|
||
opponentPulseKey={opponentPulseKey}
|
||
onStartMicrophone={startMicrophone}
|
||
onMockBark={bark}
|
||
onMockQuiet={() => {
|
||
heldRef.current = false;
|
||
}}
|
||
onRestart={restart}
|
||
/>
|
||
<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>
|
||
</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>
|
||
</aside>
|
||
{particleText ? <div className="bark-battle-particles">{particleText}</div> : null}
|
||
{snapshot.result ? <BarkBattleResultPanel result={snapshot.result} onRestart={restart} /> : null}
|
||
</main>
|
||
);
|
||
}
|