863 lines
28 KiB
TypeScript
863 lines
28 KiB
TypeScript
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>
|
||
);
|
||
}
|