Files
Genarrative/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx

266 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}