fix: polish bark battle creation flow

This commit is contained in:
kdletters
2026-05-22 05:00:07 +08:00
parent 01da85a577
commit bf82f04b64
73 changed files with 9362 additions and 2663 deletions

View File

@@ -1,9 +1,19 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BarkBattlePublishedConfig } from '../../../../packages/shared/src/contracts/barkBattle';
import { useResolvedAssetReadUrl } from '../../../hooks/useResolvedAssetReadUrl';
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';
@@ -13,12 +23,14 @@ import {
startBrowserMicrophoneSampler,
} from '../infrastructure/BrowserMicrophoneInput';
import { BarkBattleHud } from './BarkBattleHud';
import { BarkBattleResultPanel } from './BarkBattleResultPanel';
type BarkBattleRuntimeMode = 'draft' | 'published';
type BarkBattleRuntimeShellProps = {
title?: string;
workId?: string;
publishedConfig?: BarkBattlePublishedConfig | null;
runtimeMode?: BarkBattleRuntimeMode;
onExit?: () => void;
};
@@ -27,6 +39,66 @@ type DebugEvent = {
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,
@@ -43,7 +115,13 @@ const DEBUG_CONFIG_FIELDS: Array<{
max: number;
step: number;
}> = [
{ key: 'roundDurationMs', label: '局长(ms)', min: 1000, max: 60000, step: 1000 },
{
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 },
@@ -64,8 +142,13 @@ const MICROPHONE_FAILURE_REASONS = new Set<MicrophoneFailureReason>([
'unknown',
]);
function isMicrophoneFailureReason(reason: unknown): reason is MicrophoneFailureReason {
return typeof reason === 'string' && MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason);
function isMicrophoneFailureReason(
reason: unknown,
): reason is MicrophoneFailureReason {
return (
typeof reason === 'string' &&
MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason)
);
}
function buildRuntimeConfigFromPublishedConfig(
@@ -79,9 +162,9 @@ function buildRuntimeConfigFromPublishedConfig(
BarkBattlePublishedConfig['difficultyPreset'],
Partial<BarkBattleConfig>
> = {
easy: { barkThreshold: 0.42, opponentBasePower: 0.16, drawThreshold: 10 },
normal: { barkThreshold: 0.5, opponentBasePower: 0.22, drawThreshold: 12 },
hard: { barkThreshold: 0.58, opponentBasePower: 0.3, drawThreshold: 14 },
easy: { opponentBasePower: 0.16 },
normal: { opponentBasePower: 0.22 },
hard: { opponentBasePower: 0.3 },
};
return {
@@ -90,10 +173,99 @@ function buildRuntimeConfigFromPublishedConfig(
};
}
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(
@@ -101,6 +273,7 @@ export function BarkBattleRuntimeShell({
[publishedConfig],
);
const [config, setConfig] = useState(initialConfig);
const runtimeConfigRef = useRef(initialConfig);
const controllerRef = useRef<BarkBattleController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new BarkBattleController(config);
@@ -108,31 +281,52 @@ export function BarkBattleRuntimeShell({
const controller = controllerRef.current;
const [snapshot, setSnapshot] = useState(() => controller.getSnapshot());
const [particleText, setParticleText] = useState('');
const [inputMode, setInputMode] = useState<'mock' | 'microphone'>('mock');
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 barkAudioRef = useRef<HTMLAudioElement | null>(null);
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 replacementConfig = publishedConfig ?? null;
const { resolvedUrl: resolvedBarkSoundSrc } = useResolvedAssetReadUrl(
replacementConfig?.barkSoundSrc ?? 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;
const playBarkSound = useCallback(() => {
const audio = barkAudioRef.current;
if (!audio || !resolvedBarkSoundSrc) {
return;
}
audio.currentTime = 0;
void audio.play().catch(() => {});
}, [resolvedBarkSoundSrc]);
useEffect(() => {
lastOnomatopoeiaRef.current = '';
setPlayerBurstText(onomatopoeiaPool[0] ?? '炸场!');
}, [onomatopoeiaPool]);
const appendDebugEvent = useCallback((text: string) => {
debugEventIdRef.current += 1;
@@ -140,72 +334,296 @@ export function BarkBattleRuntimeShell({
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);
playBarkSound();
appendDebugEvent(`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`);
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) {
if (
nextSnapshot.phase === 'playing' &&
Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >=
0.08
) {
setOpponentPulseKey((current) => current + 1);
playBarkSound();
appendDebugEvent(`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`);
appendDebugEvent(
`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`,
);
}
lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount;
lastOpponentPowerRef.current = nextSnapshot.opponent.power;
setSnapshot(nextSnapshot);
}, [appendDebugEvent, controller, playBarkSound]);
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);
syncSnapshot();
}, [controller, initialConfig, syncSnapshot]);
setActivePublishedConfig(replacementConfig);
}, [controller, initialConfig, replacementConfig]);
const startMicrophone = useCallback(async () => {
stopMicrophone();
let shouldAcceptMicrophoneSamples = false;
try {
controller.startWithMockInput();
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, atMs);
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';
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]);
}, [
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);
syncSnapshot();
}, [config, controller, syncSnapshot]);
setSnapshot(controller.getSnapshot());
}, [config, controller]);
useEffect(() => {
const timer = window.setInterval(() => {
controller.tick(100);
if (inputMode === 'mock') {
if (inputMode === 'mock' && !isPublishedRuntime) {
if (heldRef.current) {
recordRuntimeSample(0.88);
controller.submitMockSample(0.88);
} else {
recordRuntimeSample(0.12);
controller.submitMockSample(0.12);
setLiveInputVolume(0);
}
@@ -213,31 +631,52 @@ export function BarkBattleRuntimeShell({
syncSnapshot();
}, 100);
return () => window.clearInterval(timer);
}, [controller, inputMode, syncSnapshot]);
}, [
controller,
inputMode,
isPublishedRuntime,
recordRuntimeSample,
syncSnapshot,
]);
const restart = () => {
heldRef.current = false;
stopMicrophone();
setInputMode('mock');
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 = () => {
const startMock = async () => {
if (isPublishedRuntime) {
const message = '正式对局需要使用真实麦克风';
setRuntimeError(message);
appendDebugEvent(message);
return;
}
stopMicrophone();
setInputMode('mock');
setLiveInputVolume(0);
controller.startWithMockInput();
startRuntimeRound();
appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)');
syncSnapshot();
};
const finishNow = () => {
if (isPublishedRuntime && !activeRunRef.current) {
const message = '正式对局需要使用真实麦克风';
setRuntimeError(message);
appendDebugEvent(message);
return;
}
heldRef.current = false;
stopMicrophone();
controller.finishNow();
@@ -245,89 +684,179 @@ export function BarkBattleRuntimeShell({
syncSnapshot();
};
const bark = () => {
const bark = async () => {
if (isPublishedRuntime) {
const message = '正式对局需要使用真实麦克风';
setRuntimeError(message);
appendDebugEvent(message);
return;
}
recordRuntimeSample(0.9);
controller.forcePlayerBark(0.9);
syncSnapshot();
setParticleText('汪!');
window.setTimeout(() => setParticleText(''), 680);
};
const exitRuntime = () => {
heldRef.current = false;
stopMicrophone();
onExit?.();
};
return (
<main className="bark-battle-runtime" aria-label={title}>
{resolvedBarkSoundSrc ? (
<audio ref={barkAudioRef} src={resolvedBarkSoundSrc} preload="auto" />
{onExit ? (
<button
className="bark-battle-runtime__back-button"
type="button"
onClick={exitRuntime}
>
</button>
) : null}
<BarkBattleHud
snapshot={snapshot}
playerPulseKey={playerPulseKey}
opponentPulseKey={opponentPulseKey}
playerCharacterImageSrc={replacementConfig?.playerCharacterImageSrc}
opponentCharacterImageSrc={replacementConfig?.opponentCharacterImageSrc}
uiBackgroundImageSrc={replacementConfig?.uiBackgroundImageSrc}
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="反击"
/>
<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)}
{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="对战结算"
>
{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}
<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>
))}
<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>
</aside>
{particleText ? <div className="bark-battle-particles">{particleText}</div> : null}
{snapshot.result ? <BarkBattleResultPanel result={snapshot.result} onRestart={restart} /> : null}
) : null}
</main>
);
}