1
This commit is contained in:
@@ -2,6 +2,8 @@ export {
|
||||
buildLocalMatch3DOptimisticRun,
|
||||
confirmLocalMatch3DClick,
|
||||
MATCH3D_VISUAL_SEEDS,
|
||||
normalizeLocalMatch3DRuntimeClearCount,
|
||||
resolveLocalMatch3DItemTypeCount,
|
||||
resolveLocalMatch3DTimer,
|
||||
startLocalMatch3DRun,
|
||||
stopLocalMatch3DRun,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
'启动抓大鹅玩法失败',
|
||||
{
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
export {
|
||||
deleteMatch3DWork,
|
||||
generateMatch3DBackgroundImage,
|
||||
generateMatch3DCoverImage,
|
||||
generateMatch3DItemAssets,
|
||||
generateMatch3DWorkTags,
|
||||
getMatch3DWorkDetail,
|
||||
listMatch3DGallery,
|
||||
listMatch3DWorks,
|
||||
match3dWorksClient,
|
||||
persistMatch3DGeneratedModel,
|
||||
publishMatch3DWork,
|
||||
updateMatch3DAudioAssets,
|
||||
updateMatch3DGeneratedItemAssets,
|
||||
|
||||
@@ -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,
|
||||
|
||||
116
src/services/match3dGeneratedModelCache.test.ts
Normal file
116
src/services/match3dGeneratedModelCache.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
200
src/services/match3dGeneratedModelCache.ts
Normal file
200
src/services/match3dGeneratedModelCache.ts
Normal 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();
|
||||
}
|
||||
@@ -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 件',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
69
src/services/runtimeAudioFeedback.ts
Normal file
69
src/services/runtimeAudioFeedback.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user