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

@@ -2,6 +2,8 @@ export {
buildLocalMatch3DOptimisticRun,
confirmLocalMatch3DClick,
MATCH3D_VISUAL_SEEDS,
normalizeLocalMatch3DRuntimeClearCount,
resolveLocalMatch3DItemTypeCount,
resolveLocalMatch3DTimer,
startLocalMatch3DRun,
stopLocalMatch3DRun,

View File

@@ -221,8 +221,23 @@ function resolveSizeTierPlan(typeCount: number) {
return baseCounts.flatMap((rule) => Array(rule.count).fill(rule));
}
export function resolveLocalMatch3DItemTypeCount(clearCount: number) {
const normalizedClearCount = Math.max(1, Math.round(clearCount));
if (normalizedClearCount === 8) return 3;
if (normalizedClearCount === 12) return 9;
if (normalizedClearCount === 16) return 15;
if (normalizedClearCount === 20 || normalizedClearCount === 21) return 21;
return Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, normalizedClearCount);
}
export function normalizeLocalMatch3DRuntimeClearCount(clearCount: number) {
const normalizedClearCount = Math.max(1, Math.round(clearCount));
// 中文注释:旧硬核草稿可能仍带 20 次消除;本地试玩按新硬核 21 组三消执行。
return normalizedClearCount === 20 ? 21 : normalizedClearCount;
}
function selectVisualSeeds(clearCount: number): Match3DSelectedVisualSeed[] {
const typeCount = Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, clearCount);
const typeCount = resolveLocalMatch3DItemTypeCount(clearCount);
const seeds = [...MATCH3D_VISUAL_SEEDS];
let state = hashNumber(clearCount * 2_654_435_761);
for (let index = seeds.length - 1; index > 0; index -= 1) {
@@ -410,7 +425,7 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) {
}
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
const normalizedClearCount = Math.max(1, Math.round(clearCount));
const normalizedClearCount = normalizeLocalMatch3DRuntimeClearCount(clearCount);
const selectedSeeds = selectVisualSeeds(normalizedClearCount);
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
Array.from({ length: 3 }, (_, copyOffset) => {

View File

@@ -5,6 +5,7 @@ import type {
Match3DClickRejectReason,
Match3DClickResponse,
Match3DRunResponse,
StartMatch3DRunRequest,
StopMatch3DRunRequest,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import {
@@ -30,7 +31,9 @@ type Match3DRuntimeRequestOptions = Pick<
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
> & {
itemTypeCountOverride?: number | null;
};
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
switch (reason) {
@@ -73,12 +76,17 @@ export function startMatch3DRun(
profileId: string,
options: Match3DRuntimeRequestOptions = {},
) {
const payload: StartMatch3DRunRequest = {
profileId,
itemTypeCountOverride: options.itemTypeCountOverride ?? null,
};
return requestJson<Match3DRunResponse>(
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileId }),
body: JSON.stringify(payload),
},
'启动抓大鹅玩法失败',
{

View File

@@ -1,10 +1,14 @@
export {
deleteMatch3DWork,
generateMatch3DBackgroundImage,
generateMatch3DCoverImage,
generateMatch3DItemAssets,
generateMatch3DWorkTags,
getMatch3DWorkDetail,
listMatch3DGallery,
listMatch3DWorks,
match3dWorksClient,
persistMatch3DGeneratedModel,
publishMatch3DWork,
updateMatch3DAudioAssets,
updateMatch3DGeneratedItemAssets,

View File

@@ -1,9 +1,17 @@
import type {
GenerateMatch3DBackgroundImageRequest,
GenerateMatch3DBackgroundImageResponse,
GenerateMatch3DCoverImageRequest,
GenerateMatch3DCoverImageResponse,
GenerateMatch3DItemAssetsRequest,
GenerateMatch3DItemAssetsResponse,
GenerateMatch3DWorkTagsRequest,
GenerateMatch3DWorkTagsResponse,
Match3DWorkDetailResponse,
Match3DWorkMutationResponse,
Match3DWorksResponse,
PersistMatch3DGeneratedModelRequest,
PersistMatch3DGeneratedModelResponse,
PutMatch3DAudioAssetsRequest,
PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
@@ -103,10 +111,100 @@ export function updateMatch3DGeneratedItemAssets(
export const updateMatch3DAudioAssets = updateMatch3DGeneratedItemAssets;
/**
* 将历史外部 GLB 链接转存为抓大鹅私有模型资产;新草稿不再调用。
*/
export function persistMatch3DGeneratedModel(
profileId: string,
payload: PersistMatch3DGeneratedModelRequest,
) {
return requestJson<PersistMatch3DGeneratedModelResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/generated-models`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'保存抓大鹅历史模型失败',
{
retry: MATCH3D_WORKS_WRITE_RETRY,
timeoutMs: 240_000,
},
);
}
/**
* 生成并保存抓大鹅作品封面图。
*/
export function generateMatch3DCoverImage(
profileId: string,
payload: GenerateMatch3DCoverImageRequest,
) {
return requestJson<GenerateMatch3DCoverImageResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/cover-image`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'生成抓大鹅封面图失败',
{
retry: MATCH3D_WORKS_WRITE_RETRY,
timeoutMs: 240_000,
},
);
}
/**
* 按画面描述重新生成并保存抓大鹅局内 UI 背景图。
*/
export function generateMatch3DBackgroundImage(
profileId: string,
payload: GenerateMatch3DBackgroundImageRequest,
) {
return requestJson<GenerateMatch3DBackgroundImageResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/background-image`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'生成抓大鹅背景图失败',
{
retry: MATCH3D_WORKS_WRITE_RETRY,
timeoutMs: 240_000,
},
);
}
/**
* 按名称批量生成抓大鹅 2D 五视角物品图片。
*/
export function generateMatch3DItemAssets(
profileId: string,
payload: GenerateMatch3DItemAssetsRequest,
) {
return requestJson<GenerateMatch3DItemAssetsResponse>(
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/item-assets`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'生成抓大鹅物品素材失败',
{
retry: MATCH3D_WORKS_WRITE_RETRY,
timeoutMs: 20 * 60 * 1000,
},
);
}
/**
* 根据当前作品名称与题材生成发布标签。
*/
export function generateMatch3DWorkTags(payload: GenerateMatch3DWorkTagsRequest) {
export function generateMatch3DWorkTags(
payload: GenerateMatch3DWorkTagsRequest,
) {
return requestJson<GenerateMatch3DWorkTagsResponse>(
`${MATCH3D_WORKS_API_BASE}/tags`,
{
@@ -145,10 +243,14 @@ export function deleteMatch3DWork(profileId: string) {
export const match3dWorksClient = {
delete: deleteMatch3DWork,
generateBackgroundImage: generateMatch3DBackgroundImage,
generateCoverImage: generateMatch3DCoverImage,
generateItemAssets: generateMatch3DItemAssets,
generateTags: generateMatch3DWorkTags,
getDetail: getMatch3DWorkDetail,
listGallery: listMatch3DGallery,
list: listMatch3DWorks,
persistGeneratedModel: persistMatch3DGeneratedModel,
publish: publishMatch3DWork,
updateAudioAssets: updateMatch3DAudioAssets,
updateGeneratedItemAssets: updateMatch3DGeneratedItemAssets,

View File

@@ -0,0 +1,116 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { setStoredAccessToken, clearStoredAccessToken } from './apiClient';
import {
clearMatch3DGeneratedModelBytesCache,
getMatch3DGeneratedModelAssetSources,
preloadMatch3DGeneratedModelAssets,
readMatch3DGeneratedModelBytes,
} from './match3dGeneratedModelCache';
describe('match3dGeneratedModelCache', () => {
afterEach(() => {
vi.restoreAllMocks();
clearMatch3DGeneratedModelBytesCache();
clearStoredAccessToken({ emit: false });
});
test('预加载生成模型字节并复用本地缓存', async () => {
setStoredAccessToken('test-access-token', { emit: false });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(new Uint8Array([103, 108, 84, 70]), {
status: 200,
headers: {
'Content-Type': 'model/gltf-binary',
},
}),
);
await preloadMatch3DGeneratedModelAssets(
[
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
modelSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
modelObjectKey: null,
modelFileName: 'strawberry.glb',
taskUuid: null,
subscriptionKey: null,
status: 'model_ready',
error: null,
},
],
{ expireSeconds: 300 },
);
const bytes = await readMatch3DGeneratedModelBytes(
'/generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
{ expireSeconds: 300 },
);
expect(Array.from(new Uint8Array(bytes))).toEqual([103, 108, 84, 70]);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
test('模型源列表会去重并兼容 modelObjectKey', () => {
const sources = getMatch3DGeneratedModelAssetSources([
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
modelSrc: null,
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
modelFileName: 'strawberry.glb',
taskUuid: null,
subscriptionKey: null,
status: 'model_ready',
error: null,
},
{
itemId: 'match3d-item-1-duplicate',
itemName: '草莓副本',
imageSrc: null,
imageObjectKey: null,
modelSrc:
'generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
modelObjectKey: null,
modelFileName: 'strawberry.glb',
taskUuid: null,
subscriptionKey: null,
status: 'model_ready',
error: null,
},
]);
expect(sources).toEqual([
'generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
]);
});
test('同时存在外部 modelSrc 和平台 modelObjectKey 时优先预加载平台对象', () => {
const sources = getMatch3DGeneratedModelAssetSources([
{
itemId: 'match3d-item-legacy',
itemName: '苹果',
imageSrc: null,
imageObjectKey: null,
modelSrc: 'https://rodin.example.com/expired/model.glb',
modelObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-legacy/model.glb',
modelFileName: 'apple.glb',
taskUuid: null,
subscriptionKey: null,
status: 'model_ready',
error: null,
},
]);
expect(sources).toEqual([
'generated-match3d-assets/session/profile/items/match3d-item-legacy/model.glb',
]);
});
});

View File

@@ -0,0 +1,200 @@
import type { Match3DGeneratedItemAsset } from '../../packages/shared/src/contracts/match3dWorks';
import { readAssetBytes } from './assetReadUrlService';
type CachedMatch3DModelBytes = {
accessedAt: number;
promise: Promise<ArrayBuffer>;
};
type Match3DModelBytesOptions = {
expireSeconds?: number;
signal?: AbortSignal;
};
const MATCH3D_MODEL_BYTES_CACHE_LIMIT = 36;
const match3dModelBytesCache = new Map<string, CachedMatch3DModelBytes>();
function normalizeMatch3DModelSource(source: string | null | undefined) {
return source?.trim() ?? '';
}
function isExternalMatch3DModelSource(source: string) {
return /^(?:https?:)?\/\//iu.test(source.trim());
}
function trimMatch3DModelBytesCache() {
if (match3dModelBytesCache.size <= MATCH3D_MODEL_BYTES_CACHE_LIMIT) {
return;
}
const staleKeys = [...match3dModelBytesCache.entries()]
.sort((left, right) => left[1].accessedAt - right[1].accessedAt)
.slice(0, match3dModelBytesCache.size - MATCH3D_MODEL_BYTES_CACHE_LIMIT)
.map(([source]) => source);
staleKeys.forEach((source) => match3dModelBytesCache.delete(source));
}
function waitWithAbort<T>(promise: Promise<T>, signal?: AbortSignal) {
if (!signal) {
return promise;
}
if (signal.aborted) {
return Promise.reject(new DOMException('加载已取消', 'AbortError'));
}
return new Promise<T>((resolve, reject) => {
const handleAbort = () => {
signal.removeEventListener('abort', handleAbort);
reject(new DOMException('加载已取消', 'AbortError'));
};
signal.addEventListener('abort', handleAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener('abort', handleAbort);
resolve(value);
},
(error) => {
signal.removeEventListener('abort', handleAbort);
reject(error);
},
);
});
}
export function resolveMatch3DGeneratedModelAssetSource(
asset: Match3DGeneratedItemAsset,
) {
// 中文注释:历史草稿可能同时保留已过期的 Rodin 外部 modelSrc 和后续修复出的平台 objectKey
// 试玩、正式游戏和预览都必须优先读取平台私有对象,避免继续请求过期外链。
const modelSrc = normalizeMatch3DModelSource(asset.modelSrc);
const objectKey = normalizeMatch3DModelSource(asset.modelObjectKey);
if (modelSrc && (!isExternalMatch3DModelSource(modelSrc) || !objectKey)) {
return modelSrc;
}
return objectKey || modelSrc;
}
export function resolveMatch3DGeneratedImageViewSource(
view:
| NonNullable<Match3DGeneratedItemAsset['imageViews']>[number]
| null
| undefined,
) {
const imageSrc = normalizeMatch3DModelSource(view?.imageSrc);
const objectKey = normalizeMatch3DModelSource(view?.imageObjectKey);
return objectKey || imageSrc;
}
export function getMatch3DGeneratedImageViewSources(
asset: Match3DGeneratedItemAsset,
) {
const sources =
asset.imageViews
?.map(resolveMatch3DGeneratedImageViewSource)
.filter((source) => source.length > 0) ?? [];
const primarySource =
normalizeMatch3DModelSource(asset.imageObjectKey) ||
normalizeMatch3DModelSource(asset.imageSrc);
return [...new Set(primarySource ? [primarySource, ...sources] : sources)];
}
export function resolveMatch3DGeneratedImageAssetSource(
asset: Match3DGeneratedItemAsset,
) {
return getMatch3DGeneratedImageViewSources(asset)[0] ?? '';
}
export function getMatch3DGeneratedImageAssetSources(
assets: readonly Match3DGeneratedItemAsset[] = [],
) {
return [
...new Set(
assets.flatMap((asset) => getMatch3DGeneratedImageViewSources(asset)),
),
];
}
export function getMatch3DGeneratedModelAssetSources(
assets: readonly Match3DGeneratedItemAsset[] = [],
) {
return [
...new Set(
assets
.map(resolveMatch3DGeneratedModelAssetSource)
.filter((source) => source.length > 0),
),
];
}
export function readMatch3DGeneratedModelBytes(
source: string | null | undefined,
options: Match3DModelBytesOptions = {},
) {
const normalizedSource = normalizeMatch3DModelSource(source);
if (!normalizedSource) {
return Promise.reject(new Error('抓大鹅 3D 模型路径不能为空'));
}
const cached = match3dModelBytesCache.get(normalizedSource);
if (cached) {
cached.accessedAt = Date.now();
return waitWithAbort(cached.promise, options.signal);
}
const entry: CachedMatch3DModelBytes = {
accessedAt: Date.now(),
promise: readAssetBytes(normalizedSource, {
expireSeconds: options.expireSeconds,
}).then(async (response) => {
const bytes = await response.arrayBuffer();
if (bytes.byteLength <= 0) {
throw new Error('抓大鹅 3D 模型内容为空');
}
return bytes;
}),
};
match3dModelBytesCache.set(normalizedSource, entry);
trimMatch3DModelBytesCache();
entry.promise.catch(() => {
if (match3dModelBytesCache.get(normalizedSource) === entry) {
match3dModelBytesCache.delete(normalizedSource);
}
});
return waitWithAbort(entry.promise, options.signal);
}
export async function preloadMatch3DGeneratedModelSources(
sources: readonly string[],
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
) {
const normalizedSources = [
...new Set(
sources
.map(normalizeMatch3DModelSource)
.filter((source) => source.length > 0),
),
];
await Promise.allSettled(
normalizedSources.map((source) =>
readMatch3DGeneratedModelBytes(source, {
expireSeconds: options.expireSeconds,
}),
),
);
}
export function preloadMatch3DGeneratedModelAssets(
assets: readonly Match3DGeneratedItemAsset[] = [],
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
) {
return preloadMatch3DGeneratedModelSources(
getMatch3DGeneratedModelAssetSources(assets),
options,
);
}
export function clearMatch3DGeneratedModelBytesCache() {
match3dModelBytesCache.clear();
}

View File

@@ -166,7 +166,7 @@ describe('miniGameDraftGenerationProgress', () => {
'match3d-material-sheet',
'match3d-slice-images',
'match3d-upload-images',
'match3d-generate-models',
'match3d-generate-views',
]);
expect(progress?.phaseId).toBe('match3d-material-sheet');
expect(progress?.phaseLabel).toBe('生成素材图');
@@ -186,10 +186,10 @@ describe('miniGameDraftGenerationProgress', () => {
expect(progress?.steps[0]?.detail).toBe('根据题材设定生成作品名称与标签。');
});
test('match3d draft generation keeps backend observed model phase', () => {
test('match3d draft generation keeps backend observed asset phase', () => {
const state = {
...createMiniGameDraftGenerationState('match3d'),
phase: 'match3d-generate-models' as const,
phase: 'match3d-generate-views' as const,
completedAssetCount: 1,
totalAssetCount: 3,
};
@@ -199,12 +199,13 @@ describe('miniGameDraftGenerationProgress', () => {
state.startedAtMs + 20_000,
);
expect(progress?.phaseId).toBe('match3d-generate-models');
expect(progress?.phaseId).toBe('match3d-generate-views');
expect(progress?.steps.at(-1)?.detail).toContain('点击音效');
expect(progress?.steps.at(-1)?.completed).toBe(1);
expect(progress?.steps.at(-1)?.total).toBe(3);
});
test('match3d generation anchors show theme and fixed three items', () => {
test('match3d generation anchors show theme and difficulty item count', () => {
const entries = buildMatch3DGenerationAnchorEntries(null, {
themeText: '水果',
clearCount: 20,
@@ -221,7 +222,7 @@ describe('miniGameDraftGenerationProgress', () => {
{
id: 'match3d-items',
label: '物品数量',
value: '3 件',
value: '21 件',
},
]);
});

View File

@@ -35,7 +35,7 @@ export type MiniGameDraftGenerationPhase =
| 'match3d-material-sheet'
| 'match3d-slice-images'
| 'match3d-upload-images'
| 'match3d-generate-models'
| 'match3d-generate-views'
| 'match3d-ready'
| 'puzzle-images'
| 'puzzle-select-image'
@@ -151,31 +151,31 @@ const MATCH3D_STEPS = [
{
id: 'match3d-item-names',
label: '生成物品名称',
detail: '根据题材生成本局的 3 个物品名称。',
detail: '根据难度生成本局物品名称。',
weight: 8,
},
{
id: 'match3d-material-sheet',
label: '生成素材图',
detail: '生成一张 1:1 的网格素材图。',
detail: '按 1K 参数分批生成 5x5 多视角素材图。',
weight: 18,
},
{
id: 'match3d-slice-images',
label: '切割独立图片',
detail: '把素材图切成独立物品参考图。',
detail: '把素材图切成每个物品的五个视角。',
weight: 8,
},
{
id: 'match3d-upload-images',
label: '上传图片资产',
detail: '写入素材图和独立物品参考图。',
detail: '写入独立 2D 视角素材。',
weight: 8,
},
{
id: 'match3d-generate-models',
label: '生成3D模型',
detail: '调用 Hyper3D Rodin 生成 GLB 模型并转存。',
id: 'match3d-generate-views',
label: '整理素材',
detail: '校验多视角素材并按需并行生成点击音效。',
weight: 50,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
@@ -188,7 +188,7 @@ const MATCH3D_PHASE_ORDER: Partial<
'match3d-material-sheet': 2,
'match3d-slice-images': 3,
'match3d-upload-images': 4,
'match3d-generate-models': 5,
'match3d-generate-views': 5,
};
function clampProgress(value: number) {
@@ -298,7 +298,7 @@ function resolveMatch3DPhaseByElapsedMs(
): MiniGameDraftGenerationPhase {
const elapsedPhase =
elapsedMs >= 92_000
? 'match3d-generate-models'
? 'match3d-generate-views'
: elapsedMs >= 72_000
? 'match3d-upload-images'
: elapsedMs >= 58_000
@@ -552,7 +552,9 @@ export function buildMatch3DGenerationAnchorEntries(
}
const config = session?.config;
const itemCount = 3;
const clearCount = formPayload?.clearCount ?? config?.clearCount ?? null;
const difficulty = formPayload?.difficulty ?? config?.difficulty ?? null;
const itemCount = resolveMatch3DGeneratedItemCount(clearCount, difficulty);
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'match3d-theme',
@@ -580,6 +582,24 @@ export function buildMatch3DGenerationAnchorEntries(
.filter((entry) => entry.value.trim());
}
function resolveMatch3DGeneratedItemCount(
clearCount: number | null | undefined,
difficulty: number | null | undefined,
) {
if (clearCount === 8) return 3;
if (clearCount === 12) return 9;
if (clearCount === 16) return 15;
if (clearCount === 20 || clearCount === 21) return 21;
const normalizedDifficulty =
typeof difficulty === 'number' && Number.isFinite(difficulty)
? Math.max(1, Math.min(10, Math.round(difficulty)))
: 4;
if (normalizedDifficulty <= 2) return 3;
if (normalizedDifficulty <= 4) return 9;
if (normalizedDifficulty <= 6) return 15;
return 21;
}
export function buildSquareHoleGenerationAnchorEntries(
session: SquareHoleSessionSnapshot | null | undefined,
): CustomWorldStructuredAnchorEntry[] {

View File

@@ -0,0 +1,69 @@
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_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 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));
}