Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
This commit is contained in:
@@ -267,6 +267,7 @@ test('auth gate keeps password entry available when login options are empty', as
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
expect(within(dialog).getByLabelText('密码')).toBeTruthy();
|
||||
expect(within(dialog).queryByText('当前登录入口暂不可用。')).toBeNull();
|
||||
expect(within(dialog).queryByText('读取登录方式失败')).toBeNull();
|
||||
});
|
||||
|
||||
test('auth gate falls back to password entry when login options request fails', async () => {
|
||||
|
||||
@@ -269,6 +269,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
let isActive = true;
|
||||
|
||||
const hydrate = async () => {
|
||||
const callbackResult = consumeAuthCallbackResult();
|
||||
const loadLoginOptions = async () => {
|
||||
const options = await getAuthLoginOptions();
|
||||
if (!isActive) {
|
||||
@@ -297,16 +298,13 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
|
||||
setAvailableLoginMethods(FALLBACK_LOGIN_METHODS);
|
||||
setUser(null);
|
||||
setError(
|
||||
optionsError instanceof Error
|
||||
? optionsError.message
|
||||
: '读取登录方式失败,请稍后再试。',
|
||||
);
|
||||
// 中文注释:登录方式接口失败时按产品约定保留密码登录入口;
|
||||
// 这里不展示接口读取错误,避免用户误以为登录本身不可用。
|
||||
setError(callbackResult?.error ?? '');
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
};
|
||||
|
||||
const callbackResult = consumeAuthCallbackResult();
|
||||
if (callbackResult?.error && isActive) {
|
||||
setError(callbackResult.error);
|
||||
setShowLoginModal(true);
|
||||
|
||||
@@ -112,9 +112,9 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((match3dButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(
|
||||
within(match3dButton).getAllByText('敬请期待').length,
|
||||
within(match3dButton).getAllByText('经典消除玩法').length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(puzzleButton).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
|
||||
@@ -61,8 +61,119 @@ test('点击可见物品后先乐观入槽再等待确认', async () => {
|
||||
expect(clickableItem).toBeTruthy();
|
||||
const { onClickItem, onOptimisticRunChange } = renderRuntime(run);
|
||||
|
||||
fireEvent.click(screen.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`));
|
||||
fireEvent.click(
|
||||
screen.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`),
|
||||
);
|
||||
|
||||
expect(onOptimisticRunChange).toHaveBeenCalled();
|
||||
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
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',
|
||||
x: 0.42 + index * 0.16,
|
||||
y: 0.5,
|
||||
layer: index,
|
||||
clickable: true,
|
||||
}));
|
||||
renderRuntime(run);
|
||||
|
||||
expect(screen.getByTestId('match3d-visual-red_circle')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-yellow_triangle')).toBeTruthy();
|
||||
expect(screen.queryAllByText('苹')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('水果题材视觉键也渲染为无文字纯色几何体', () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
run.items = run.items.slice(0, 3).map((item, index) => ({
|
||||
...item,
|
||||
itemInstanceId: `fruit-${index}`,
|
||||
itemTypeId: `fruit-type-${index}`,
|
||||
visualKey:
|
||||
index === 0
|
||||
? 'watermelon-green'
|
||||
: index === 1
|
||||
? 'apple-red'
|
||||
: 'grape-purple',
|
||||
x: 0.35 + index * 0.15,
|
||||
y: 0.5,
|
||||
radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07,
|
||||
layer: index,
|
||||
clickable: true,
|
||||
}));
|
||||
renderRuntime(run);
|
||||
|
||||
expect(screen.getByTestId('match3d-visual-watermelon-green')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByTestId('match3d-visual-apple-red').getAttribute('data-shape'),
|
||||
).toBe('heart');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-visual-grape-purple')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('star');
|
||||
expect(screen.queryByText('苹果')).toBeNull();
|
||||
expect(screen.queryByText('苹')).toBeNull();
|
||||
});
|
||||
|
||||
test('运行态支持梯形和平行四边形等差异化几何造型', () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
run.items = run.items.slice(0, 3).map((item, index) => ({
|
||||
...item,
|
||||
itemInstanceId: `geometry-${index}`,
|
||||
itemTypeId: `geometry-type-${index}`,
|
||||
visualKey:
|
||||
index === 0
|
||||
? 'peach-pink'
|
||||
: index === 1
|
||||
? 'banana-yellow'
|
||||
: 'orange_hexagon',
|
||||
x: 0.35 + index * 0.15,
|
||||
y: 0.5,
|
||||
layer: index,
|
||||
clickable: true,
|
||||
}));
|
||||
renderRuntime(run);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('match3d-visual-peach-pink').getAttribute('data-shape'),
|
||||
).toBe('trapezoid');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-visual-banana-yellow')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('parallelogram');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-visual-orange_hexagon')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('hexagon');
|
||||
});
|
||||
|
||||
test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => {
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const item = run.items[0]!;
|
||||
run.items = [
|
||||
{
|
||||
...item,
|
||||
itemInstanceId: 'legacy-outside',
|
||||
visualKey: 'apple-red',
|
||||
x: -0.4,
|
||||
y: 0.5,
|
||||
radius: 0.1,
|
||||
clickable: true,
|
||||
},
|
||||
];
|
||||
renderRuntime(run);
|
||||
|
||||
const token = screen.getByTestId(
|
||||
'match3d-item-legacy-outside',
|
||||
) as HTMLElement;
|
||||
expect(parseFloat(token.style.left)).toBeGreaterThanOrEqual(0);
|
||||
expect(parseFloat(token.style.left)).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
@@ -41,6 +41,174 @@ type Match3DFeedbackEvent = {
|
||||
kind: 'cleared' | 'rejected';
|
||||
itemIds: string[];
|
||||
};
|
||||
type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number];
|
||||
type Match3DGeometryShape =
|
||||
| 'circle'
|
||||
| 'triangle'
|
||||
| 'diamond'
|
||||
| 'square'
|
||||
| 'star'
|
||||
| 'hexagon'
|
||||
| 'capsule'
|
||||
| 'heart'
|
||||
| 'trapezoid'
|
||||
| 'parallelogram';
|
||||
type Match3DGeometryAsset = {
|
||||
shape: Match3DGeometryShape;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
};
|
||||
|
||||
const MATCH3D_RENDER_CENTER = 0.5;
|
||||
const MATCH3D_RENDER_RADIUS = 0.5;
|
||||
const MATCH3D_RENDER_SAFE_MARGIN = 0.035;
|
||||
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',
|
||||
},
|
||||
};
|
||||
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' },
|
||||
];
|
||||
const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
{
|
||||
itemTypeId: 'unknown-rose',
|
||||
visualKey: 'unknown-rose',
|
||||
colorClassName: 'from-rose-400 to-red-600',
|
||||
label: '一',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'unknown-amber',
|
||||
visualKey: 'unknown-amber',
|
||||
colorClassName: 'from-yellow-300 to-amber-500',
|
||||
label: '二',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'unknown-violet',
|
||||
visualKey: 'unknown-violet',
|
||||
colorClassName: 'from-violet-400 to-purple-700',
|
||||
label: '三',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'unknown-emerald',
|
||||
visualKey: 'unknown-emerald',
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '四',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'unknown-sky',
|
||||
visualKey: 'unknown-sky',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '五',
|
||||
},
|
||||
];
|
||||
|
||||
function formatTimer(value: number) {
|
||||
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
|
||||
@@ -49,7 +217,11 @@ function formatTimer(value: number) {
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatElapsed(startedAtMs: number, remainingMs: number, durationLimitMs: number) {
|
||||
function formatElapsed(
|
||||
startedAtMs: number,
|
||||
remainingMs: number,
|
||||
durationLimitMs: number,
|
||||
) {
|
||||
const elapsedMs = Math.max(0, durationLimitMs - remainingMs);
|
||||
const totalSeconds = Math.floor(elapsedMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
@@ -57,11 +229,128 @@ function formatElapsed(startedAtMs: number, remainingMs: number, durationLimitMs
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function hashVisualKey(visualKey: string) {
|
||||
let hash = 0;
|
||||
for (const char of visualKey) {
|
||||
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function resolveVisualSeed(visualKey: string) {
|
||||
return (
|
||||
MATCH3D_VISUAL_SEEDS.find((seed) => seed.visualKey === visualKey) ??
|
||||
MATCH3D_VISUAL_SEEDS[0]!
|
||||
const knownSeed = MATCH3D_VISUAL_SEEDS.find(
|
||||
(seed) => seed.visualKey === visualKey,
|
||||
);
|
||||
if (knownSeed) {
|
||||
return knownSeed;
|
||||
}
|
||||
return MATCH3D_UNKNOWN_VISUAL_SEEDS[
|
||||
hashVisualKey(visualKey) % MATCH3D_UNKNOWN_VISUAL_SEEDS.length
|
||||
]!;
|
||||
}
|
||||
|
||||
function resolveGeometryAsset(visualKey: string): Match3DGeometryAsset {
|
||||
return (
|
||||
MATCH3D_GEOMETRY_ASSETS[visualKey] ??
|
||||
MATCH3D_UNKNOWN_GEOMETRY_ASSETS[
|
||||
hashVisualKey(visualKey) % MATCH3D_UNKNOWN_GEOMETRY_ASSETS.length
|
||||
]!
|
||||
);
|
||||
}
|
||||
|
||||
function renderGeometryShape(asset: Match3DGeometryAsset) {
|
||||
const shapeProps = {
|
||||
fill: asset.fill,
|
||||
stroke: asset.stroke,
|
||||
strokeWidth: 6,
|
||||
strokeLinejoin: 'round' as const,
|
||||
};
|
||||
|
||||
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} />;
|
||||
}
|
||||
}
|
||||
|
||||
function Match3DVisualIcon({
|
||||
visualKey,
|
||||
className = '',
|
||||
}: {
|
||||
visualKey: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const asset = resolveGeometryAsset(visualKey);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`pointer-events-none h-full w-full drop-shadow-[0_5px_7px_rgba(15,23,42,0.36)] ${className}`}
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden
|
||||
focusable={false}
|
||||
data-testid={`match3d-visual-${visualKey}`}
|
||||
data-shape={asset.shape}
|
||||
>
|
||||
{renderGeometryShape(asset)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRenderableItemFrame(item: Match3DItemSnapshot) {
|
||||
const maxRadius = MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN;
|
||||
const radius = Math.min(
|
||||
Math.max(Number.isFinite(item.radius) ? item.radius : 0.06, 0.035),
|
||||
maxRadius,
|
||||
);
|
||||
const rawX = Number.isFinite(item.x) ? item.x : MATCH3D_RENDER_CENTER;
|
||||
const rawY = Number.isFinite(item.y) ? item.y : MATCH3D_RENDER_CENTER;
|
||||
const dx = rawX - MATCH3D_RENDER_CENTER;
|
||||
const dy = rawY - MATCH3D_RENDER_CENTER;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const maxDistance = Math.max(
|
||||
0,
|
||||
MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN - radius,
|
||||
);
|
||||
|
||||
if (distance <= maxDistance || distance <= 0) {
|
||||
return { x: rawX, y: rawY, radius };
|
||||
}
|
||||
|
||||
const ratio = maxDistance / distance;
|
||||
return {
|
||||
x: MATCH3D_RENDER_CENTER + dx * ratio,
|
||||
y: MATCH3D_RENDER_CENTER + dy * ratio,
|
||||
radius,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClientEventId(itemInstanceId: string) {
|
||||
@@ -81,9 +370,11 @@ function isItemState(
|
||||
state: Match3DItemSnapshot['state'],
|
||||
expected: 'in_board' | 'in_tray' | 'cleared' | 'flying',
|
||||
) {
|
||||
return String(state)
|
||||
.replace(/([a-z])([A-Z])/gu, '$1_$2')
|
||||
.toLowerCase() === expected;
|
||||
return (
|
||||
String(state)
|
||||
.replace(/([a-z])([A-Z])/gu, '$1_$2')
|
||||
.toLowerCase() === expected
|
||||
);
|
||||
}
|
||||
|
||||
function isPointInsideCircle(
|
||||
@@ -91,14 +382,11 @@ function isPointInsideCircle(
|
||||
pointY: number,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
return Math.hypot(pointX - item.x, pointY - item.y) <= item.radius;
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius;
|
||||
}
|
||||
|
||||
function findHitItem(
|
||||
run: Match3DRunSnapshot,
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
) {
|
||||
function findHitItem(run: Match3DRunSnapshot, pointX: number, pointY: number) {
|
||||
return run.items
|
||||
.filter(
|
||||
(item) =>
|
||||
@@ -151,51 +439,57 @@ function Match3DToken({
|
||||
onClick: (item: Match3DItemSnapshot) => void;
|
||||
}) {
|
||||
const visualSeed = resolveVisualSeed(item.visualKey);
|
||||
const size = `${item.radius * 200}%`;
|
||||
const itemStateClass =
|
||||
isItemState(item.state, 'flying')
|
||||
? 'scale-75 opacity-0'
|
||||
: item.clickable
|
||||
? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95'
|
||||
: 'opacity-48';
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
const size = `${frame.radius * 200}%`;
|
||||
const itemStateClass = isItemState(item.state, 'flying')
|
||||
? 'scale-75 opacity-0'
|
||||
: item.clickable
|
||||
? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95'
|
||||
: 'opacity-48';
|
||||
|
||||
if (!isItemState(item.state, 'in_board') && !isItemState(item.state, 'flying')) {
|
||||
if (
|
||||
!isItemState(item.state, 'in_board') &&
|
||||
!isItemState(item.state, 'flying')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white/35 bg-gradient-to-br ${visualSeed.colorClassName} text-sm font-black text-white shadow-[0_10px_18px_rgba(15,23,42,0.32)] transition-all duration-300 [text-shadow:0_1px_2px_rgba(15,23,42,0.65)] ${itemStateClass}`}
|
||||
className={`absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center bg-transparent p-0 transition-all duration-300 ${itemStateClass}`}
|
||||
style={{
|
||||
left: `${item.x * 100}%`,
|
||||
top: `${item.y * 100}%`,
|
||||
left: `${frame.x * 100}%`,
|
||||
top: `${frame.y * 100}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: item.layer,
|
||||
zIndex: item.layer + 10,
|
||||
}}
|
||||
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
|
||||
data-testid={`match3d-item-${item.itemInstanceId}`}
|
||||
disabled={disabled || !item.clickable || !isItemState(item.state, 'in_board')}
|
||||
disabled={
|
||||
disabled || !item.clickable || !isItemState(item.state, 'in_board')
|
||||
}
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
<span className="relative z-10">{visualSeed.label}</span>
|
||||
<span className="absolute inset-[16%] rounded-full bg-white/24" />
|
||||
<span className="absolute left-[18%] top-[14%] h-[18%] w-[28%] rounded-full bg-white/42 blur-[1px]" />
|
||||
<Match3DVisualIcon visualKey={item.visualKey} className="relative z-10" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) {
|
||||
if (!slot.visualKey) {
|
||||
return <span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />;
|
||||
return (
|
||||
<span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />
|
||||
);
|
||||
}
|
||||
const visualSeed = resolveVisualSeed(slot.visualKey);
|
||||
return (
|
||||
<span
|
||||
className={`flex h-full w-full items-center justify-center rounded-xl border border-white/35 bg-gradient-to-br ${visualSeed.colorClassName} text-xs font-black text-white shadow-[0_8px_16px_rgba(15,23,42,0.24)] [text-shadow:0_1px_2px_rgba(15,23,42,0.62)]`}
|
||||
className="flex h-full w-full items-center justify-center p-1"
|
||||
aria-label={visualSeed.label}
|
||||
>
|
||||
{visualSeed.label}
|
||||
<Match3DVisualIcon visualKey={slot.visualKey} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -228,14 +522,18 @@ function Match3DSettlement({
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<span
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-full ${
|
||||
won ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'
|
||||
won
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-rose-100 text-rose-700'
|
||||
}`}
|
||||
>
|
||||
{won ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-xl font-black">{title}</h2>
|
||||
<p className="text-sm font-semibold text-slate-500">{description}</p>
|
||||
<p className="text-sm font-semibold text-slate-500">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -271,9 +569,8 @@ export function Match3DRuntimeShell({
|
||||
}: Match3DRuntimeShellProps) {
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
|
||||
const [feedbackEvent, setFeedbackEvent] = useState<Match3DFeedbackEvent | null>(
|
||||
null,
|
||||
);
|
||||
const [feedbackEvent, setFeedbackEvent] =
|
||||
useState<Match3DFeedbackEvent | null>(null);
|
||||
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -371,7 +668,7 @@ export function Match3DRuntimeShell({
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-slate-950 text-white">
|
||||
{isBusy ? '载入中' : error ?? '暂无运行态'}
|
||||
{isBusy ? '载入中' : (error ?? '暂无运行态')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -422,7 +719,7 @@ export function Match3DRuntimeShell({
|
||||
onPointerDown={handleBoardPointerDown}
|
||||
data-testid="match3d-board"
|
||||
>
|
||||
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
|
||||
<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%)]" />
|
||||
{run.items.map((item) => (
|
||||
<Match3DToken
|
||||
key={item.itemInstanceId}
|
||||
|
||||
@@ -1106,14 +1106,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
const galleryResponse = await listMatch3DGallery();
|
||||
setMatch3DGalleryEntries(galleryResponse.items);
|
||||
return galleryResponse.items;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// 中文注释:公开广场是首页展示数据,失败时只降级为空列表;
|
||||
// 不写入创作错误态,避免挡住抓大鹅共创入口。
|
||||
setMatch3DGalleryEntries([]);
|
||||
setMatch3DError(
|
||||
resolveMatch3DErrorMessage(error, '读取抓大鹅广场失败。'),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}, [resolveMatch3DErrorMessage]);
|
||||
}, []);
|
||||
|
||||
const refreshPuzzleShelf = useCallback(async () => {
|
||||
setIsPuzzleLoadingLibrary(true);
|
||||
@@ -1238,6 +1237,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isBigFishCreationVisible
|
||||
? refreshBigFishGallery()
|
||||
: Promise.resolve([] as BigFishWorkSummary[]),
|
||||
refreshMatch3DGallery(),
|
||||
refreshPuzzleGallery(),
|
||||
]);
|
||||
return latestSession;
|
||||
@@ -1680,6 +1680,24 @@ export function PlatformEntryFlowShellImpl({
|
||||
await bigFishFlow.openWorkspace();
|
||||
}, [bigFishFlow]);
|
||||
|
||||
const openMatch3DAgentWorkspace = useCallback(async () => {
|
||||
setMatch3DSession(null);
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DRun(null);
|
||||
setMatch3DError(null);
|
||||
setStreamingMatch3DReplyText('');
|
||||
setIsStreamingMatch3DReply(false);
|
||||
await match3dFlow.openWorkspace();
|
||||
}, [
|
||||
match3dFlow,
|
||||
setIsStreamingMatch3DReply,
|
||||
setMatch3DError,
|
||||
setMatch3DProfile,
|
||||
setMatch3DRun,
|
||||
setMatch3DSession,
|
||||
setStreamingMatch3DReplyText,
|
||||
]);
|
||||
|
||||
const openPuzzleAgentWorkspace = useCallback(async () => {
|
||||
setPuzzleRun(null);
|
||||
setPuzzleOperation(null);
|
||||
@@ -1824,7 +1842,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const handleCreationHubCreateType = useCallback(
|
||||
(type: PlatformCreationTypeId) => {
|
||||
if (type === 'match3d' || type === 'airp' || type === 'visual-novel') {
|
||||
if (type === 'airp' || type === 'visual-novel') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1846,6 +1864,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'match3d') {
|
||||
runProtectedAction(() => {
|
||||
void openMatch3DAgentWorkspace();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'puzzle') {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleAgentWorkspace();
|
||||
@@ -1854,6 +1879,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
[
|
||||
openBigFishAgentWorkspace,
|
||||
openMatch3DAgentWorkspace,
|
||||
openPuzzleAgentWorkspace,
|
||||
prepareCreationLaunch,
|
||||
runProtectedAction,
|
||||
@@ -5108,7 +5134,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
}}
|
||||
onSelectMatch3D={() => {
|
||||
// 抓大鹅创作入口当前为敬请期待;保留回调防御,避免旧入口绕过锁定态。
|
||||
runProtectedAction(() => {
|
||||
void openMatch3DAgentWorkspace();
|
||||
});
|
||||
}}
|
||||
onSelectPuzzle={() => {
|
||||
runProtectedAction(() => {
|
||||
|
||||
@@ -10,6 +10,9 @@ import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
Match3DAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
@@ -428,6 +431,21 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
|
||||
Match3DAgentWorkspace: ({
|
||||
session,
|
||||
}: {
|
||||
session: { sessionId: string; messages: Array<{ text: string }> } | null;
|
||||
}) => (
|
||||
<div className="match3d-agent-workspace-mock">
|
||||
<div>抓大鹅工作区:{session?.sessionId ?? 'missing-session'}</div>
|
||||
{session?.messages.map((message) => (
|
||||
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
|
||||
Match3DRuntimeShell: ({
|
||||
run,
|
||||
@@ -666,6 +684,59 @@ function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockMatch3DAgentSession(
|
||||
overrides: Partial<Match3DAgentSessionSnapshot> = {},
|
||||
): Match3DAgentSessionSnapshot {
|
||||
const sessionId = overrides.sessionId ?? 'match3d-agent-session-1';
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
currentTurn: 0,
|
||||
progressPercent: 20,
|
||||
stage: 'collecting',
|
||||
anchorPack: {
|
||||
theme: {
|
||||
key: 'theme',
|
||||
label: '题材主题',
|
||||
value: '水果消除',
|
||||
status: 'confirmed',
|
||||
},
|
||||
clearCount: {
|
||||
key: 'clearCount',
|
||||
label: '需要消除次数',
|
||||
value: '4',
|
||||
status: 'confirmed',
|
||||
},
|
||||
difficulty: {
|
||||
key: 'difficulty',
|
||||
label: '难度',
|
||||
value: '5',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
themeText: '水果消除',
|
||||
referenceImageSrc: null,
|
||||
clearCount: 4,
|
||||
difficulty: 5,
|
||||
},
|
||||
draft: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'match3d-message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '我们先确定抓大鹅题材、消除次数和难度。',
|
||||
createdAt: '2026-05-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
lastAssistantReply: '我们先确定抓大鹅题材、消除次数和难度。',
|
||||
publishedProfileId: null,
|
||||
updatedAt: '2026-05-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockRpgGalleryDetail(
|
||||
entry: CustomWorldGalleryCard,
|
||||
): CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
@@ -2637,6 +2708,38 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
|
||||
expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull();
|
||||
});
|
||||
|
||||
test('match3d creation card opens workspace even when public galleries fail', async () => {
|
||||
const user = userEvent.setup();
|
||||
const match3dSession = buildMockMatch3DAgentSession();
|
||||
|
||||
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
|
||||
new Error('读取作品广场失败'),
|
||||
);
|
||||
vi.mocked(listMatch3DGallery).mockRejectedValueOnce(
|
||||
new Error('读取抓大鹅广场失败'),
|
||||
);
|
||||
vi.mocked(match3dCreationClient.createSession).mockResolvedValueOnce({
|
||||
session: match3dSession,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
||||
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
||||
|
||||
const button = screen.getByRole('button', {
|
||||
name: /抓大鹅.*经典消除玩法/u,
|
||||
});
|
||||
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dCreationClient.createSession).toHaveBeenCalledWith({});
|
||||
});
|
||||
expect(await screen.findByText('抓大鹅工作区:match3d-agent-session-1')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('puzzle draft card restores the bound agent session and opens the result view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -270,6 +270,8 @@ export function useRpgEntryBootstrap(
|
||||
if (galleryEntriesResult.status === 'fulfilled') {
|
||||
setPublishedGalleryEntries(galleryEntriesResult.value);
|
||||
} else {
|
||||
// 中文注释:公开广场只影响首页展示,失败时降级为空列表;
|
||||
// 私有作品库和创作作品列表的受保护失败才需要阻塞提示。
|
||||
setPublishedGalleryEntries([]);
|
||||
}
|
||||
|
||||
@@ -277,17 +279,14 @@ export function useRpgEntryBootstrap(
|
||||
(canReadProtectedData &&
|
||||
libraryEntriesResult.status === 'rejected') ||
|
||||
(canReadProtectedData &&
|
||||
workEntriesResult.status === 'rejected') ||
|
||||
galleryEntriesResult.status === 'rejected'
|
||||
workEntriesResult.status === 'rejected')
|
||||
) {
|
||||
const platformFailure =
|
||||
libraryEntriesResult.status === 'rejected'
|
||||
? libraryEntriesResult.reason
|
||||
: workEntriesResult.status === 'rejected'
|
||||
? workEntriesResult.reason
|
||||
: galleryEntriesResult.status === 'rejected'
|
||||
? galleryEntriesResult.reason
|
||||
: null;
|
||||
: null;
|
||||
setPlatformError(
|
||||
resolveRpgEntryErrorMessage(platformFailure, '读取平台数据失败。'),
|
||||
);
|
||||
|
||||
@@ -41,10 +41,10 @@ export const NEW_WORK_ENTRY_CONFIG = {
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
subtitle: '经典消除玩法',
|
||||
badge: '可创建',
|
||||
visible: true,
|
||||
open: false,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
|
||||
@@ -14,68 +14,147 @@ type Match3DVisualSeed = {
|
||||
visualKey: string;
|
||||
colorClassName: string;
|
||||
label: string;
|
||||
sizeScale?: number;
|
||||
};
|
||||
|
||||
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
// 中文注释:水果题材内置视觉键要和后端 module-match3d 保持一致,避免不同物品被兜底成同一图案。
|
||||
{
|
||||
itemTypeId: 'watermelon',
|
||||
visualKey: 'watermelon-green',
|
||||
colorClassName: 'from-emerald-500 to-green-800',
|
||||
label: '西瓜',
|
||||
sizeScale: 1.24,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'apple-red',
|
||||
colorClassName: 'from-rose-400 to-red-600',
|
||||
label: '苹',
|
||||
label: '苹果',
|
||||
sizeScale: 1,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'banana',
|
||||
visualKey: 'banana-yellow',
|
||||
colorClassName: 'from-yellow-300 to-amber-500',
|
||||
label: '蕉',
|
||||
label: '香蕉',
|
||||
sizeScale: 1.04,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'grape',
|
||||
visualKey: 'grape-purple',
|
||||
colorClassName: 'from-violet-400 to-purple-700',
|
||||
label: '萄',
|
||||
label: '葡萄',
|
||||
sizeScale: 0.78,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'melon',
|
||||
visualKey: 'melon-green',
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '瓜',
|
||||
label: '甜瓜',
|
||||
sizeScale: 1.12,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'berry',
|
||||
visualKey: 'berry-blue',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '莓',
|
||||
label: '蓝莓',
|
||||
sizeScale: 0.78,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'peach',
|
||||
visualKey: 'peach-pink',
|
||||
colorClassName: 'from-pink-300 to-orange-400',
|
||||
label: '桃',
|
||||
label: '桃子',
|
||||
sizeScale: 1,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'plum',
|
||||
visualKey: 'plum-indigo',
|
||||
colorClassName: 'from-indigo-300 to-indigo-700',
|
||||
label: '李',
|
||||
label: '李子',
|
||||
sizeScale: 0.86,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'lime',
|
||||
visualKey: 'lime-lime',
|
||||
colorClassName: 'from-lime-300 to-lime-600',
|
||||
label: '柠',
|
||||
label: '青柠',
|
||||
sizeScale: 0.86,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'orange',
|
||||
visualKey: 'orange-orange',
|
||||
colorClassName: 'from-orange-300 to-orange-600',
|
||||
label: '橙',
|
||||
label: '橙子',
|
||||
sizeScale: 1,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'candy',
|
||||
visualKey: 'candy-cyan',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'pear-cyan',
|
||||
colorClassName: 'from-cyan-300 to-teal-600',
|
||||
label: '糖',
|
||||
label: '梨',
|
||||
sizeScale: 1,
|
||||
},
|
||||
{
|
||||
itemTypeId: 'red-circle',
|
||||
visualKey: 'red_circle',
|
||||
colorClassName: 'from-rose-400 to-red-600',
|
||||
label: '圆',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'yellow-triangle',
|
||||
visualKey: 'yellow_triangle',
|
||||
colorClassName: 'from-yellow-300 to-amber-500',
|
||||
label: '三',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'purple-diamond',
|
||||
visualKey: 'purple_diamond',
|
||||
colorClassName: 'from-violet-400 to-purple-700',
|
||||
label: '菱',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'green-square',
|
||||
visualKey: 'green_square',
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '方',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'blue-star',
|
||||
visualKey: 'blue_star',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '星',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'orange-hexagon',
|
||||
visualKey: 'orange_hexagon',
|
||||
colorClassName: 'from-orange-300 to-orange-600',
|
||||
label: '六',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'cyan-capsule',
|
||||
visualKey: 'cyan_capsule',
|
||||
colorClassName: 'from-cyan-300 to-teal-600',
|
||||
label: '胶',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'pink-heart',
|
||||
visualKey: 'pink_heart',
|
||||
colorClassName: 'from-pink-300 to-rose-500',
|
||||
label: '心',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'lime-leaf',
|
||||
visualKey: 'lime_leaf',
|
||||
colorClassName: 'from-lime-300 to-lime-600',
|
||||
label: '叶',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'white-moon',
|
||||
visualKey: 'white_moon',
|
||||
colorClassName: 'from-slate-100 to-slate-400',
|
||||
label: '月',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -117,8 +196,11 @@ function buildItem(
|
||||
const angle = index * 0.86 + copyIndex * 0.22;
|
||||
const spread = 0.16 + (ring % 4) * 0.085;
|
||||
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 radius = 0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004;
|
||||
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);
|
||||
return {
|
||||
itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`,
|
||||
itemTypeId: seed.itemTypeId,
|
||||
@@ -142,7 +224,10 @@ function recomputeClickable(items: Match3DItemSnapshot[]) {
|
||||
};
|
||||
}
|
||||
const coveredByHigherLayer = boardItems.some((other) => {
|
||||
if (other.itemInstanceId === item.itemInstanceId || other.layer <= item.layer) {
|
||||
if (
|
||||
other.itemInstanceId === item.itemInstanceId ||
|
||||
other.layer <= item.layer
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const distance = Math.hypot(other.x - item.x, other.y - item.y);
|
||||
@@ -173,7 +258,9 @@ function resolveRunStatus(run: Match3DRunSnapshot): Match3DRunSnapshot {
|
||||
remainingMs: Math.max(0, run.remainingMs),
|
||||
};
|
||||
}
|
||||
const trayIsFull = run.traySlots.every((slot) => Boolean(slot.itemInstanceId));
|
||||
const trayIsFull = run.traySlots.every((slot) =>
|
||||
Boolean(slot.itemInstanceId),
|
||||
);
|
||||
if (trayIsFull) {
|
||||
return {
|
||||
...run,
|
||||
@@ -202,7 +289,9 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) {
|
||||
]);
|
||||
}
|
||||
|
||||
const matchedSlots = [...slotsByType.values()].find((slots) => slots.length >= 3);
|
||||
const matchedSlots = [...slotsByType.values()].find(
|
||||
(slots) => slots.length >= 3,
|
||||
);
|
||||
if (!matchedSlots) {
|
||||
return {
|
||||
run,
|
||||
@@ -213,7 +302,9 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) {
|
||||
const clearedItemInstanceIds = matchedSlots
|
||||
.slice(0, 3)
|
||||
.map((slot) => slot.itemInstanceId)
|
||||
.filter((itemInstanceId): itemInstanceId is string => Boolean(itemInstanceId));
|
||||
.filter((itemInstanceId): itemInstanceId is string =>
|
||||
Boolean(itemInstanceId),
|
||||
);
|
||||
const clearedSet = new Set(clearedItemInstanceIds);
|
||||
const nextRun = {
|
||||
...run,
|
||||
@@ -241,11 +332,17 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) {
|
||||
|
||||
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
const typeCount = Math.min(MATCH3D_VISUAL_SEEDS.length, normalizedClearCount);
|
||||
const typeCount = Math.min(10, normalizedClearCount);
|
||||
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
|
||||
Array.from({ length: 3 }, (_, copyOffset) => {
|
||||
const seed = MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ?? MATCH3D_VISUAL_SEEDS[0]!;
|
||||
return buildItem(seed, clearIndex * 3 + copyOffset, clearIndex * 3 + copyOffset);
|
||||
const seed =
|
||||
MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ??
|
||||
MATCH3D_VISUAL_SEEDS[0]!;
|
||||
return buildItem(
|
||||
seed,
|
||||
clearIndex * 3 + copyOffset,
|
||||
clearIndex * 3 + copyOffset,
|
||||
);
|
||||
}),
|
||||
).flat();
|
||||
const nowMs = Date.now();
|
||||
@@ -274,7 +371,9 @@ export function buildLocalMatch3DOptimisticRun(
|
||||
run: Match3DRunSnapshot,
|
||||
itemInstanceId: string,
|
||||
): Match3DRunSnapshot {
|
||||
const targetItem = run.items.find((item) => item.itemInstanceId === itemInstanceId);
|
||||
const targetItem = run.items.find(
|
||||
(item) => item.itemInstanceId === itemInstanceId,
|
||||
);
|
||||
const nextTrayIndex = findNextTrayIndex(run.traySlots);
|
||||
if (!targetItem || targetItem.state !== 'InBoard' || nextTrayIndex < 0) {
|
||||
return run;
|
||||
@@ -397,7 +496,9 @@ export async function confirmLocalMatch3DClick(
|
||||
};
|
||||
}
|
||||
|
||||
export function stopLocalMatch3DRun(run: Match3DRunSnapshot): Match3DRunSnapshot {
|
||||
export function stopLocalMatch3DRun(
|
||||
run: Match3DRunSnapshot,
|
||||
): Match3DRunSnapshot {
|
||||
if (run.status !== 'Running') {
|
||||
return run;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user