This commit is contained in:
2026-05-13 00:28:07 +08:00
parent ef4f91a75e
commit 01c5ab985a
101 changed files with 10635 additions and 2292 deletions

View File

@@ -35,6 +35,13 @@ import {
type RuntimeDragInputSession,
type RuntimeInputPoint,
} from '../../services/input-devices';
import {
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
playRuntimeClickSound,
playRuntimeCountdownSound,
playRuntimeLevelClearSound,
resolveRuntimeCountdownSecondBucket,
} from '../../services/runtimeAudioFeedback';
import { useMocapInput } from '../../services/useMocapInput';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -326,6 +333,11 @@ function triggerPuzzlePiecePressHapticFeedback() {
vibrate.call(navigator, [PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS]);
}
function triggerPuzzlePiecePressFeedback(volume: number) {
triggerPuzzlePiecePressHapticFeedback();
playRuntimeClickSound(undefined, volume);
}
/**
* 拼图运行时壳层。
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。
@@ -378,6 +390,8 @@ export function PuzzleRuntimeShell({
const previousUiPauseActiveRef = useRef(false);
const pauseChangePromiseRef = useRef<Promise<void>>(Promise.resolve());
const timeExpiredSyncKeyRef = useRef<string | null>(null);
const clearSoundKeyRef = useRef<string | null>(null);
const countdownSoundKeyRef = useRef<string | null>(null);
const dragSessionRef = useRef<{
pieceId: string;
inputId: string;
@@ -425,6 +439,7 @@ export function PuzzleRuntimeShell({
const mergeGroupSignatureRef = useRef<string | null>(null);
const hintDemoTimeoutRef = useRef<number | null>(null);
const mergeFlashTimeoutRef = useRef<number | null>(null);
const backgroundAudioRef = useRef<HTMLAudioElement | null>(null);
const boardRef = useRef<HTMLDivElement | null>(null);
const currentLevel = run?.currentLevel ?? null;
const currentLevelRef = useRef(currentLevel);
@@ -442,11 +457,21 @@ export function PuzzleRuntimeShell({
const clearResultKey = currentLevel
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
: null;
const runtimeRunId = run?.runId ?? null;
const currentLevelIndex = currentLevel?.levelIndex ?? null;
const currentLevelStartedAtMs = currentLevel?.startedAtMs ?? null;
const currentLevelStatus = currentLevel?.status ?? null;
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
const backgroundMusicSrc = currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
const { resolvedUrl: resolvedBackgroundMusicSrc } = useResolvedAssetReadUrl(backgroundMusicSrc);
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
currentLevel?.coverImageSrc ?? null,
);
const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
currentLevel?.uiBackgroundImageSrc ?? null,
);
const mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
const primaryMocapHand = mocapInput.latestCommand?.primaryHand;
const primaryMocapHandState = primaryMocapHand?.state;
@@ -472,6 +497,18 @@ export function PuzzleRuntimeShell({
currentLevelRef.current = currentLevel;
}, [currentLevel]);
useEffect(() => {
const audio = backgroundAudioRef.current;
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
if (audio) {
audio.pause();
}
return;
}
audio.volume = Math.max(0, Math.min(1, musicVolume));
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
const commitSelectedPieceId = (pieceId: string | null) => {
selectedPieceIdRef.current = pieceId;
setSelectedPieceId(pieceId);
@@ -815,6 +852,41 @@ export function PuzzleRuntimeShell({
run?.runId,
]);
useEffect(() => {
if (
!runtimeRunId ||
currentLevelStatus !== 'playing' ||
currentLevelIndex === null ||
currentLevelStartedAtMs === null
) {
countdownSoundKeyRef.current = null;
return;
}
const secondBucket =
displayRemainingMs <= levelAudioConfig.countdownWarningThresholdMs
? resolveRuntimeCountdownSecondBucket(displayRemainingMs)
: null;
if (secondBucket === null) {
countdownSoundKeyRef.current = null;
return;
}
const soundKey = `${runtimeRunId}:${currentLevelIndex}:${currentLevelStartedAtMs}:${secondBucket}`;
if (countdownSoundKeyRef.current === soundKey) {
return;
}
countdownSoundKeyRef.current = soundKey;
playRuntimeCountdownSound(musicVolume);
}, [
currentLevelIndex,
currentLevelStartedAtMs,
currentLevelStatus,
displayRemainingMs,
levelAudioConfig.countdownWarningThresholdMs,
musicVolume,
runtimeRunId,
]);
useEffect(
() => () => {
if (hintDemoTimeoutRef.current !== null) {
@@ -853,6 +925,10 @@ export function PuzzleRuntimeShell({
// 通关后先保留完整画面,再播放对角线闪光,最后延迟弹出结算弹窗。
clearPresentationKeyRef.current = clearResultKey;
if (clearSoundKeyRef.current !== clearResultKey) {
clearSoundKeyRef.current = clearResultKey;
playRuntimeLevelClearSound(musicVolume);
}
clearPresentationTimeouts();
setIsClearFlashVisible(true);
setIsClearResultReady(false);
@@ -864,7 +940,7 @@ export function PuzzleRuntimeShell({
setIsClearResultReady(true);
}, PUZZLE_CLEAR_FLASH_DURATION_MS + PUZZLE_CLEAR_DIALOG_DELAY_MS),
];
}, [clearResultKey, currentLevel, dismissedClearKey]);
}, [clearResultKey, currentLevel, dismissedClearKey, musicVolume]);
const handlePieceTap = (
pieceId: string,
@@ -994,7 +1070,7 @@ export function PuzzleRuntimeShell({
syncRuntimeDragFromController(session);
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
commitSelectedPieceId(session.targetId);
triggerPuzzlePiecePressHapticFeedback();
triggerPuzzlePiecePressFeedback(musicVolume);
},
onDragStart: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
@@ -1352,16 +1428,38 @@ export function PuzzleRuntimeShell({
<div
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
>
{resolvedBackgroundMusicSrc ? (
<audio
ref={backgroundAudioRef}
src={resolvedBackgroundMusicSrc}
loop
preload="auto"
/>
) : null}
<div className="puzzle-runtime-stage relative h-full w-full overflow-hidden">
{resolvedUiBackgroundImage ? (
<ResolvedAssetImage
src={resolvedUiBackgroundImage}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
/>
) : null}
{currentLevel.coverImageSrc ? (
<ResolvedAssetImage
src={currentLevel.coverImageSrc}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover opacity-[0.16] blur-2xl"
className={`absolute inset-0 h-full w-full object-cover blur-2xl ${
resolvedUiBackgroundImage ? 'opacity-[0.06]' : 'opacity-[0.16]'
}`}
/>
) : null}
<div className="puzzle-runtime-stage__grid" />
<div
className={`puzzle-runtime-stage__grid ${
resolvedUiBackgroundImage ? 'opacity-20' : ''
}`}
/>
<div className="absolute left-0 top-0 z-20 w-full px-3 py-3 sm:px-4">
<div className="grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-start gap-2 sm:grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] sm:gap-3">