1
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user