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

@@ -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()];