Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
76 lines
2.4 KiB
TypeScript
76 lines
2.4 KiB
TypeScript
export const DEFAULT_RUNTIME_CLICK_SOUND_SRC = '/audio/ui-click-soft.wav';
|
||
export const DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC =
|
||
'/audio/ui-level-clear.wav';
|
||
export const DEFAULT_RUNTIME_MERGE_SOUND_SRC =
|
||
DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC;
|
||
export const DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC =
|
||
'/audio/ui-countdown-warning.wav';
|
||
export const DEFAULT_RUNTIME_COUNTDOWN_WARNING_THRESHOLD_MS = 5_000;
|
||
|
||
export const DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG = {
|
||
clickSoundSrc: DEFAULT_RUNTIME_CLICK_SOUND_SRC,
|
||
levelClearSoundSrc: DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC,
|
||
countdownSoundSrc: DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC,
|
||
countdownWarningThresholdMs: DEFAULT_RUNTIME_COUNTDOWN_WARNING_THRESHOLD_MS,
|
||
} as const;
|
||
|
||
const runtimeAudioCache = new Map<string, HTMLAudioElement>();
|
||
|
||
function clampRuntimeAudioVolume(value: number) {
|
||
if (!Number.isFinite(value)) {
|
||
return 0.6;
|
||
}
|
||
return Math.max(0, Math.min(1, value));
|
||
}
|
||
|
||
export function playRuntimeClickSound(
|
||
source = DEFAULT_RUNTIME_CLICK_SOUND_SRC,
|
||
volume = 0.6,
|
||
) {
|
||
if (import.meta.env.MODE === 'test' || typeof Audio === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
const normalizedSource = source.trim();
|
||
if (!normalizedSource) {
|
||
return;
|
||
}
|
||
|
||
const audio =
|
||
runtimeAudioCache.get(normalizedSource) ?? new Audio(normalizedSource);
|
||
runtimeAudioCache.set(normalizedSource, audio);
|
||
audio.currentTime = 0;
|
||
audio.volume = clampRuntimeAudioVolume(volume);
|
||
try {
|
||
const playResult = audio.play();
|
||
void playResult?.catch?.(() => {
|
||
// 中文注释:浏览器可能在用户手势外拒绝播放,点击反馈不应中断主交互。
|
||
});
|
||
} catch {
|
||
// 中文注释:测试环境或极端浏览器可能未实现 play,同样不能影响主交互。
|
||
}
|
||
}
|
||
|
||
export function playRuntimeLevelClearSound(volume = 0.6) {
|
||
playRuntimeClickSound(DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC, volume);
|
||
}
|
||
|
||
export function playRuntimeMergeSound(volume = 0.6) {
|
||
playRuntimeClickSound(DEFAULT_RUNTIME_MERGE_SOUND_SRC, volume);
|
||
}
|
||
|
||
export function playRuntimeCountdownSound(volume = 0.6) {
|
||
playRuntimeClickSound(DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC, volume);
|
||
}
|
||
|
||
export function resolveRuntimeCountdownSecondBucket(remainingMs: number) {
|
||
if (
|
||
!Number.isFinite(remainingMs) ||
|
||
remainingMs <= 0 ||
|
||
remainingMs > DEFAULT_RUNTIME_COUNTDOWN_WARNING_THRESHOLD_MS
|
||
) {
|
||
return null;
|
||
}
|
||
return Math.max(1, Math.ceil(remainingMs / 1000));
|
||
}
|