feat: complete bark battle playable demo
This commit is contained in:
@@ -155,18 +155,24 @@
|
||||
right: 12px;
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
z-index: 8;
|
||||
width: min(92vw, 340px);
|
||||
max-height: 42svh;
|
||||
overflow: auto;
|
||||
width: min(78vw, 240px);
|
||||
max-height: 56px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 22px;
|
||||
padding: 12px;
|
||||
padding: 10px 12px;
|
||||
color: #fff7ed;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel--expanded {
|
||||
width: min(92vw, 340px);
|
||||
max-height: 42svh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel header,
|
||||
.bark-battle-debug-panel label {
|
||||
display: flex;
|
||||
@@ -175,6 +181,28 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel header {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel__toggle {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
color: #1f1147;
|
||||
background: #facc15;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel__body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel--expanded .bark-battle-debug-panel__body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel label {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
@@ -211,6 +239,10 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-metrics__wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.bark-battle-debug-events {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -10,7 +10,12 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
|
||||
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
|
||||
render(<BarkBattleRuntimeShell />);
|
||||
|
||||
expect(screen.getByLabelText('调试面板')).toBeTruthy();
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
expect(debugPanel).toBeTruthy();
|
||||
expect(within(debugPanel).getByRole('button', { name: '展开' })).toBeTruthy();
|
||||
|
||||
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' }));
|
||||
expect(within(debugPanel).getByRole('button', { name: '收起' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '开始' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '结束' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '重置' })).toBeTruthy();
|
||||
@@ -22,9 +27,25 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
|
||||
expect(screen.getByText(/countdown|playing/u)).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
|
||||
expect(screen.getAllByText('按下模拟叫声按钮').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/玩家叫声触发 #1/u).length).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '结束' }));
|
||||
expect(screen.getByRole('dialog', { name: '对战结算' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('真实声控入口在不支持麦克风时展示失败原因,mock 开始不请求权限', async () => {
|
||||
render(<BarkBattleRuntimeShell />);
|
||||
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始声控' }));
|
||||
|
||||
expect(screen.getByText('当前浏览器不支持麦克风输入')).toBeTruthy();
|
||||
expect(screen.getAllByText(/麦克风不可用:unsupported/u).length).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始' }));
|
||||
expect(screen.getAllByText(/开始 mock 对局(不会请求浏览器麦克风权限)/u).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/输入模式:Mock 输入/u)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user