Files
Genarrative/src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx
2026-05-22 05:00:07 +08:00

863 lines
28 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, useMemo, useRef, useState } from 'react';
import type {
BarkBattleDerivedMetrics,
BarkBattlePublishedConfig,
BarkBattleRuntimeConfig,
BarkBattleRunStartResponse,
BarkBattleServerResult,
} from '../../../../packages/shared/src/contracts/barkBattle';
import {
finishBarkBattleRun,
startBarkBattleRun,
} from '../../../services/bark-battle-runtime';
import {
type BarkBattleConfig,
buildBarkBattleDefaultOnomatopoeia,
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';
type BarkBattleRuntimeMode = 'draft' | 'published';
type BarkBattleRuntimeShellProps = {
title?: string;
workId?: string;
publishedConfig?: BarkBattlePublishedConfig | null;
runtimeMode?: BarkBattleRuntimeMode;
onExit?: () => void;
};
type DebugEvent = {
id: number;
text: string;
};
type BarkBattleActiveRun = Pick<
BarkBattleRunStartResponse,
| 'runId'
| 'runToken'
| 'workId'
| 'configVersion'
| 'rulesetVersion'
| 'difficultyPreset'
| 'serverStartedAt'
>;
type BarkBattleMetricAccumulator = {
sampleCount: number;
volumeSum: number;
maxVolume: number;
comboMax: number;
currentCombo: number;
};
const BARK_BATTLE_CLIENT_RUNTIME_VERSION = 'bark-battle-web-v1';
function createMetricAccumulator(): BarkBattleMetricAccumulator {
return {
sampleCount: 0,
volumeSum: 0,
maxVolume: 0,
comboMax: 0,
currentCombo: 0,
};
}
function normalizeMetricVolume(volume: number) {
if (!Number.isFinite(volume)) {
return 0;
}
return Math.max(0, Math.min(1, volume));
}
function resolveClientResult(
winner: 'player' | 'opponent' | 'draw' | null,
): BarkBattleServerResult {
if (winner === 'player') {
return 'player_win';
}
if (winner === 'opponent') {
return 'opponent_win';
}
return 'draw';
}
function resolveResultTitle(winner: 'player' | 'opponent' | 'draw' | null) {
if (winner === 'player') {
return '汪力压制成功';
}
if (winner === 'opponent') {
return '对手声浪更强';
}
return '势均力敌';
}
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)
);
}
function buildRuntimeConfigFromPublishedConfig(
publishedConfig?: BarkBattlePublishedConfig | null,
): BarkBattleConfig {
if (!publishedConfig) {
return DEFAULT_BARK_BATTLE_CONFIG;
}
const difficultyOverrides: Record<
BarkBattlePublishedConfig['difficultyPreset'],
Partial<BarkBattleConfig>
> = {
easy: { opponentBasePower: 0.16 },
normal: { opponentBasePower: 0.22 },
hard: { opponentBasePower: 0.3 },
};
return {
...DEFAULT_BARK_BATTLE_CONFIG,
...difficultyOverrides[publishedConfig.difficultyPreset],
};
}
function buildRuntimeConfigFromServerConfig(
runtimeConfig: BarkBattleRuntimeConfig,
): BarkBattleConfig {
const baseConfig = buildRuntimeConfigFromPublishedConfig({
workId: runtimeConfig.workId,
draftId: null,
configVersion: runtimeConfig.configVersion,
rulesetVersion: runtimeConfig.rulesetVersion,
playTypeId: runtimeConfig.playTypeId,
title: '',
description: '',
themeDescription: runtimeConfig.themeDescription,
playerImageDescription: runtimeConfig.playerImageDescription,
opponentImageDescription: runtimeConfig.opponentImageDescription,
onomatopoeia: runtimeConfig.onomatopoeia,
playerCharacterImageSrc: runtimeConfig.playerCharacterImageSrc,
opponentCharacterImageSrc: runtimeConfig.opponentCharacterImageSrc,
uiBackgroundImageSrc: runtimeConfig.uiBackgroundImageSrc,
difficultyPreset: runtimeConfig.difficultyPreset,
updatedAt: runtimeConfig.updatedAt,
publishedAt: runtimeConfig.updatedAt,
});
return {
...baseConfig,
roundDurationMs: runtimeConfig.durationMs,
drawThreshold: runtimeConfig.drawThreshold,
minBarkGapMs: runtimeConfig.minBarkGapMs,
};
}
function normalizeOnomatopoeiaPool(
publishedConfig?: BarkBattlePublishedConfig | null,
) {
const custom = publishedConfig?.onomatopoeia
?.map((word) => word.trim())
.filter(Boolean)
.slice(0, 24);
if (custom?.length) {
return custom;
}
return buildBarkBattleDefaultOnomatopoeia({
themeDescription: publishedConfig?.themeDescription,
playerImageDescription: publishedConfig?.playerImageDescription,
opponentImageDescription: publishedConfig?.opponentImageDescription,
});
}
function buildPublishedConfigFromServerRuntimeConfig(
current: BarkBattlePublishedConfig,
runtimeConfig: BarkBattleRuntimeConfig,
): BarkBattlePublishedConfig {
return {
...current,
workId: runtimeConfig.workId,
configVersion: runtimeConfig.configVersion,
rulesetVersion: runtimeConfig.rulesetVersion,
playTypeId: runtimeConfig.playTypeId,
themeDescription: runtimeConfig.themeDescription,
playerImageDescription: runtimeConfig.playerImageDescription,
opponentImageDescription: runtimeConfig.opponentImageDescription,
onomatopoeia: runtimeConfig.onomatopoeia,
playerCharacterImageSrc: runtimeConfig.playerCharacterImageSrc,
opponentCharacterImageSrc: runtimeConfig.opponentCharacterImageSrc,
uiBackgroundImageSrc: runtimeConfig.uiBackgroundImageSrc,
difficultyPreset: runtimeConfig.difficultyPreset,
updatedAt: runtimeConfig.updatedAt,
};
}
function pickRandomOnomatopoeia(
pool: readonly string[],
previous: string,
) {
if (!pool.length) {
return '炸场!';
}
if (pool.length === 1) {
return pool[0] ?? '炸场!';
}
const candidates = pool.filter((word) => word !== previous);
const activePool = candidates.length ? candidates : pool;
const index = Math.min(
activePool.length - 1,
Math.floor(Math.random() * activePool.length),
);
return activePool[index] ?? activePool[0] ?? '炸场!';
}
export function BarkBattleRuntimeShell({
title = '汪汪声浪大作战',
workId,
publishedConfig,
runtimeMode = 'draft',
onExit,
}: BarkBattleRuntimeShellProps) {
const initialConfig = useMemo(
() => buildRuntimeConfigFromPublishedConfig(publishedConfig),
[publishedConfig],
);
const [config, setConfig] = useState(initialConfig);
const runtimeConfigRef = useRef(initialConfig);
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 replacementConfig = publishedConfig ?? null;
const [activePublishedConfig, setActivePublishedConfig] = useState(
replacementConfig,
);
const onomatopoeiaPool = useMemo(
() => normalizeOnomatopoeiaPool(activePublishedConfig),
[activePublishedConfig],
);
const [playerBurstText, setPlayerBurstText] = useState(
() => onomatopoeiaPool[0] ?? '炸场!',
);
const isPublishedRuntime =
runtimeMode === 'published' && Boolean(replacementConfig?.workId);
const [inputMode, setInputMode] = useState<'mock' | 'microphone'>(
isPublishedRuntime ? 'microphone' : 'mock',
);
useEffect(() => {
setInputMode(isPublishedRuntime ? 'microphone' : 'mock');
}, [isPublishedRuntime]);
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 [runtimeError, setRuntimeError] = useState<string | null>(null);
const heldRef = useRef(false);
const lastPlayerBarkCountRef = useRef(0);
const lastOpponentPowerRef = useRef(0);
const debugEventIdRef = useRef(0);
const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null);
const activeRunRef = useRef<BarkBattleActiveRun | null>(null);
const pendingRunStartRef = useRef<Promise<boolean> | null>(null);
const runStartedAtRef = useRef<string | null>(null);
const submittedRunIdsRef = useRef<Set<string>>(new Set());
const autoStartMicrophoneAttemptedRef = useRef(false);
const metricAccumulatorRef = useRef<BarkBattleMetricAccumulator>(
createMetricAccumulator(),
);
const lastOnomatopoeiaRef = useRef('');
// 中文注释:正式公开 runtime 面向玩家只保留真实麦克风入口mock 与调参面板只服务草稿试玩。
const shouldShowDebugPanel = !isPublishedRuntime;
useEffect(() => {
lastOnomatopoeiaRef.current = '';
setPlayerBurstText(onomatopoeiaPool[0] ?? '炸场!');
}, [onomatopoeiaPool]);
const appendDebugEvent = useCallback((text: string) => {
debugEventIdRef.current += 1;
const event = { id: debugEventIdRef.current, text };
setDebugEvents((current) => [event, ...current].slice(0, 5));
}, []);
const flashOnomatopoeia = useCallback(() => {
const nextWord = pickRandomOnomatopoeia(
onomatopoeiaPool,
lastOnomatopoeiaRef.current,
);
lastOnomatopoeiaRef.current = nextWord;
setPlayerBurstText(nextWord);
setParticleText(nextWord);
window.setTimeout(() => setParticleText(''), 520);
}, [onomatopoeiaPool]);
const resetRuntimeRunState = useCallback(() => {
activeRunRef.current = null;
pendingRunStartRef.current = null;
runStartedAtRef.current = null;
submittedRunIdsRef.current = new Set();
metricAccumulatorRef.current = createMetricAccumulator();
setRuntimeError(null);
}, []);
const recordRuntimeSample = useCallback((volume: number) => {
const normalized = normalizeMetricVolume(volume);
const metrics = metricAccumulatorRef.current;
metrics.sampleCount += 1;
metrics.volumeSum += normalized;
metrics.maxVolume = Math.max(metrics.maxVolume, normalized);
}, []);
const recordRuntimeTrigger = useCallback((volume: number) => {
const normalized = normalizeMetricVolume(volume);
const metrics = metricAccumulatorRef.current;
metrics.currentCombo += 1;
metrics.comboMax = Math.max(metrics.comboMax, metrics.currentCombo);
metrics.maxVolume = Math.max(metrics.maxVolume, normalized);
}, []);
const buildDerivedMetrics = useCallback((): BarkBattleDerivedMetrics => {
const metrics = metricAccumulatorRef.current;
const nextSnapshot = controller.getSnapshot();
return {
triggerCount: nextSnapshot.player.barkCount,
maxVolume: Number(metrics.maxVolume.toFixed(3)),
averageVolume: Number(
(metrics.sampleCount
? metrics.volumeSum / metrics.sampleCount
: 0
).toFixed(3),
),
finalEnergy: Number(nextSnapshot.energy.toFixed(2)),
comboMax: metrics.comboMax,
};
}, [controller]);
const submitFinishedRunIfNeeded = useCallback(
(nextSnapshot = controller.getSnapshot()) => {
if (!isPublishedRuntime || nextSnapshot.phase !== 'finished') {
return;
}
const activeRun = activeRunRef.current;
if (!activeRun || submittedRunIdsRef.current.has(activeRun.runId)) {
return;
}
submittedRunIdsRef.current.add(activeRun.runId);
const finishedAt = new Date().toISOString();
const startedAt =
runStartedAtRef.current ?? activeRun.serverStartedAt ?? finishedAt;
const durationMs = Math.max(
0,
runtimeConfigRef.current.roundDurationMs - nextSnapshot.remainingMs,
);
void finishBarkBattleRun(activeRun.runId, {
runId: activeRun.runId,
runToken: activeRun.runToken,
workId: activeRun.workId,
configVersion: activeRun.configVersion,
rulesetVersion: activeRun.rulesetVersion,
difficultyPreset: activeRun.difficultyPreset,
clientStartedAt: startedAt,
clientFinishedAt: finishedAt,
durationMs,
derivedMetrics: buildDerivedMetrics(),
clientResult: resolveClientResult(nextSnapshot.winner),
clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION,
})
.then(() => {
appendDebugEvent('正式成绩已提交');
})
.catch((error) => {
setRuntimeError(
error instanceof Error ? error.message : '提交正式成绩失败',
);
appendDebugEvent('正式成绩提交失败');
});
},
[
appendDebugEvent,
buildDerivedMetrics,
controller,
isPublishedRuntime,
],
);
const startFormalRunIfNeeded = useCallback(async (): Promise<boolean> => {
if (!isPublishedRuntime || !replacementConfig?.workId) {
return true;
}
if (activeRunRef.current) {
return true;
}
if (!pendingRunStartRef.current) {
pendingRunStartRef.current = (async () => {
try {
setRuntimeError(null);
const started = await startBarkBattleRun(replacementConfig.workId, {
// 中文注释:公开广场摘要可能滞后于已发布运行配置;正式开局以服务端当前发布快照为准。
sourceRoute:
typeof window === 'undefined'
? 'bark-battle-runtime'
: window.location.pathname,
clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION,
});
const serverRuntimeConfig = buildRuntimeConfigFromServerConfig(
started.runtimeConfig,
);
// 中文注释:公开卡片可能只带摘要;正式开局后用服务端 runtimeConfig 刷新拟声词和素材。
setActivePublishedConfig((current) =>
buildPublishedConfigFromServerRuntimeConfig(
current ?? replacementConfig,
started.runtimeConfig,
),
);
runtimeConfigRef.current = serverRuntimeConfig;
controller.updateConfigForActiveRound(serverRuntimeConfig);
activeRunRef.current = {
runId: started.runId,
runToken: started.runToken,
workId: started.workId,
configVersion: started.configVersion,
rulesetVersion: started.rulesetVersion,
difficultyPreset: started.difficultyPreset,
serverStartedAt: started.serverStartedAt,
};
runStartedAtRef.current = new Date().toISOString();
appendDebugEvent(`正式对局已登记:${started.runId}`);
return true;
} catch (error) {
const message =
error instanceof Error ? error.message : '启动正式对局失败';
setRuntimeError(message);
appendDebugEvent(message);
return false;
} finally {
pendingRunStartRef.current = null;
}
})();
}
return pendingRunStartRef.current ?? Promise.resolve(true);
}, [appendDebugEvent, controller, isPublishedRuntime, replacementConfig]);
const syncSnapshot = useCallback(() => {
const nextSnapshot = controller.getSnapshot();
if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) {
setPlayerPulseKey((current) => current + 1);
recordRuntimeTrigger(nextSnapshot.player.power);
flashOnomatopoeia();
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);
submitFinishedRunIfNeeded(nextSnapshot);
}, [
appendDebugEvent,
controller,
flashOnomatopoeia,
recordRuntimeTrigger,
submitFinishedRunIfNeeded,
]);
const stopMicrophone = useCallback(() => {
microphoneSamplerRef.current?.stop();
microphoneSamplerRef.current = null;
}, []);
// 中文注释:领域层沿用 startMockRound 表示“进入对局倒计时”,正式/草稿输入差异由外层 sampler 控制。
const startRuntimeRound = useCallback(() => {
controller.startWithMockInput();
}, [controller]);
useEffect(() => {
setConfig(initialConfig);
runtimeConfigRef.current = initialConfig;
controller.updateConfig(initialConfig);
setActivePublishedConfig(replacementConfig);
}, [controller, initialConfig, replacementConfig]);
const startMicrophone = useCallback(async () => {
stopMicrophone();
let shouldAcceptMicrophoneSamples = false;
try {
const sampler = await startBrowserMicrophoneSampler((volume, atMs) => {
if (!shouldAcceptMicrophoneSamples) {
return;
}
setLiveInputVolume(volume);
recordRuntimeSample(volume);
if (volume >= config.barkThreshold) {
appendDebugEvent(`麦克风输入 ${(volume * 100).toFixed(0)}%`);
}
controller.submitInputSample(
volume,
controller.getSampleClockMs() + atMs,
);
});
if (!(await startFormalRunIfNeeded())) {
sampler.stop();
return;
}
startRuntimeRound();
microphoneSamplerRef.current = sampler;
setInputMode('microphone');
shouldAcceptMicrophoneSamples = true;
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,
recordRuntimeSample,
startFormalRunIfNeeded,
startRuntimeRound,
stopMicrophone,
syncSnapshot,
]);
useEffect(() => {
if (
!isPublishedRuntime ||
snapshot.phase !== 'permission' ||
autoStartMicrophoneAttemptedRef.current
) {
return;
}
// 中文注释:公开作品从详情页“启动”进入运行态后立即申请麦克风,授权成功后直接进入倒计时。
autoStartMicrophoneAttemptedRef.current = true;
void startMicrophone();
}, [isPublishedRuntime, snapshot.phase, startMicrophone]);
useEffect(() => stopMicrophone, [stopMicrophone]);
useEffect(() => {
runtimeConfigRef.current = config;
controller.updateConfig(config);
setSnapshot(controller.getSnapshot());
}, [config, controller]);
useEffect(() => {
const timer = window.setInterval(() => {
controller.tick(100);
if (inputMode === 'mock' && !isPublishedRuntime) {
if (heldRef.current) {
recordRuntimeSample(0.88);
controller.submitMockSample(0.88);
} else {
recordRuntimeSample(0.12);
controller.submitMockSample(0.12);
setLiveInputVolume(0);
}
}
syncSnapshot();
}, 100);
return () => window.clearInterval(timer);
}, [
controller,
inputMode,
isPublishedRuntime,
recordRuntimeSample,
syncSnapshot,
]);
const restart = () => {
heldRef.current = false;
stopMicrophone();
setInputMode(isPublishedRuntime ? 'microphone' : 'mock');
setLiveInputVolume(0);
controller.restart();
setParticleText('');
setPlayerBurstText(onomatopoeiaPool[0] ?? '炸场!');
setDebugEvents([]);
resetRuntimeRunState();
autoStartMicrophoneAttemptedRef.current = false;
lastPlayerBarkCountRef.current = 0;
lastOpponentPowerRef.current = 0;
syncSnapshot();
};
const startMock = async () => {
if (isPublishedRuntime) {
const message = '正式对局需要使用真实麦克风';
setRuntimeError(message);
appendDebugEvent(message);
return;
}
stopMicrophone();
setInputMode('mock');
setLiveInputVolume(0);
startRuntimeRound();
appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)');
syncSnapshot();
};
const finishNow = () => {
if (isPublishedRuntime && !activeRunRef.current) {
const message = '正式对局需要使用真实麦克风';
setRuntimeError(message);
appendDebugEvent(message);
return;
}
heldRef.current = false;
stopMicrophone();
controller.finishNow();
appendDebugEvent('人工结束对局');
syncSnapshot();
};
const bark = async () => {
if (isPublishedRuntime) {
const message = '正式对局需要使用真实麦克风';
setRuntimeError(message);
appendDebugEvent(message);
return;
}
recordRuntimeSample(0.9);
controller.forcePlayerBark(0.9);
syncSnapshot();
};
const exitRuntime = () => {
heldRef.current = false;
stopMicrophone();
onExit?.();
};
return (
<main className="bark-battle-runtime" aria-label={title}>
{onExit ? (
<button
className="bark-battle-runtime__back-button"
type="button"
onClick={exitRuntime}
>
</button>
) : null}
<BarkBattleHud
snapshot={snapshot}
playerPulseKey={playerPulseKey}
opponentPulseKey={opponentPulseKey}
playerCharacterImageSrc={activePublishedConfig?.playerCharacterImageSrc}
opponentCharacterImageSrc={activePublishedConfig?.opponentCharacterImageSrc}
uiBackgroundImageSrc={activePublishedConfig?.uiBackgroundImageSrc}
onStartMicrophone={startMicrophone}
onMockBark={bark}
onMockQuiet={() => {
heldRef.current = false;
}}
onRestart={restart}
enableMockControls={!isPublishedRuntime}
runtimeError={shouldShowDebugPanel ? null : runtimeError}
playerBurstText={playerBurstText}
opponentBurstText="反击"
/>
{shouldShowDebugPanel ? (
<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>
{onExit ? (
<button type="button" onClick={onExit}>
</button>
) : null}
</div>
{workId ? (
<p className="bark-battle-debug-panel__work-id">{workId}</p>
) : null}
{runtimeError ? (
<p className="bark-battle-debug-panel__work-id" role="alert">
{runtimeError}
</p>
) : null}
<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>
) : null}
{particleText ? (
<div className="bark-battle-particles">{particleText}</div>
) : null}
{snapshot.result ? (
<div className="bark-battle-result-modal" role="presentation">
<section
className="bark-battle-result bark-battle-result--modal"
role="dialog"
aria-modal="true"
aria-label="对战结算"
>
<p className="bark-battle-result__eyebrow"></p>
<h2>{resolveResultTitle(snapshot.result.winner)}</h2>
<div className="bark-battle-result__stats">
<span>
<strong>{snapshot.result.playerBarkCount}</strong>
</span>
<span>
<strong>{snapshot.result.opponentBarkCount}</strong>
</span>
<span>
<strong>{snapshot.result.score}</strong>
</span>
</div>
<div className="bark-battle-result__actions">
{onExit ? (
<button type="button" onClick={exitRuntime}>
</button>
) : null}
<button
className="bark-battle-primary-button"
type="button"
onClick={restart}
>
</button>
</div>
</section>
</div>
) : null}
</main>
);
}