Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
CI / verify (push) Has been cancelled
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -12,7 +12,13 @@ import {
|
||||
} from './services/match3d-runtime';
|
||||
|
||||
function buildInitialRun() {
|
||||
return startLocalMatch3DRun(12);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const clearCountParam = params.get('clearCount') ?? params.get('count');
|
||||
const clearCount =
|
||||
clearCountParam === null ? 12 : Number.parseInt(clearCountParam, 10);
|
||||
return startLocalMatch3DRun(
|
||||
Number.isFinite(clearCount) && clearCount > 0 ? clearCount : 12,
|
||||
);
|
||||
}
|
||||
|
||||
export default function Match3DPlaygroundApp() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useEffect } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
@@ -12,16 +12,42 @@ import {
|
||||
confirmLocalMatch3DClick,
|
||||
startLocalMatch3DRun,
|
||||
} from '../../services/match3d-runtime';
|
||||
import {
|
||||
MATCH3D_EXTRUDED_READABLE_SHAPES,
|
||||
createMatch3DThreeGeometry,
|
||||
} from './Match3DPhysicsBoard';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
|
||||
vi.mock('./Match3DPhysicsBoard', () => ({
|
||||
Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => {
|
||||
useEffect(() => {
|
||||
onFallback();
|
||||
}, [onFallback]);
|
||||
return <div data-testid="match3d-physics-board-fallback" />;
|
||||
},
|
||||
}));
|
||||
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
||||
return {
|
||||
...actual,
|
||||
Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => {
|
||||
useEffect(() => {
|
||||
const shouldKeep3D =
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__ === true;
|
||||
if (!shouldKeep3D) {
|
||||
onFallback();
|
||||
}
|
||||
}, [onFallback]);
|
||||
return <div data-testid="match3d-physics-board-fallback" />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (
|
||||
globalThis as typeof globalThis & {
|
||||
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__;
|
||||
});
|
||||
|
||||
function renderRuntime(run: Match3DRunSnapshot) {
|
||||
let currentRun = run;
|
||||
@@ -79,13 +105,204 @@ test('点击可见物品后先乐观入槽再等待确认', async () => {
|
||||
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('后端形状视觉键不会被统一兜底成红色苹字', () => {
|
||||
test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋盘上下文', () => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__ = true;
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const selectedItem = run.items[0]!;
|
||||
const nextRun: Match3DRunSnapshot = {
|
||||
...run,
|
||||
items: run.items.map((item, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...item,
|
||||
state: 'InTray' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: 0,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === 0
|
||||
? {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: selectedItem.itemInstanceId,
|
||||
itemTypeId: selectedItem.itemTypeId,
|
||||
visualKey: selectedItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
|
||||
renderRuntime(nextRun);
|
||||
|
||||
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('本地试玩按消除次数生成类型并在 25 类封顶', () => {
|
||||
const smallRun = startLocalMatch3DRun(12);
|
||||
const largeRun = startLocalMatch3DRun(100);
|
||||
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);
|
||||
});
|
||||
|
||||
test('25 次以内生成不重复积木视觉签名', () => {
|
||||
const run = startLocalMatch3DRun(25);
|
||||
const firstItemByType = new Map(
|
||||
run.items.map((item) => [item.itemTypeId, item]),
|
||||
);
|
||||
const visualKeys = new Set(
|
||||
[...firstItemByType.values()].map((item) => item.visualKey),
|
||||
);
|
||||
const signatures = new Set(
|
||||
[...firstItemByType.values()].map(
|
||||
(item) => {
|
||||
const asset = resolveGeometryAsset(item.visualKey);
|
||||
return `${asset.shape}-${asset.fill}-${asset.studsX}x${asset.studsY}-${asset.heightScale}`;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(firstItemByType.size).toBe(25);
|
||||
expect(visualKeys.size).toBe(25);
|
||||
expect(signatures.size).toBe(25);
|
||||
});
|
||||
|
||||
test('积木池覆盖参考图里的特殊件', () => {
|
||||
const shapes = new Set(
|
||||
startLocalMatch3DRun(25).items.map((item) =>
|
||||
resolveGeometryAsset(item.visualKey).shape,
|
||||
),
|
||||
);
|
||||
|
||||
expect(shapes).toContain('brick');
|
||||
expect(shapes).toContain('tile');
|
||||
expect(shapes).toContain('slope');
|
||||
expect(shapes).toContain('cylinder');
|
||||
expect(shapes).toContain('ring');
|
||||
expect(shapes).toContain('arch');
|
||||
expect(shapes).toContain('cone');
|
||||
});
|
||||
|
||||
test('3D 特殊积木件使用可辨认挤出轮廓而不是基础代理体', async () => {
|
||||
const three = await import('three');
|
||||
|
||||
for (const shape of MATCH3D_EXTRUDED_READABLE_SHAPES) {
|
||||
const geometry = createMatch3DThreeGeometry(three, shape, 1);
|
||||
|
||||
expect(geometry.type).toBe('ExtrudeGeometry');
|
||||
}
|
||||
});
|
||||
|
||||
test('15 次消除时每种视觉模型只对应一次消除目标', () => {
|
||||
const run = startLocalMatch3DRun(15);
|
||||
const countByVisualKey = new Map<string, number>();
|
||||
const typeByVisualKey = new Map<string, Set<string>>();
|
||||
|
||||
for (const item of run.items) {
|
||||
countByVisualKey.set(
|
||||
item.visualKey,
|
||||
(countByVisualKey.get(item.visualKey) ?? 0) + 1,
|
||||
);
|
||||
typeByVisualKey.set(item.visualKey, typeByVisualKey.get(item.visualKey) ?? new Set());
|
||||
typeByVisualKey.get(item.visualKey)!.add(item.itemTypeId);
|
||||
}
|
||||
|
||||
expect(countByVisualKey.size).toBe(15);
|
||||
expect([...countByVisualKey.values()]).toEqual(Array(15).fill(3));
|
||||
expect(
|
||||
[...typeByVisualKey.values()].every((itemTypeIds) => itemTypeIds.size === 1),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('25 次以内的随机抽取不会刷新重复物品', () => {
|
||||
for (const clearCount of [1, 12, 15, 24, 25]) {
|
||||
const run = startLocalMatch3DRun(clearCount);
|
||||
const visualKeys = new Set(run.items.map((item) => item.visualKey));
|
||||
|
||||
expect(visualKeys.size).toBe(clearCount);
|
||||
}
|
||||
});
|
||||
|
||||
test('25 类型局面按五档体积比例生成尺寸', () => {
|
||||
const run = startLocalMatch3DRun(25);
|
||||
const radiusByVisualKey = new Map<string, number>();
|
||||
for (const item of run.items) {
|
||||
radiusByVisualKey.set(item.visualKey, item.radius);
|
||||
}
|
||||
|
||||
const baseRadius = [...radiusByVisualKey.values()].find(
|
||||
(radius) => Math.abs(radius / 0.072 - 1) < 0.01,
|
||||
);
|
||||
expect(baseRadius).toBeTruthy();
|
||||
|
||||
const tierCounts = new Map<string, number>();
|
||||
for (const radius of radiusByVisualKey.values()) {
|
||||
const relativeVolume = Math.pow(radius / baseRadius!, 3);
|
||||
const tier =
|
||||
relativeVolume >= 1.6
|
||||
? 'XL'
|
||||
: relativeVolume >= 1.25
|
||||
? 'L'
|
||||
: relativeVolume >= 0.65 && relativeVolume <= 0.85
|
||||
? 'XS'
|
||||
: relativeVolume <= 0.5
|
||||
? 'S'
|
||||
: 'M';
|
||||
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('S')).toBe(1);
|
||||
});
|
||||
|
||||
test('同一视觉模型在复用时保持唯一尺寸', () => {
|
||||
const run = startLocalMatch3DRun(30);
|
||||
const radiiByVisualKey = new Map<string, Set<number>>();
|
||||
|
||||
for (const item of run.items) {
|
||||
const radii = radiiByVisualKey.get(item.visualKey) ?? new Set<number>();
|
||||
radii.add(Math.round(item.radius * 10_000));
|
||||
radiiByVisualKey.set(item.visualKey, radii);
|
||||
}
|
||||
|
||||
expect(radiiByVisualKey.size).toBe(25);
|
||||
expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('积木 3D 资源可以为本局类型创建几何体', async () => {
|
||||
const three = await import('three');
|
||||
const run = startLocalMatch3DRun(15);
|
||||
const firstItemByType = new Map(
|
||||
run.items.map((item) => [item.itemTypeId, item]),
|
||||
);
|
||||
|
||||
expect(firstItemByType.size).toBe(15);
|
||||
for (const item of firstItemByType.values()) {
|
||||
const shape = resolveGeometryAsset(item.visualKey).shape;
|
||||
const geometry = createMatch3DThreeGeometry(three, shape, 1);
|
||||
|
||||
expect(geometry).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('积木视觉键不会被统一兜底成红色苹字', () => {
|
||||
const run = startLocalMatch3DRun(2);
|
||||
run.items = run.items.slice(0, 2).map((item, index) => ({
|
||||
...item,
|
||||
itemInstanceId: `shape-${index}`,
|
||||
itemTypeId: `shape-type-${index}`,
|
||||
visualKey: index === 0 ? 'red_circle' : 'yellow_triangle',
|
||||
itemInstanceId: `block-${index}`,
|
||||
itemTypeId: `block-type-${index}`,
|
||||
visualKey: index === 0 ? 'block-red-2x4' : 'block-blue-1x2',
|
||||
x: 0.42 + index * 0.16,
|
||||
y: 0.5,
|
||||
layer: index,
|
||||
@@ -93,23 +310,23 @@ test('后端形状视觉键不会被统一兜底成红色苹字', () => {
|
||||
}));
|
||||
renderRuntime(run);
|
||||
|
||||
expect(screen.getByTestId('match3d-visual-red_circle')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-yellow_triangle')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-block-blue-1x2')).toBeTruthy();
|
||||
expect(screen.queryAllByText('苹')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('水果题材视觉键也渲染为无文字纯色几何体', () => {
|
||||
test('积木视觉键渲染为无文字纯色图标', () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
run.items = run.items.slice(0, 3).map((item, index) => ({
|
||||
...item,
|
||||
itemInstanceId: `fruit-${index}`,
|
||||
itemTypeId: `fruit-type-${index}`,
|
||||
itemInstanceId: `block-icon-${index}`,
|
||||
itemTypeId: `block-icon-type-${index}`,
|
||||
visualKey:
|
||||
index === 0
|
||||
? 'watermelon-green'
|
||||
? 'block-red-2x4'
|
||||
: index === 1
|
||||
? 'apple-red'
|
||||
: 'grape-purple',
|
||||
? 'block-clear-ring'
|
||||
: 'block-mint-arch',
|
||||
x: 0.35 + index * 0.15,
|
||||
y: 0.5,
|
||||
radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07,
|
||||
@@ -118,31 +335,31 @@ test('水果题材视觉键也渲染为无文字纯色几何体', () => {
|
||||
}));
|
||||
renderRuntime(run);
|
||||
|
||||
expect(screen.getByTestId('match3d-visual-watermelon-green')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByTestId('match3d-visual-apple-red').getAttribute('data-shape'),
|
||||
).toBe('heart');
|
||||
screen.getByTestId('match3d-visual-block-clear-ring').getAttribute('data-shape'),
|
||||
).toBe('ring');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-visual-grape-purple')
|
||||
.getByTestId('match3d-visual-block-mint-arch')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('star');
|
||||
).toBe('arch');
|
||||
expect(screen.queryByText('苹果')).toBeNull();
|
||||
expect(screen.queryByText('苹')).toBeNull();
|
||||
});
|
||||
|
||||
test('运行态支持梯形和平行四边形等差异化几何造型', () => {
|
||||
test('运行态支持长条、斜坡和圆柱等差异化积木造型', () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
run.items = run.items.slice(0, 3).map((item, index) => ({
|
||||
...item,
|
||||
itemInstanceId: `geometry-${index}`,
|
||||
itemTypeId: `geometry-type-${index}`,
|
||||
itemInstanceId: `block-geometry-${index}`,
|
||||
itemTypeId: `block-geometry-type-${index}`,
|
||||
visualKey:
|
||||
index === 0
|
||||
? 'peach-pink'
|
||||
? 'block-black-1x8'
|
||||
: index === 1
|
||||
? 'banana-yellow'
|
||||
: 'orange_hexagon',
|
||||
? 'block-purple-slope-1x2'
|
||||
: 'block-green-cylinder',
|
||||
x: 0.35 + index * 0.15,
|
||||
y: 0.5,
|
||||
layer: index,
|
||||
@@ -151,18 +368,18 @@ test('运行态支持梯形和平行四边形等差异化几何造型', () => {
|
||||
renderRuntime(run);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('match3d-visual-peach-pink').getAttribute('data-shape'),
|
||||
).toBe('trapezoid');
|
||||
screen.getByTestId('match3d-visual-block-black-1x8').getAttribute('data-shape'),
|
||||
).toBe('brick');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-visual-banana-yellow')
|
||||
.getByTestId('match3d-visual-block-purple-slope-1x2')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('parallelogram');
|
||||
).toBe('slope');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-visual-orange_hexagon')
|
||||
.getByTestId('match3d-visual-block-green-cylinder')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('hexagon');
|
||||
).toBe('cylinder');
|
||||
});
|
||||
|
||||
test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => {
|
||||
@@ -172,7 +389,7 @@ test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', ()
|
||||
{
|
||||
...item,
|
||||
itemInstanceId: 'legacy-outside',
|
||||
visualKey: 'apple-red',
|
||||
visualKey: 'block-red-2x4',
|
||||
x: -0.4,
|
||||
y: 0.5,
|
||||
radius: 0.1,
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
Match3DVisualIcon,
|
||||
resolveVisualSeed,
|
||||
} from './match3dVisualAssets';
|
||||
import { Match3DPhysicsBoard } from './Match3DPhysicsBoard';
|
||||
import {
|
||||
Match3DPhysicsBoard,
|
||||
Match3DTrayPreviewBoard,
|
||||
} from './Match3DPhysicsBoard';
|
||||
import {
|
||||
isItemState,
|
||||
isRunState,
|
||||
@@ -178,19 +181,28 @@ function Match3DToken({
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) {
|
||||
function Match3DTrayToken({
|
||||
slot,
|
||||
use3DPreview,
|
||||
}: {
|
||||
slot: Match3DTraySlot;
|
||||
use3DPreview: boolean;
|
||||
}) {
|
||||
if (!slot.visualKey) {
|
||||
return (
|
||||
<span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />
|
||||
);
|
||||
}
|
||||
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}
|
||||
>
|
||||
<Match3DVisualIcon visualKey={slot.visualKey} />
|
||||
<span className={use3DPreview ? 'opacity-0' : 'opacity-100'}>
|
||||
{fallback}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -321,6 +333,18 @@ export function Match3DRuntimeShell({
|
||||
}, [run]);
|
||||
|
||||
const shouldUse3DRender = !force2DRender;
|
||||
const trayPreviewItems = useMemo(() => {
|
||||
if (!run) {
|
||||
return [];
|
||||
}
|
||||
return run.traySlots.map((slot) =>
|
||||
slot.itemInstanceId
|
||||
? (run.items.find(
|
||||
(item) => item.itemInstanceId === slot.itemInstanceId,
|
||||
) ?? null)
|
||||
: null,
|
||||
);
|
||||
}, [run]);
|
||||
|
||||
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
||||
if (!run || !isRunState(run.status, 'running') || pendingClick) {
|
||||
@@ -436,7 +460,9 @@ export function Match3DRuntimeShell({
|
||||
<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 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)]"
|
||||
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'
|
||||
}`}
|
||||
style={{
|
||||
width: 'min(92vw, 58dvh, 100%)',
|
||||
}}
|
||||
@@ -474,16 +500,27 @@ export function Match3DRuntimeShell({
|
||||
</section>
|
||||
|
||||
<section className="mt-3 w-full min-w-0 overflow-hidden rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
|
||||
<div className="grid grid-cols-7 gap-1.5" data-testid="match3d-tray">
|
||||
{run.traySlots.map((slot) => (
|
||||
<div
|
||||
key={slot.slotIndex}
|
||||
className="aspect-square min-w-0 rounded-xl bg-white/10 p-1"
|
||||
data-testid="match3d-tray-slot"
|
||||
>
|
||||
<Match3DTrayToken slot={slot} />
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="relative grid grid-cols-7 gap-1.5"
|
||||
data-testid="match3d-tray"
|
||||
>
|
||||
{shouldUse3DRender ? (
|
||||
<Match3DTrayPreviewBoard slotItems={trayPreviewItems} />
|
||||
) : null}
|
||||
{run.traySlots.map((slot) => {
|
||||
return (
|
||||
<div
|
||||
key={slot.slotIndex}
|
||||
className="relative z-0 aspect-square min-w-0 rounded-xl bg-white/10 p-1"
|
||||
data-testid="match3d-tray-slot"
|
||||
>
|
||||
<Match3DTrayToken
|
||||
slot={slot}
|
||||
use3DPreview={shouldUse3DRender}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,139 +2,63 @@ import { MATCH3D_VISUAL_SEEDS } from '../../services/match3d-runtime';
|
||||
|
||||
type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number];
|
||||
|
||||
export type Match3DGeometryShape =
|
||||
| 'circle'
|
||||
| 'triangle'
|
||||
| 'diamond'
|
||||
| 'square'
|
||||
| 'star'
|
||||
| 'hexagon'
|
||||
| 'capsule'
|
||||
| 'heart'
|
||||
| 'trapezoid'
|
||||
| 'parallelogram';
|
||||
export type Match3DBlockShape =
|
||||
| 'brick'
|
||||
| 'tile'
|
||||
| 'slope'
|
||||
| 'cylinder'
|
||||
| 'ring'
|
||||
| 'arch'
|
||||
| 'cone';
|
||||
|
||||
export type Match3DGeometryShape = Match3DBlockShape;
|
||||
|
||||
export type Match3DGeometryAsset = {
|
||||
shape: Match3DGeometryShape;
|
||||
shape: Match3DBlockShape;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
studsX: number;
|
||||
studsY: number;
|
||||
heightScale: number;
|
||||
transparent?: boolean;
|
||||
};
|
||||
|
||||
const MATCH3D_GEOMETRY_ASSETS: Record<string, Match3DGeometryAsset> = {
|
||||
'watermelon-green': {
|
||||
shape: 'circle',
|
||||
fill: '#16a34a',
|
||||
stroke: '#14532d',
|
||||
},
|
||||
'apple-red': {
|
||||
shape: 'heart',
|
||||
fill: '#ef4444',
|
||||
stroke: '#991b1b',
|
||||
},
|
||||
'banana-yellow': {
|
||||
shape: 'parallelogram',
|
||||
fill: '#facc15',
|
||||
stroke: '#a16207',
|
||||
},
|
||||
'grape-purple': {
|
||||
shape: 'star',
|
||||
fill: '#8b5cf6',
|
||||
stroke: '#5b21b6',
|
||||
},
|
||||
'melon-green': {
|
||||
shape: 'hexagon',
|
||||
fill: '#84cc16',
|
||||
stroke: '#3f6212',
|
||||
},
|
||||
'berry-blue': {
|
||||
shape: 'diamond',
|
||||
fill: '#2563eb',
|
||||
stroke: '#1e3a8a',
|
||||
},
|
||||
'peach-pink': {
|
||||
shape: 'trapezoid',
|
||||
fill: '#fb7185',
|
||||
stroke: '#be123c',
|
||||
},
|
||||
'plum-indigo': {
|
||||
shape: 'capsule',
|
||||
fill: '#4f46e5',
|
||||
stroke: '#312e81',
|
||||
},
|
||||
'lime-lime': {
|
||||
shape: 'square',
|
||||
fill: '#65a30d',
|
||||
stroke: '#365314',
|
||||
},
|
||||
'orange-orange': {
|
||||
shape: 'triangle',
|
||||
fill: '#f97316',
|
||||
stroke: '#9a3412',
|
||||
},
|
||||
'pear-cyan': {
|
||||
shape: 'parallelogram',
|
||||
fill: '#06b6d4',
|
||||
stroke: '#155e75',
|
||||
},
|
||||
red_circle: {
|
||||
shape: 'circle',
|
||||
fill: '#ef4444',
|
||||
stroke: '#991b1b',
|
||||
},
|
||||
yellow_triangle: {
|
||||
shape: 'triangle',
|
||||
fill: '#facc15',
|
||||
stroke: '#a16207',
|
||||
},
|
||||
purple_diamond: {
|
||||
shape: 'diamond',
|
||||
fill: '#7c3aed',
|
||||
stroke: '#4c1d95',
|
||||
},
|
||||
green_square: {
|
||||
shape: 'square',
|
||||
fill: '#16a34a',
|
||||
stroke: '#14532d',
|
||||
},
|
||||
blue_star: {
|
||||
shape: 'star',
|
||||
fill: '#0ea5e9',
|
||||
stroke: '#075985',
|
||||
},
|
||||
orange_hexagon: {
|
||||
shape: 'hexagon',
|
||||
fill: '#f97316',
|
||||
stroke: '#9a3412',
|
||||
},
|
||||
cyan_capsule: {
|
||||
shape: 'capsule',
|
||||
fill: '#06b6d4',
|
||||
stroke: '#155e75',
|
||||
},
|
||||
pink_heart: {
|
||||
shape: 'heart',
|
||||
fill: '#ec4899',
|
||||
stroke: '#9d174d',
|
||||
},
|
||||
lime_leaf: {
|
||||
shape: 'trapezoid',
|
||||
fill: '#84cc16',
|
||||
stroke: '#3f6212',
|
||||
},
|
||||
white_moon: {
|
||||
shape: 'parallelogram',
|
||||
fill: '#e2e8f0',
|
||||
stroke: '#64748b',
|
||||
'block-red-2x4': blockAsset('brick', '#e31818', '#8f1111', 4, 2, 0.72),
|
||||
'block-blue-1x2': blockAsset('brick', '#1478d4', '#0b4f91', 2, 1, 0.82),
|
||||
'block-yellow-2x2': blockAsset('brick', '#f7c51d', '#a66f00', 2, 2, 0.76),
|
||||
'block-green-1x4': blockAsset('brick', '#079447', '#055c2f', 4, 1, 0.72),
|
||||
'block-orange-1x6': blockAsset('brick', '#ff7a12', '#b84708', 6, 1, 0.64),
|
||||
'block-white-1x1': blockAsset('brick', '#f3f2ec', '#b7b8b2', 1, 1, 0.86),
|
||||
'block-black-1x8': blockAsset('brick', '#101214', '#030405', 8, 1, 0.54),
|
||||
'block-tan-2x3': blockAsset('brick', '#d8bd72', '#9b7a35', 3, 2, 0.68),
|
||||
'block-lime-1x2': blockAsset('brick', '#a5df18', '#6d990b', 2, 1, 0.58),
|
||||
'block-darkred-2x2': blockAsset('brick', '#b51217', '#76090d', 2, 2, 0.7),
|
||||
'block-blue-1x4': blockAsset('brick', '#1688df', '#0b5c9e', 4, 1, 0.58),
|
||||
'block-pink-2x4': blockAsset('brick', '#f66bb5', '#ba2e7e', 4, 2, 0.56),
|
||||
'block-gray-1x6': blockAsset('brick', '#4c5456', '#232829', 6, 1, 0.5),
|
||||
'block-lavender-tile-2x2': blockAsset('tile', '#c99fe6', '#8b63ad', 2, 2, 0.28),
|
||||
'block-teal-tile-1x3': blockAsset('tile', '#11adb0', '#087377', 3, 1, 0.26),
|
||||
'block-mint-tile-1x4': blockAsset('tile', '#a7c6ac', '#6e9275', 4, 1, 0.24),
|
||||
'block-magenta-tile-2x2': blockAsset('tile', '#cf0f68', '#8e0644', 2, 2, 0.28),
|
||||
'block-orange-tile-2x2-stud': blockAsset('tile', '#ff970f', '#b65b05', 2, 2, 0.3),
|
||||
'block-purple-slope-1x2': blockAsset('slope', '#5e42b6', '#342070', 2, 1, 0.82),
|
||||
'block-brown-slope-1x2': blockAsset('slope', '#8b421f', '#552414', 2, 1, 0.94),
|
||||
'block-sky-slope-2x2': blockAsset('slope', '#4db3f2', '#1f78b7', 2, 2, 0.9),
|
||||
'block-green-cylinder': blockAsset('cylinder', '#159554', '#076236', 1, 1, 1.08),
|
||||
'block-clear-ring': {
|
||||
...blockAsset('ring', '#d9e1df', '#aebbbb', 2, 2, 0.38),
|
||||
transparent: true,
|
||||
},
|
||||
'block-mint-arch': blockAsset('arch', '#c4ded2', '#83a996', 4, 1, 1.0),
|
||||
'block-gold-cone': blockAsset('cone', '#d39a10', '#8c6105', 1, 1, 1.18),
|
||||
};
|
||||
|
||||
const MATCH3D_UNKNOWN_GEOMETRY_ASSETS: Match3DGeometryAsset[] = [
|
||||
{ shape: 'circle', fill: '#f43f5e', stroke: '#9f1239' },
|
||||
{ shape: 'triangle', fill: '#f59e0b', stroke: '#92400e' },
|
||||
{ shape: 'diamond', fill: '#8b5cf6', stroke: '#5b21b6' },
|
||||
{ shape: 'star', fill: '#10b981', stroke: '#065f46' },
|
||||
{ shape: 'trapezoid', fill: '#0ea5e9', stroke: '#075985' },
|
||||
{ shape: 'parallelogram', fill: '#14b8a6', stroke: '#115e59' },
|
||||
blockAsset('brick', '#e11d48', '#9f1239', 2, 2, 0.68),
|
||||
blockAsset('tile', '#f59e0b', '#92400e', 3, 1, 0.28),
|
||||
blockAsset('slope', '#8b5cf6', '#5b21b6', 2, 1, 0.86),
|
||||
blockAsset('cylinder', '#10b981', '#065f46', 1, 1, 1.0),
|
||||
];
|
||||
|
||||
const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
@@ -162,14 +86,26 @@ const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '四',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'unknown-sky',
|
||||
visualKey: 'unknown-sky',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '五',
|
||||
},
|
||||
];
|
||||
|
||||
function blockAsset(
|
||||
shape: Match3DBlockShape,
|
||||
fill: string,
|
||||
stroke: string,
|
||||
studsX: number,
|
||||
studsY: number,
|
||||
heightScale: number,
|
||||
): Match3DGeometryAsset {
|
||||
return {
|
||||
shape,
|
||||
fill,
|
||||
stroke,
|
||||
studsX,
|
||||
studsY,
|
||||
heightScale,
|
||||
};
|
||||
}
|
||||
|
||||
export function hashVisualKey(visualKey: string) {
|
||||
let hash = 0;
|
||||
for (const char of visualKey) {
|
||||
@@ -199,48 +135,80 @@ export function resolveGeometryAsset(visualKey: string): Match3DGeometryAsset {
|
||||
);
|
||||
}
|
||||
|
||||
function renderGeometryShape(asset: Match3DGeometryAsset) {
|
||||
function renderBlockIcon(asset: Match3DGeometryAsset) {
|
||||
const shapeProps = {
|
||||
fill: asset.fill,
|
||||
stroke: asset.stroke,
|
||||
strokeWidth: 6,
|
||||
strokeWidth: 5,
|
||||
strokeLinejoin: 'round' as const,
|
||||
opacity: asset.transparent ? 0.72 : 1,
|
||||
};
|
||||
|
||||
switch (asset.shape) {
|
||||
case 'circle':
|
||||
return <circle cx="50" cy="50" r="36" {...shapeProps} />;
|
||||
case 'triangle':
|
||||
return <path d="M50 12 L89 84 H11Z" {...shapeProps} />;
|
||||
case 'diamond':
|
||||
return <path d="M50 9 L91 50 L50 91 L9 50Z" {...shapeProps} />;
|
||||
case 'square':
|
||||
return <rect x="16" y="16" width="68" height="68" rx="8" {...shapeProps} />;
|
||||
case 'star':
|
||||
return (
|
||||
<path
|
||||
d="M50 8 L61 36 L91 38 L68 58 L76 88 L50 72 L24 88 L32 58 L9 38 L39 36Z"
|
||||
{...shapeProps}
|
||||
/>
|
||||
);
|
||||
case 'hexagon':
|
||||
return <path d="M28 12 H72 L94 50 L72 88 H28 L6 50Z" {...shapeProps} />;
|
||||
case 'capsule':
|
||||
return <rect x="10" y="28" width="80" height="44" rx="22" {...shapeProps} />;
|
||||
case 'heart':
|
||||
return (
|
||||
<path
|
||||
d="M50 86 C25 66 13 52 17 34 C20 18 40 16 50 31 C60 16 80 18 83 34 C87 52 75 66 50 86Z"
|
||||
{...shapeProps}
|
||||
/>
|
||||
);
|
||||
case 'trapezoid':
|
||||
return <path d="M27 18 H73 L90 82 H10Z" {...shapeProps} />;
|
||||
case 'parallelogram':
|
||||
return <path d="M34 16 H88 L66 84 H12Z" {...shapeProps} />;
|
||||
default:
|
||||
return <circle cx="50" cy="50" r="36" {...shapeProps} />;
|
||||
if (asset.shape === 'cylinder') {
|
||||
return (
|
||||
<>
|
||||
<rect x="34" y="22" width="32" height="56" rx="12" {...shapeProps} />
|
||||
<ellipse cx="50" cy="24" rx="16" ry="8" fill={asset.fill} stroke={asset.stroke} strokeWidth={5} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.shape === 'ring') {
|
||||
return (
|
||||
<>
|
||||
<ellipse cx="50" cy="50" rx="34" ry="24" {...shapeProps} />
|
||||
<ellipse cx="50" cy="50" rx="17" ry="11" fill="rgba(255,255,255,0.88)" stroke={asset.stroke} strokeWidth={5} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.shape === 'arch') {
|
||||
return (
|
||||
<path
|
||||
d="M14 78 V28 H86 V78 H66 V46 C66 34 58 27 50 27 C42 27 34 34 34 46 V78Z"
|
||||
{...shapeProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.shape === 'cone') {
|
||||
return (
|
||||
<path d="M50 12 C66 28 78 62 78 82 H22 C22 62 34 28 50 12Z" {...shapeProps} />
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.shape === 'slope') {
|
||||
return <path d="M16 76 L84 76 L84 30 L16 60Z" {...shapeProps} />;
|
||||
}
|
||||
|
||||
const width = Math.min(76, 16 + asset.studsX * 14);
|
||||
const height = Math.min(54, 18 + asset.studsY * 13);
|
||||
const x = 50 - width / 2;
|
||||
const y = 54 - height / 2;
|
||||
const studRadius = asset.shape === 'tile' ? 0 : 5;
|
||||
return (
|
||||
<>
|
||||
<rect x={x} y={y} width={width} height={height} rx="7" {...shapeProps} />
|
||||
{Array.from({ length: asset.studsX * asset.studsY }, (_, index) => {
|
||||
if (studRadius <= 0) {
|
||||
return null;
|
||||
}
|
||||
const column = index % asset.studsX;
|
||||
const row = Math.floor(index / asset.studsX);
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={x + ((column + 0.5) * width) / asset.studsX}
|
||||
cy={y + ((row + 0.5) * height) / asset.studsY}
|
||||
r={studRadius}
|
||||
fill={asset.fill}
|
||||
stroke={asset.stroke}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Match3DVisualIcon({
|
||||
@@ -261,7 +229,7 @@ export function Match3DVisualIcon({
|
||||
data-testid={`match3d-visual-${visualKey}`}
|
||||
data-shape={asset.shape}
|
||||
>
|
||||
{renderGeometryShape(asset)}
|
||||
{renderBlockIcon(asset)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,156 +8,237 @@ import type {
|
||||
|
||||
const MATCH3D_TRAY_SLOT_COUNT = 7;
|
||||
const MATCH3D_LOCAL_DURATION_MS = 600_000;
|
||||
const MATCH3D_MAX_ITEM_TYPE_COUNT = 25;
|
||||
const MATCH3D_LOCAL_BASE_RADIUS = 0.072;
|
||||
|
||||
type Match3DSizeTier = 'XL' | 'L' | 'M' | 'XS' | 'S';
|
||||
|
||||
type Match3DVisualSeed = {
|
||||
itemTypeId: string;
|
||||
visualKey: string;
|
||||
colorClassName: string;
|
||||
label: string;
|
||||
sizeScale?: number;
|
||||
};
|
||||
|
||||
type Match3DSelectedVisualSeed = Match3DVisualSeed & {
|
||||
radiusScale: number;
|
||||
relativeVolume: number;
|
||||
sizeTier: Match3DSizeTier;
|
||||
};
|
||||
|
||||
const MATCH3D_SIZE_TIER_RULES: Array<{
|
||||
radiusScale: number;
|
||||
ratio: number;
|
||||
relativeVolume: number;
|
||||
sizeTier: Match3DSizeTier;
|
||||
}> = [
|
||||
{ sizeTier: 'XL', ratio: 0.2, relativeVolume: 1.86, radiusScale: 1.23 },
|
||||
{ sizeTier: 'L', ratio: 0.3, relativeVolume: 1.4, radiusScale: 1.12 },
|
||||
{ sizeTier: 'M', ratio: 0.3, relativeVolume: 1, radiusScale: 1 },
|
||||
{ sizeTier: 'XS', ratio: 0.15, relativeVolume: 0.73, radiusScale: 0.9 },
|
||||
{ sizeTier: 'S', ratio: 0.05, relativeVolume: 0.44, radiusScale: 0.76 },
|
||||
];
|
||||
|
||||
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
// 中文注释:水果题材内置视觉键要和后端 module-match3d 保持一致,避免不同物品被兜底成同一图案。
|
||||
// 中文注释:默认 25 类使用参考图中的积木件,形状、尺寸和颜色都要能区分。
|
||||
{
|
||||
itemTypeId: 'watermelon',
|
||||
visualKey: 'watermelon-green',
|
||||
colorClassName: 'from-emerald-500 to-green-800',
|
||||
label: '西瓜',
|
||||
sizeScale: 1.24,
|
||||
itemTypeId: 'block-red-2x4',
|
||||
visualKey: 'block-red-2x4',
|
||||
colorClassName: 'from-rose-400 to-red-700',
|
||||
label: '红色二乘四',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'apple-red',
|
||||
colorClassName: 'from-rose-400 to-red-600',
|
||||
label: '苹果',
|
||||
sizeScale: 1,
|
||||
itemTypeId: 'block-blue-1x2',
|
||||
visualKey: 'block-blue-1x2',
|
||||
colorClassName: 'from-blue-300 to-blue-700',
|
||||
label: '蓝色一乘二',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'banana',
|
||||
visualKey: 'banana-yellow',
|
||||
colorClassName: 'from-yellow-300 to-amber-500',
|
||||
label: '香蕉',
|
||||
sizeScale: 1.04,
|
||||
itemTypeId: 'block-yellow-2x2',
|
||||
visualKey: 'block-yellow-2x2',
|
||||
colorClassName: 'from-yellow-300 to-yellow-600',
|
||||
label: '黄色二乘二',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'grape',
|
||||
visualKey: 'grape-purple',
|
||||
colorClassName: 'from-violet-400 to-purple-700',
|
||||
label: '葡萄',
|
||||
sizeScale: 0.78,
|
||||
itemTypeId: 'block-green-1x4',
|
||||
visualKey: 'block-green-1x4',
|
||||
colorClassName: 'from-emerald-300 to-green-700',
|
||||
label: '绿色一乘四',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'melon',
|
||||
visualKey: 'melon-green',
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '甜瓜',
|
||||
sizeScale: 1.12,
|
||||
itemTypeId: 'block-orange-1x6',
|
||||
visualKey: 'block-orange-1x6',
|
||||
colorClassName: 'from-orange-300 to-orange-700',
|
||||
label: '橙色一乘六',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'berry',
|
||||
visualKey: 'berry-blue',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '蓝莓',
|
||||
sizeScale: 0.78,
|
||||
itemTypeId: 'block-white-1x1',
|
||||
visualKey: 'block-white-1x1',
|
||||
colorClassName: 'from-slate-50 to-slate-300',
|
||||
label: '白色一乘一',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'peach',
|
||||
visualKey: 'peach-pink',
|
||||
colorClassName: 'from-pink-300 to-orange-400',
|
||||
label: '桃子',
|
||||
sizeScale: 1,
|
||||
itemTypeId: 'block-black-1x8',
|
||||
visualKey: 'block-black-1x8',
|
||||
colorClassName: 'from-zinc-700 to-black',
|
||||
label: '黑色一乘八',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'plum',
|
||||
visualKey: 'plum-indigo',
|
||||
colorClassName: 'from-indigo-300 to-indigo-700',
|
||||
label: '李子',
|
||||
sizeScale: 0.86,
|
||||
itemTypeId: 'block-tan-2x3',
|
||||
visualKey: 'block-tan-2x3',
|
||||
colorClassName: 'from-amber-100 to-yellow-600',
|
||||
label: '米色二乘三',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'lime',
|
||||
visualKey: 'lime-lime',
|
||||
colorClassName: 'from-lime-300 to-lime-600',
|
||||
label: '青柠',
|
||||
sizeScale: 0.86,
|
||||
itemTypeId: 'block-lime-1x2',
|
||||
visualKey: 'block-lime-1x2',
|
||||
colorClassName: 'from-lime-300 to-lime-700',
|
||||
label: '青柠一乘二',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'orange',
|
||||
visualKey: 'orange-orange',
|
||||
colorClassName: 'from-orange-300 to-orange-600',
|
||||
label: '橙子',
|
||||
sizeScale: 1,
|
||||
itemTypeId: 'block-darkred-2x2',
|
||||
visualKey: 'block-darkred-2x2',
|
||||
colorClassName: 'from-red-700 to-red-950',
|
||||
label: '深红二乘二',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'pear-cyan',
|
||||
colorClassName: 'from-cyan-300 to-teal-600',
|
||||
label: '梨',
|
||||
sizeScale: 1,
|
||||
itemTypeId: 'block-blue-1x4',
|
||||
visualKey: 'block-blue-1x4',
|
||||
colorClassName: 'from-sky-300 to-blue-700',
|
||||
label: '蓝色一乘四',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'red-circle',
|
||||
visualKey: 'red_circle',
|
||||
colorClassName: 'from-rose-400 to-red-600',
|
||||
label: '圆',
|
||||
itemTypeId: 'block-pink-2x4',
|
||||
visualKey: 'block-pink-2x4',
|
||||
colorClassName: 'from-pink-300 to-pink-600',
|
||||
label: '粉色二乘四',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'yellow-triangle',
|
||||
visualKey: 'yellow_triangle',
|
||||
colorClassName: 'from-yellow-300 to-amber-500',
|
||||
label: '三',
|
||||
itemTypeId: 'block-gray-1x6',
|
||||
visualKey: 'block-gray-1x6',
|
||||
colorClassName: 'from-zinc-400 to-zinc-700',
|
||||
label: '灰色一乘六',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'purple-diamond',
|
||||
visualKey: 'purple_diamond',
|
||||
colorClassName: 'from-violet-400 to-purple-700',
|
||||
label: '菱',
|
||||
itemTypeId: 'block-lavender-tile-2x2',
|
||||
visualKey: 'block-lavender-tile-2x2',
|
||||
colorClassName: 'from-purple-200 to-purple-500',
|
||||
label: '薰衣草光板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'green-square',
|
||||
visualKey: 'green_square',
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '方',
|
||||
itemTypeId: 'block-teal-tile-1x3',
|
||||
visualKey: 'block-teal-tile-1x3',
|
||||
colorClassName: 'from-teal-300 to-teal-700',
|
||||
label: '青色长光板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'blue-star',
|
||||
visualKey: 'blue_star',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '星',
|
||||
itemTypeId: 'block-mint-tile-1x4',
|
||||
visualKey: 'block-mint-tile-1x4',
|
||||
colorClassName: 'from-emerald-100 to-emerald-400',
|
||||
label: '薄荷长光板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'orange-hexagon',
|
||||
visualKey: 'orange_hexagon',
|
||||
colorClassName: 'from-orange-300 to-orange-600',
|
||||
label: '六',
|
||||
itemTypeId: 'block-magenta-tile-2x2',
|
||||
visualKey: 'block-magenta-tile-2x2',
|
||||
colorClassName: 'from-fuchsia-500 to-pink-800',
|
||||
label: '洋红光板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'cyan-capsule',
|
||||
visualKey: 'cyan_capsule',
|
||||
colorClassName: 'from-cyan-300 to-teal-600',
|
||||
label: '胶',
|
||||
itemTypeId: 'block-orange-tile-2x2-stud',
|
||||
visualKey: 'block-orange-tile-2x2-stud',
|
||||
colorClassName: 'from-orange-300 to-amber-700',
|
||||
label: '橙色单钉板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'pink-heart',
|
||||
visualKey: 'pink_heart',
|
||||
colorClassName: 'from-pink-300 to-rose-500',
|
||||
label: '心',
|
||||
itemTypeId: 'block-purple-slope-1x2',
|
||||
visualKey: 'block-purple-slope-1x2',
|
||||
colorClassName: 'from-violet-400 to-violet-900',
|
||||
label: '紫色斜坡',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'lime-leaf',
|
||||
visualKey: 'lime_leaf',
|
||||
colorClassName: 'from-lime-300 to-lime-600',
|
||||
label: '叶',
|
||||
itemTypeId: 'block-brown-slope-1x2',
|
||||
visualKey: 'block-brown-slope-1x2',
|
||||
colorClassName: 'from-orange-900 to-stone-700',
|
||||
label: '棕色斜坡',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'white-moon',
|
||||
visualKey: 'white_moon',
|
||||
colorClassName: 'from-slate-100 to-slate-400',
|
||||
label: '月',
|
||||
itemTypeId: 'block-sky-slope-2x2',
|
||||
visualKey: 'block-sky-slope-2x2',
|
||||
colorClassName: 'from-sky-300 to-sky-600',
|
||||
label: '天蓝斜坡',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-green-cylinder',
|
||||
visualKey: 'block-green-cylinder',
|
||||
colorClassName: 'from-green-400 to-green-800',
|
||||
label: '绿色圆柱',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-clear-ring',
|
||||
visualKey: 'block-clear-ring',
|
||||
colorClassName: 'from-slate-50 to-slate-300',
|
||||
label: '透明圆环',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-mint-arch',
|
||||
visualKey: 'block-mint-arch',
|
||||
colorClassName: 'from-emerald-100 to-emerald-300',
|
||||
label: '薄荷拱门',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-gold-cone',
|
||||
visualKey: 'block-gold-cone',
|
||||
colorClassName: 'from-yellow-300 to-amber-700',
|
||||
label: '金色锥形件',
|
||||
},
|
||||
];
|
||||
|
||||
function hashNumber(value: number) {
|
||||
let state = Math.max(1, value >>> 0);
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 7;
|
||||
state ^= state << 17;
|
||||
return state >>> 0;
|
||||
}
|
||||
|
||||
function resolveSizeTierPlan(typeCount: number) {
|
||||
const baseCounts = MATCH3D_SIZE_TIER_RULES.map((rule) => ({
|
||||
...rule,
|
||||
count: Math.floor(typeCount * rule.ratio),
|
||||
remainder: typeCount * rule.ratio - Math.floor(typeCount * rule.ratio),
|
||||
}));
|
||||
let assignedCount = baseCounts.reduce((sum, rule) => sum + rule.count, 0);
|
||||
const remainderOrder = [...baseCounts].sort(
|
||||
(left, right) => right.remainder - left.remainder,
|
||||
);
|
||||
let cursor = 0;
|
||||
while (assignedCount < typeCount) {
|
||||
remainderOrder[cursor % remainderOrder.length]!.count += 1;
|
||||
assignedCount += 1;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
return baseCounts.flatMap((rule) => Array(rule.count).fill(rule));
|
||||
}
|
||||
|
||||
function selectVisualSeeds(clearCount: number): Match3DSelectedVisualSeed[] {
|
||||
const typeCount = Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, clearCount);
|
||||
const seeds = [...MATCH3D_VISUAL_SEEDS];
|
||||
let state = hashNumber(clearCount * 2_654_435_761);
|
||||
for (let index = seeds.length - 1; index > 0; index -= 1) {
|
||||
state = hashNumber(state + index);
|
||||
const swapIndex = state % (index + 1);
|
||||
[seeds[index], seeds[swapIndex]] = [seeds[swapIndex]!, seeds[index]!];
|
||||
}
|
||||
const sizeTierPlan = resolveSizeTierPlan(typeCount);
|
||||
return seeds.slice(0, typeCount).map((seed, index) => ({
|
||||
...seed,
|
||||
radiusScale: sizeTierPlan[index]!.radiusScale,
|
||||
relativeVolume: sizeTierPlan[index]!.relativeVolume,
|
||||
sizeTier: sizeTierPlan[index]!.sizeTier,
|
||||
}));
|
||||
}
|
||||
|
||||
function createEmptyTray(): Match3DTraySlot[] {
|
||||
return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({
|
||||
slotIndex,
|
||||
@@ -188,7 +269,7 @@ function normalizeRemainingMs(run: Match3DRunSnapshot, nowMs = Date.now()) {
|
||||
}
|
||||
|
||||
function buildItem(
|
||||
seed: Match3DVisualSeed,
|
||||
seed: Match3DSelectedVisualSeed,
|
||||
index: number,
|
||||
copyIndex: number,
|
||||
): Match3DItemSnapshot {
|
||||
@@ -198,9 +279,7 @@ function buildItem(
|
||||
const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026;
|
||||
const y =
|
||||
0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02;
|
||||
const baseRadius =
|
||||
0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004;
|
||||
const radius = baseRadius * (seed.sizeScale ?? 1);
|
||||
const radius = MATCH3D_LOCAL_BASE_RADIUS * seed.radiusScale;
|
||||
return {
|
||||
itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`,
|
||||
itemTypeId: seed.itemTypeId,
|
||||
@@ -332,12 +411,12 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) {
|
||||
|
||||
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
const typeCount = Math.min(10, normalizedClearCount);
|
||||
const selectedSeeds = selectVisualSeeds(normalizedClearCount);
|
||||
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
|
||||
Array.from({ length: 3 }, (_, copyOffset) => {
|
||||
const seed =
|
||||
MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ??
|
||||
MATCH3D_VISUAL_SEEDS[0]!;
|
||||
selectedSeeds[clearIndex % selectedSeeds.length] ??
|
||||
selectedSeeds[0]!;
|
||||
return buildItem(
|
||||
seed,
|
||||
clearIndex * 3 + copyOffset,
|
||||
|
||||
Reference in New Issue
Block a user