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

@@ -6,7 +6,10 @@ import type {
} from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import { isDebugMode } from '../../config/debugMode';
import { readAssetBytes } from '../../services/assetReadUrlService';
import {
readMatch3DGeneratedModelBytes,
resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
import {
isItemState,
resolveRenderableItemFrame,
@@ -111,6 +114,8 @@ type PhysicsRuntime = {
animationId: number | null;
camera: ThreeCamera;
entries: Map<string, PhysicsEntry>;
failedGeneratedModelTypeIds: Set<string>;
generatedModelByType: Map<string, Match3DGeneratedItemAsset>;
generatedModelTemplates: Match3DGeneratedModelTemplateMap;
pendingSpawns: Map<string, PendingPhysicsSpawn>;
raycaster: import('three').Raycaster;
@@ -170,7 +175,7 @@ export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape>
function normalizeMatch3DGeneratedModelSource(
asset: Match3DGeneratedItemAsset,
) {
return asset.modelSrc?.trim() || asset.modelObjectKey?.trim() || '';
return resolveMatch3DGeneratedModelAssetSource(asset);
}
function compareMatch3DGeneratedTypeId(left: string, right: string) {
@@ -213,6 +218,7 @@ export function buildMatch3DGeneratedAssetTypeMap(
...resolved.asset,
modelSrc: resolved.source,
});
debugMatch3DGeneratedModelMapped(itemTypeId, resolved.source);
});
return assetMap;
@@ -237,12 +243,16 @@ function resolveGeneratedModelSourceForItemType(
return asset ? normalizeMatch3DGeneratedModelSource(asset) : '';
}
function shouldLogMatch3DGeneratedModelDiagnostics() {
return isDebugMode() && import.meta.env.MODE !== 'test';
}
function warnMatch3DGeneratedModelLoadFailure(
itemTypeId: string,
source: string,
error: unknown,
) {
if (!isDebugMode()) {
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
return;
}
const message =
@@ -254,6 +264,32 @@ function warnMatch3DGeneratedModelLoadFailure(
});
}
function debugMatch3DGeneratedModelLoaded(
itemTypeId: string,
source: string,
) {
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
return;
}
console.debug('[match3d] generated model loaded', {
itemTypeId,
source,
});
}
function debugMatch3DGeneratedModelMapped(
itemTypeId: string,
source: string,
) {
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
return;
}
console.debug('[match3d] generated model mapped', {
itemTypeId,
source,
});
}
async function loadMatch3DGeneratedModelTemplate(
templateMap: Match3DGeneratedModelTemplateMap,
three: ThreeModule,
@@ -265,11 +301,10 @@ async function loadMatch3DGeneratedModelTemplate(
if (cached?.source === source) {
return cached.scene;
}
const response = await readAssetBytes(source, {
const bytes = await readMatch3DGeneratedModelBytes(source, {
expireSeconds: 300,
signal,
});
const bytes = await response.arrayBuffer();
if (bytes.byteLength === 0) {
throw new Error('抓大鹅 3D 模型内容为空');
}
@@ -297,6 +332,7 @@ async function loadMatch3DGeneratedModelTemplate(
scene,
source,
});
debugMatch3DGeneratedModelLoaded(itemTypeId, source);
return scene;
}
@@ -327,7 +363,6 @@ function createGeneratedModelMesh(
}
const position = toWorldPosition(item);
const model = cloneThreeObjectWithMaterials(template);
markObjectForItem(model, item.itemInstanceId);
const bounds = new three.Box3().setFromObject(model);
const size = bounds.getSize(new three.Vector3());
const dimension = Math.max(size.x, size.y, size.z, 0.001);
@@ -341,10 +376,13 @@ function createGeneratedModelMesh(
model.position.sub(center);
const bottomY = scaledBounds.min.y - center.y;
model.position.y -= bottomY;
const pivot = new three.Group();
pivot.add(model);
markObjectForItem(pivot, item.itemInstanceId);
return {
lockReadableTop: false,
mesh: model,
mesh: pivot,
radius: position.radius,
shape: 'brick' as Match3DGeometryShape,
topRotationY: ((item.layer % 12) / 12) * Math.PI * 2,
@@ -1157,6 +1195,22 @@ function createItemMesh(
);
}
function shouldWaitForGeneratedModelTemplate(
generatedModelByType: Map<string, Match3DGeneratedItemAsset>,
templateMap: Match3DGeneratedModelTemplateMap,
failedTypeIds: ReadonlySet<string>,
itemTypeId: string,
) {
const source = resolveGeneratedModelSourceForItemType(
generatedModelByType,
itemTypeId,
);
// 中文注释:坏 GLB 或过期链接不能让整局空等模板;失败类型应立即走默认几何降级。
return Boolean(
source && !templateMap.has(itemTypeId) && !failedTypeIds.has(itemTypeId),
);
}
export function buildMatch3DPhysicsEntrySignature(
runId: string,
item: Match3DItemSnapshot,
@@ -1192,6 +1246,16 @@ function createPhysicsEntryFromPendingSpawn(
now: number,
templateMap?: Match3DGeneratedModelTemplateMap | null,
) {
if (
shouldWaitForGeneratedModelTemplate(
runtime.generatedModelByType,
runtime.generatedModelTemplates,
runtime.failedGeneratedModelTypeIds,
pendingSpawn.item.itemTypeId,
)
) {
return;
}
const visual = createItemMesh(runtime.three, pendingSpawn.item, templateMap);
const asset = resolveGeometryAsset(pendingSpawn.item.visualKey);
const colliderBounds = resolveMatch3DColliderBounds(asset, visual.radius);
@@ -1273,7 +1337,17 @@ function createPhysicsEntryFromPendingSpawn(
function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
const readySpawns = [...runtime.pendingSpawns.entries()]
.filter(([, pendingSpawn]) => now >= pendingSpawn.spawnAtMs)
.filter(([, pendingSpawn]) => {
if (now < pendingSpawn.spawnAtMs) {
return false;
}
return !shouldWaitForGeneratedModelTemplate(
runtime.generatedModelByType,
runtime.generatedModelTemplates,
runtime.failedGeneratedModelTypeIds,
pendingSpawn.item.itemTypeId,
);
})
.sort((left, right) => {
if (left[1].spawnAtMs !== right[1].spawnAtMs) {
return left[1].spawnAtMs - right[1].spawnAtMs;
@@ -1317,6 +1391,7 @@ type TrayPreviewRuntime = {
animationId: number | null;
camera: ThreeCamera;
entries: Map<string, ThreeObject3D>;
failedGeneratedModelTypeIds: Set<string>;
generatedModelTemplates: Match3DGeneratedModelTemplateMap;
renderer: ThreeRenderer;
scene: ThreeScene;
@@ -1620,6 +1695,8 @@ export function Match3DTrayPreviewBoard({
animationId: null,
camera,
entries: runtimeRef.current?.entries ?? new Map(),
failedGeneratedModelTypeIds:
runtimeRef.current?.failedGeneratedModelTypeIds ?? new Set(),
generatedModelTemplates:
runtimeRef.current?.generatedModelTemplates ?? new Map(),
renderer,
@@ -1645,6 +1722,7 @@ export function Match3DTrayPreviewBoard({
animationId: window.requestAnimationFrame(animate),
camera,
entries: new Map(),
failedGeneratedModelTypeIds: new Set(),
generatedModelTemplates: new Map(),
renderer,
scene,
@@ -1687,6 +1765,7 @@ export function Match3DTrayPreviewBoard({
staleItemTypeIds.delete(itemTypeId);
const hadFreshTemplate =
runtime.generatedModelTemplates.get(itemTypeId)?.source === source;
runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
void loadMatch3DGeneratedModelTemplate(
runtime.generatedModelTemplates,
runtime.three,
@@ -1721,6 +1800,8 @@ export function Match3DTrayPreviewBoard({
caughtError,
);
runtime.generatedModelTemplates.delete(itemTypeId);
runtime.failedGeneratedModelTypeIds.add(itemTypeId);
setTrayModelRevision((current) => current + 1);
});
});
staleItemTypeIds.forEach((itemTypeId) => {
@@ -1729,6 +1810,7 @@ export function Match3DTrayPreviewBoard({
disposeThreeObject(template.scene);
}
runtime.generatedModelTemplates.delete(itemTypeId);
runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
});
return () => {
@@ -1785,7 +1867,9 @@ export function Match3DTrayPreviewBoard({
const preview = createItemMesh(
runtime.three,
item,
runtime.generatedModelTemplates,
runtime.failedGeneratedModelTypeIds.has(item.itemTypeId)
? null
: runtime.generatedModelTemplates,
);
const model = preview.mesh;
const rotation = resolveMatch3DTrayPreviewRotation(item.visualKey);
@@ -2050,6 +2134,8 @@ export function Match3DPhysicsBoard({
animationId: null,
camera,
entries: new Map(),
failedGeneratedModelTypeIds: new Set(),
generatedModelByType,
generatedModelTemplates: new Map(),
pendingSpawns: new Map(),
raycaster: new three.Raycaster(),
@@ -2157,6 +2243,7 @@ export function Match3DPhysicsBoard({
if (!runtime) {
return undefined;
}
runtime.generatedModelByType = generatedModelByType;
const abortController = new AbortController();
const staleItemTypeIds = new Set(runtime.generatedModelTemplates.keys());
generatedModelByType.forEach((asset, itemTypeId) => {
@@ -2167,6 +2254,7 @@ export function Match3DPhysicsBoard({
}
const hadFreshTemplate =
runtime.generatedModelTemplates.get(itemTypeId)?.source === source;
runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
void loadMatch3DGeneratedModelTemplate(
runtime.generatedModelTemplates,
runtime.three,
@@ -2201,6 +2289,8 @@ export function Match3DPhysicsBoard({
caughtError,
);
runtime.generatedModelTemplates.delete(itemTypeId);
runtime.failedGeneratedModelTypeIds.add(itemTypeId);
setGeneratedModelRevision((current) => current + 1);
});
});
staleItemTypeIds.forEach((itemTypeId) => {
@@ -2209,6 +2299,7 @@ export function Match3DPhysicsBoard({
disposeThreeObject(template.scene);
}
runtime.generatedModelTemplates.delete(itemTypeId);
runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
});
return () => {

View File

@@ -13,6 +13,7 @@ import type {
} from '../../../packages/shared/src/contracts/match3dRuntime';
import {
confirmLocalMatch3DClick,
resolveLocalMatch3DItemTypeCount,
startLocalMatch3DRun,
} from '../../services/match3d-runtime';
import {
@@ -79,7 +80,10 @@ afterEach(() => {
).__MATCH3D_KEEP_3D_TEST_RENDER__;
});
function renderRuntime(run: Match3DRunSnapshot) {
function renderRuntime(
run: Match3DRunSnapshot,
generatedItemAssets: Match3DGeneratedItemAsset[] = [],
) {
let currentRun = run;
let authorityRun = run;
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => {
@@ -92,6 +96,7 @@ function renderRuntime(run: Match3DRunSnapshot) {
rerender(
<Match3DRuntimeShell
run={currentRun}
generatedItemAssets={generatedItemAssets}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
@@ -102,6 +107,7 @@ function renderRuntime(run: Match3DRunSnapshot) {
const { rerender } = render(
<Match3DRuntimeShell
run={currentRun}
generatedItemAssets={generatedItemAssets}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
@@ -122,7 +128,7 @@ test('展示圆形空间和 7 格备选栏', () => {
});
test('显示层把可消除物整体半径放大 2 倍且保留相对比例', () => {
const run = startLocalMatch3DRun(25);
const run = startLocalMatch3DRun(21);
const firstItemByType = [...new Map(
run.items.map((item) => [item.itemTypeId, item]),
).values()];
@@ -159,12 +165,7 @@ test('点击可见物品后先乐观入槽再等待确认', async () => {
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
});
test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋盘上下文', () => {
(
globalThis as typeof globalThis & {
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
}
).__MATCH3D_KEEP_3D_TEST_RENDER__ = true;
test('运行态按物品实例稳定使用生成 2D 视角素材', () => {
const run = startLocalMatch3DRun(1);
const selectedItem = run.items[0]!;
const nextRun: Match3DRunSnapshot = {
@@ -187,13 +188,31 @@ test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋
itemTypeId: selectedItem.itemTypeId,
visualKey: selectedItem.visualKey,
}
: slot,
: slot,
),
};
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
viewId: `view-${viewIndex}`,
viewIndex,
imageSrc: `/match3d/strawberry-view-${viewIndex}.png`,
imageObjectKey: null,
})),
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
},
];
renderRuntime(nextRun);
renderRuntime(nextRun, generatedItemAssets);
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
const trayImage = screen.getByTestId('match3d-tray-image') as HTMLImageElement;
expect(trayImage.src).toContain('/match3d/strawberry-view-');
});
test('3D WebGL 画布锁定 CSS 尺寸,避免高 DPR 手机上溢出中心棋盘', () => {
@@ -283,19 +302,81 @@ test('生成模型按运行态类型编号映射,并兼容仅 modelObjectKey
);
});
test('本地试玩按消除次数生成类型并在 25 类封顶', () => {
test('运行态会先换签 generated 图片素材再渲染局内物品', async () => {
const run = startLocalMatch3DRun(3);
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
viewId: `view-${viewIndex}`,
viewIndex,
imageSrc: null,
imageObjectKey:
`generated-match3d-assets/session/profile/items/match3d-item-1/views/view-${viewIndex}.png`,
})),
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
];
vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
read: {
signedUrl: 'https://oss.example.com/match3d-view.png',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
),
);
render(
<Match3DRuntimeShell
run={run}
generatedItemAssets={generatedItemAssets}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={vi.fn()}
onClickItem={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getAllByTestId('match3d-token-image').length).toBeGreaterThan(0);
});
expect(screen.getAllByTestId('match3d-token-image')[0]!.getAttribute('src')).toBe(
'https://oss.example.com/match3d-view.png',
);
});
test('本地试玩按难度档位生成类型并兼容历史硬核消除数', () => {
const smallRun = startLocalMatch3DRun(12);
const largeRun = startLocalMatch3DRun(100);
const hardRun = startLocalMatch3DRun(20);
const countTypes = (run: Match3DRunSnapshot) =>
new Set(run.items.map((item) => item.itemTypeId)).size;
expect(countTypes(smallRun)).toBe(12);
expect(countTypes(largeRun)).toBe(25);
expect(largeRun.items).toHaveLength(300);
expect(resolveLocalMatch3DItemTypeCount(8)).toBe(3);
expect(resolveLocalMatch3DItemTypeCount(12)).toBe(9);
expect(resolveLocalMatch3DItemTypeCount(16)).toBe(15);
expect(resolveLocalMatch3DItemTypeCount(20)).toBe(21);
expect(resolveLocalMatch3DItemTypeCount(21)).toBe(21);
expect(countTypes(smallRun)).toBe(9);
expect(countTypes(hardRun)).toBe(21);
expect(hardRun.clearCount).toBe(21);
expect(hardRun.items).toHaveLength(63);
});
test('25 次以内生成不重复积木视觉签名', () => {
const run = startLocalMatch3DRun(25);
test('硬核档位生成不重复积木视觉签名', () => {
const run = startLocalMatch3DRun(21);
const firstItemByType = new Map(
run.items.map((item) => [item.itemTypeId, item]),
);
@@ -311,14 +392,14 @@ test('25 次以内生成不重复积木视觉签名', () => {
),
);
expect(firstItemByType.size).toBe(25);
expect(visualKeys.size).toBe(25);
expect(signatures.size).toBe(25);
expect(firstItemByType.size).toBe(21);
expect(visualKeys.size).toBe(21);
expect(signatures.size).toBe(21);
});
test('积木池覆盖参考图里的特殊件', () => {
const shapes = new Set(
startLocalMatch3DRun(25).items.map((item) =>
startLocalMatch3DRun(21).items.map((item) =>
resolveGeometryAsset(item.visualKey).shape,
),
);
@@ -342,8 +423,8 @@ test('3D 特殊积木件使用可辨认挤出轮廓而不是基础代理体', as
}
});
test('15 次消除时每种视觉模型只对应一次消除目标', () => {
const run = startLocalMatch3DRun(15);
test('进阶档位保持 15 种视觉模型并按三消组复用', () => {
const run = startLocalMatch3DRun(16);
const countByVisualKey = new Map<string, number>();
const typeByVisualKey = new Map<string, Set<string>>();
@@ -357,23 +438,26 @@ test('15 次消除时每种视觉模型只对应一次消除目标', () => {
}
expect(countByVisualKey.size).toBe(15);
expect([...countByVisualKey.values()]).toEqual(Array(15).fill(3));
expect([...countByVisualKey.values()].sort((left, right) => left - right)).toEqual([
...Array(14).fill(3),
6,
]);
expect(
[...typeByVisualKey.values()].every((itemTypeIds) => itemTypeIds.size === 1),
).toBe(true);
});
test('25 次以内的随机抽取不会刷新重复物品', () => {
for (const clearCount of [1, 12, 15, 24, 25]) {
test('随机抽取不会刷新重复物品', () => {
for (const clearCount of [1, 8, 12, 16, 21]) {
const run = startLocalMatch3DRun(clearCount);
const visualKeys = new Set(run.items.map((item) => item.visualKey));
expect(visualKeys.size).toBe(clearCount);
expect(visualKeys.size).toBe(resolveLocalMatch3DItemTypeCount(clearCount));
}
});
test('25 类型局面按五档体积比例生成尺寸', () => {
const run = startLocalMatch3DRun(25);
test('硬核档位按五档体积比例生成尺寸', () => {
const run = startLocalMatch3DRun(21);
const radiusByVisualKey = new Map<string, number>();
for (const item of run.items) {
radiusByVisualKey.set(item.visualKey, item.radius);
@@ -400,15 +484,15 @@ test('25 类型局面按五档体积比例生成尺寸', () => {
tierCounts.set(tier, (tierCounts.get(tier) ?? 0) + 1);
}
expect(tierCounts.get('XL')).toBe(5);
expect(tierCounts.get('L')).toBe(8);
expect(tierCounts.get('M')).toBe(7);
expect(tierCounts.get('XS')).toBe(4);
expect(tierCounts.get('XL')).toBe(4);
expect(tierCounts.get('L')).toBe(7);
expect(tierCounts.get('M')).toBe(6);
expect(tierCounts.get('XS')).toBe(3);
expect(tierCounts.get('S')).toBe(1);
});
test('同一视觉模型在复用时保持唯一尺寸', () => {
const run = startLocalMatch3DRun(30);
const run = startLocalMatch3DRun(21);
const radiiByVisualKey = new Map<string, Set<number>>();
for (const item of run.items) {
@@ -417,13 +501,13 @@ test('同一视觉模型在复用时保持唯一尺寸', () => {
radiiByVisualKey.set(item.visualKey, radii);
}
expect(radiiByVisualKey.size).toBe(25);
expect(radiiByVisualKey.size).toBe(21);
expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true);
});
test('托盘 3D 预览保留场内模型的相对尺寸比例', async () => {
const three = await import('three');
const run = startLocalMatch3DRun(25);
const run = startLocalMatch3DRun(21);
const firstItemByType = [...new Map(
run.items.map((item) => [item.itemTypeId, item]),
).values()];

View File

@@ -24,9 +24,20 @@ import type {
} from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import {
Match3DPhysicsBoard,
Match3DTrayPreviewBoard,
} from './Match3DPhysicsBoard';
isGeneratedLegacyPath,
resolveAssetReadUrl,
} from '../../services/assetReadUrlService';
import {
getMatch3DGeneratedImageViewSources,
} from '../../services/match3dGeneratedModelCache';
import {
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
playRuntimeClickSound,
playRuntimeCountdownSound,
playRuntimeLevelClearSound,
resolveRuntimeCountdownSecondBucket,
} from '../../services/runtimeAudioFeedback';
import { useAuthUi } from '../auth/AuthUiContext';
import {
isItemState,
isRunState,
@@ -36,11 +47,11 @@ import {
Match3DVisualIcon,
resolveVisualSeed,
} from './match3dVisualAssets';
import { useAuthUi } from '../auth/AuthUiContext';
type Match3DRuntimeShellProps = {
run: Match3DRunSnapshot | null;
generatedItemAssets?: Match3DGeneratedItemAsset[];
backgroundImageSrc?: string | null;
isBusy?: boolean;
error?: string | null;
embedded?: boolean;
@@ -85,7 +96,6 @@ function resolveTrayPreviewItem(
};
}
const MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT = true;
const DEFAULT_MATCH3D_MUSIC_VOLUME = 0.42;
function formatTimer(value: number) {
@@ -133,6 +143,103 @@ function findHitItem(run: Match3DRunSnapshot, pointX: number, pointY: number) {
.sort((left, right) => right.layer - left.layer)[0];
}
function compareMatch3DGeneratedTypeId(left: string, right: string) {
const leftIndex = Number.parseInt(left.match(/(\d+)$/u)?.[1] ?? '', 10);
const rightIndex = Number.parseInt(right.match(/(\d+)$/u)?.[1] ?? '', 10);
if (Number.isFinite(leftIndex) && Number.isFinite(rightIndex)) {
return leftIndex - rightIndex;
}
return left.localeCompare(right);
}
function resolveMatch3DGeneratedTypeIds(run: Match3DRunSnapshot) {
return [...new Set(run.items.map((item) => item.itemTypeId.trim()))]
.filter(Boolean)
.sort(compareMatch3DGeneratedTypeId);
}
function buildMatch3DImageSourcesByType(
run: Match3DRunSnapshot | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
if (!run) {
return new Map<string, string[]>();
}
const typeIds = resolveMatch3DGeneratedTypeIds(run);
const readyAssets = generatedItemAssets
.map((asset) => getMatch3DGeneratedImageViewSources(asset))
.filter((sources) => sources.length > 0);
return new Map(
typeIds.flatMap((typeId, index) => {
const sources = readyAssets[index];
return sources ? [[typeId, sources] as const] : [];
}),
);
}
function buildMatch3DImageSourceSignature(
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
) {
return [...imageSourcesByType.entries()]
.map(([typeId, sources]) => `${typeId}:${sources.join(',')}`)
.join('|');
}
function resolveMatch3DImageReadUrlCacheKey(
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
) {
return [
...new Set(
[...imageSourcesByType.values()]
.flatMap((sources) => sources)
.map((source) => source.trim())
.filter(Boolean),
),
]
.sort()
.join('|');
}
function buildResolvedMatch3DImageSourcesByType(
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
resolvedImageSources: ReadonlyMap<string, string>,
) {
return new Map(
[...imageSourcesByType.entries()].map(([typeId, sources]) => [
typeId,
sources
.map((source) => {
const resolvedSource = resolvedImageSources.get(source);
if (resolvedSource) {
return resolvedSource;
}
return isGeneratedLegacyPath(source) ? '' : source;
})
.filter(Boolean),
]),
);
}
function hashMatch3DString(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash;
}
function resolveMatch3DImageForItem(
item: Match3DItemSnapshot,
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
) {
const sources = imageSourcesByType.get(item.itemTypeId);
if (!sources || sources.length <= 0) {
return '';
}
return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? '';
}
function buildOptimisticRun(
run: Match3DRunSnapshot,
item: Match3DItemSnapshot,
@@ -167,10 +274,12 @@ function buildOptimisticRun(
function Match3DToken({
item,
imageSrc,
disabled,
onClick,
}: {
item: Match3DItemSnapshot;
imageSrc?: string;
disabled: boolean;
onClick: (item: Match3DItemSnapshot) => void;
}) {
@@ -208,17 +317,28 @@ function Match3DToken({
}
onClick={() => onClick(item)}
>
<Match3DVisualIcon visualKey={item.visualKey} className="relative z-10" />
{imageSrc ? (
<img
src={imageSrc}
alt=""
aria-hidden="true"
data-testid="match3d-token-image"
className="relative z-10 h-full w-full object-contain drop-shadow-[0_10px_14px_rgba(15,23,42,0.34)]"
draggable={false}
/>
) : (
<Match3DVisualIcon visualKey={item.visualKey} className="relative z-10" />
)}
</button>
);
}
function Match3DTrayToken({
slot,
use3DPreview,
imageSrc,
}: {
slot: Match3DTraySlot;
use3DPreview: boolean;
imageSrc?: string;
}) {
if (!slot.visualKey) {
return (
@@ -226,15 +346,23 @@ function Match3DTrayToken({
);
}
const visualSeed = resolveVisualSeed(slot.visualKey);
const fallback = <Match3DVisualIcon visualKey={slot.visualKey} />;
return (
<span
className="flex h-full w-full items-center justify-center p-1"
aria-label={visualSeed.label}
>
<span className={use3DPreview ? 'opacity-0' : 'opacity-100'}>
{fallback}
</span>
{imageSrc ? (
<img
src={imageSrc}
alt=""
aria-hidden="true"
data-testid="match3d-tray-image"
className="h-full w-full object-contain drop-shadow-[0_5px_8px_rgba(15,23,42,0.26)]"
draggable={false}
/>
) : (
<Match3DVisualIcon visualKey={slot.visualKey} />
)}
</span>
);
}
@@ -305,6 +433,7 @@ function Match3DSettlement({
export function Match3DRuntimeShell({
run,
generatedItemAssets = [],
backgroundImageSrc = null,
isBusy = false,
error = null,
embedded = false,
@@ -317,22 +446,16 @@ export function Match3DRuntimeShell({
const authUi = useAuthUi();
const stageRef = useRef<HTMLDivElement | null>(null);
const backgroundAudioRef = useRef<HTMLAudioElement | null>(null);
const clickAudioRefs = useRef<Record<string, HTMLAudioElement>>({});
const clearSoundKeyRef = useRef<string | null>(null);
const countdownSoundKeyRef = useRef<string | null>(null);
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
const [feedbackEvent, setFeedbackEvent] =
useState<Match3DFeedbackEvent | null>(null);
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
const [force2DRender, setForce2DRender] = useState(() => {
if (typeof window === 'undefined') {
return true;
}
const params = new URLSearchParams(window.location.search);
return (
params.get('match3dRender') === '2d' ||
params.get('match3d3d') === 'off' ||
!MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT
);
});
const [resolvedBackgroundImageSrc, setResolvedBackgroundImageSrc] =
useState('');
const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
useEffect(() => {
setTimeLeftMs(run?.remainingMs ?? 0);
@@ -362,18 +485,87 @@ export function Match3DRuntimeShell({
return () => window.clearTimeout(timer);
}, [feedbackEvent]);
const progressText = useMemo(() => {
useEffect(() => {
if (!run) {
return '0/0';
clearSoundKeyRef.current = null;
return;
}
if (!isRunState(run.status, 'won')) {
return;
}
return `${run.clearedItemCount}/${run.totalItemCount}`;
}, [run]);
const shouldUse3DRender = !force2DRender;
const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME;
const soundKey = `${run.runId}:${run.snapshotVersion}:won`;
if (clearSoundKeyRef.current === soundKey) {
return;
}
clearSoundKeyRef.current = soundKey;
playRuntimeLevelClearSound(musicVolume);
}, [musicVolume, run, run?.runId, run?.snapshotVersion, run?.status]);
useEffect(() => {
if (!run || !isRunState(run.status, 'running')) {
countdownSoundKeyRef.current = null;
return;
}
const secondBucket =
timeLeftMs <= levelAudioConfig.countdownWarningThresholdMs
? resolveRuntimeCountdownSecondBucket(timeLeftMs)
: null;
if (secondBucket === null) {
countdownSoundKeyRef.current = null;
return;
}
const soundKey = `${run.runId}:${run.startedAtMs}:${secondBucket}`;
if (countdownSoundKeyRef.current === soundKey) {
return;
}
countdownSoundKeyRef.current = soundKey;
playRuntimeCountdownSound(musicVolume);
}, [
levelAudioConfig.countdownWarningThresholdMs,
musicVolume,
run,
run?.runId,
run?.startedAtMs,
run?.status,
timeLeftMs,
]);
const backgroundAssetSrc =
backgroundImageSrc?.trim() ||
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
'';
const imageSourcesByType = useMemo(
() => buildMatch3DImageSourcesByType(run, generatedItemAssets),
[generatedItemAssets, run],
);
const imageSourceSignature = useMemo(
() => buildMatch3DImageSourceSignature(imageSourcesByType),
[imageSourcesByType],
);
const [resolvedImageSources, setResolvedImageSources] = useState<
Map<string, string>
>(() => new Map());
const resolvedImageSourcesByType = useMemo(
() =>
buildResolvedMatch3DImageSourcesByType(
imageSourcesByType,
resolvedImageSources,
),
[imageSourcesByType, resolvedImageSources],
);
const backgroundMusicSrc =
generatedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
?.backgroundMusic?.audioSrc ?? null;
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] = useState('');
const clickSoundByTypeId = useMemo(() => {
if (!run) {
return new Map<string, string>();
@@ -394,7 +586,7 @@ export function Match3DRuntimeShell({
useEffect(() => {
const audio = backgroundAudioRef.current;
if (!audio || !backgroundMusicSrc || !run || !isRunState(run.status, 'running')) {
if (!audio || !resolvedBackgroundMusicSrc || !run || !isRunState(run.status, 'running')) {
if (audio) {
audio.pause();
}
@@ -402,31 +594,128 @@ export function Match3DRuntimeShell({
}
audio.volume = Math.max(0, Math.min(1, musicVolume));
void audio.play().catch(() => {});
}, [backgroundMusicSrc, musicVolume, run]);
}, [musicVolume, resolvedBackgroundMusicSrc, run]);
useEffect(() => {
Object.values(clickAudioRefs.current).forEach((audio) => {
audio.volume = Math.max(0, Math.min(1, musicVolume));
});
}, [musicVolume]);
const source = backgroundMusicSrc?.trim() ?? '';
if (!source) {
setResolvedBackgroundMusicSrc('');
return undefined;
}
if (!isGeneratedLegacyPath(source)) {
setResolvedBackgroundMusicSrc(source);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setResolvedBackgroundMusicSrc('');
void resolveAssetReadUrl(source, {
signal: controller.signal,
expireSeconds: 300,
})
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedBackgroundMusicSrc(resolvedSrc);
}
})
.catch(() => {
if (!cancelled) {
setResolvedBackgroundMusicSrc('');
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [backgroundMusicSrc]);
const playClickSound = useCallback(
(item: Match3DItemSnapshot) => {
const src = clickSoundByTypeId.get(item.itemTypeId);
if (!src) {
return;
}
const current = clickAudioRefs.current[src] ?? new Audio(src);
clickAudioRefs.current[src] = current;
current.currentTime = 0;
current.volume = Math.max(0, Math.min(1, musicVolume));
void current.play().catch(() => {});
playRuntimeClickSound(src, musicVolume);
},
[clickSoundByTypeId, musicVolume],
);
const handleTrayPreviewFallback = useCallback(() => {
setForce2DRender(true);
}, []);
useEffect(() => {
if (!backgroundAssetSrc) {
setResolvedBackgroundImageSrc('');
return undefined;
}
let cancelled = false;
const controller = new AbortController();
void resolveAssetReadUrl(backgroundAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
})
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedBackgroundImageSrc(resolvedSrc);
}
})
.catch(() => {
if (!cancelled) {
setResolvedBackgroundImageSrc('');
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [backgroundAssetSrc]);
useEffect(() => {
const rawSources = [
...new Set(
[...imageSourcesByType.values()]
.flatMap((sources) => sources)
.map((source) => source.trim())
.filter(Boolean),
),
];
if (rawSources.length <= 0) {
setResolvedImageSources(new Map());
return undefined;
}
let cancelled = false;
const controller = new AbortController();
const nextSources = new Map<string, string>();
setResolvedImageSources(() => new Map());
void Promise.all(
rawSources.map(async (source) => {
if (!isGeneratedLegacyPath(source)) {
nextSources.set(source, source);
return;
}
const resolvedSource = await resolveAssetReadUrl(source, {
signal: controller.signal,
expireSeconds: 300,
});
nextSources.set(source, resolvedSource || source);
}),
)
.then(() => {
if (!cancelled) {
setResolvedImageSources(nextSources);
}
})
.catch(() => {
if (!cancelled) {
setResolvedImageSources(new Map());
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [imageSourceSignature, imageSourcesByType]);
const trayPreviewItems = useMemo(() => {
if (!run) {
return [];
@@ -507,10 +796,18 @@ export function Match3DRuntimeShell({
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#16221f] text-white`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
{backgroundMusicSrc ? (
{resolvedBackgroundImageSrc ? (
<img
src={resolvedBackgroundImageSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : null}
{resolvedBackgroundMusicSrc ? (
<audio
ref={backgroundAudioRef}
src={backgroundMusicSrc}
src={resolvedBackgroundMusicSrc}
loop
preload="auto"
/>
@@ -546,24 +843,10 @@ export function Match3DRuntimeShell({
</button>
</header>
<section className="mt-3 grid w-full min-w-0 grid-cols-3 gap-2 overflow-hidden text-center text-[0.72rem] font-black">
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
{progressText}
</div>
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
{run.clearCount}
</div>
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
v{run.snapshotVersion}
</div>
</section>
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
<div
ref={stageRef}
className={`relative aspect-square max-w-full rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)] ${
shouldUse3DRender ? 'overflow-visible' : 'overflow-hidden'
}`}
className="relative aspect-square max-w-full overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
style={{
width: 'min(92vw, 58dvh, 100%)',
}}
@@ -571,26 +854,18 @@ export function Match3DRuntimeShell({
data-testid="match3d-board"
>
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
{shouldUse3DRender ? (
<Match3DPhysicsBoard
run={run}
{run.items.map((item) => (
<Match3DToken
key={item.itemInstanceId}
item={item}
imageSrc={resolveMatch3DImageForItem(
item,
resolvedImageSourcesByType,
)}
disabled={Boolean(pendingClick)}
generatedItemAssets={generatedItemAssets}
onClickItem={(item) => {
void handleItemClick(item);
}}
onFallback={() => setForce2DRender(true)}
onClick={handleItemClick}
/>
) : (
run.items.map((item) => (
<Match3DToken
key={item.itemInstanceId}
item={item}
disabled={Boolean(pendingClick)}
onClick={handleItemClick}
/>
))
)}
))}
{feedbackEvent?.kind === 'cleared' ? (
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/24 text-amber-100 shadow-[0_0_42px_rgba(255,255,255,0.72)] backdrop-blur-sm">
@@ -606,15 +881,14 @@ export function Match3DRuntimeShell({
className="relative grid grid-cols-7 gap-1.5"
data-testid="match3d-tray"
>
{shouldUse3DRender ? (
<Match3DTrayPreviewBoard
onFallback={handleTrayPreviewFallback}
referenceItems={run.items}
slotItems={trayPreviewItems}
generatedItemAssets={generatedItemAssets}
/>
) : null}
{run.traySlots.map((slot) => {
const trayItem =
trayPreviewItems[slot.slotIndex] ??
(slot.itemInstanceId
? run.items.find(
(item) => item.itemInstanceId === slot.itemInstanceId,
)
: null);
return (
<div
key={slot.slotIndex}
@@ -623,7 +897,14 @@ export function Match3DRuntimeShell({
>
<Match3DTrayToken
slot={slot}
use3DPreview={shouldUse3DRender}
imageSrc={
trayItem
? resolveMatch3DImageForItem(
trayItem,
resolvedImageSourcesByType,
)
: ''
}
/>
</div>
);