fix: polish bark battle creation flow
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user