Add generationStatus and match3d/runtime fixes

Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
This commit is contained in:
2026-05-16 22:59:02 +08:00
parent bb60ca91ef
commit a45e358e83
42 changed files with 3872 additions and 443 deletions

View File

@@ -52,6 +52,19 @@ import {
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
import { resolveGeometryAsset } from './match3dVisualAssets';
const runtimeAudioFeedback = vi.hoisted(() => ({
playRuntimeMergeSound: vi.fn(),
}));
vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../services/runtimeAudioFeedback')>();
return {
...actual,
playRuntimeMergeSound: runtimeAudioFeedback.playRuntimeMergeSound,
};
});
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
return {
@@ -82,6 +95,7 @@ afterEach(() => {
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
}
).__MATCH3D_KEEP_3D_TEST_RENDER__;
runtimeAudioFeedback.playRuntimeMergeSound.mockReset();
vi.restoreAllMocks();
});
@@ -519,6 +533,318 @@ test('运行态按生成素材的相对尺寸缩放场内和托盘图片', () =>
).toBe('scale(0.58)');
});
test('点击物品乐观插入到物品栏同类后面并后移后续物品', async () => {
const baseRun = startLocalMatch3DRun(3);
const [appleBoard, pearTray, appleTray] = baseRun.items.slice(0, 3);
expect(appleBoard && pearTray && appleTray).toBeTruthy();
const run: Match3DRunSnapshot = {
...baseRun,
items: [
{
...appleBoard!,
itemInstanceId: 'apple-3',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
clickable: true,
state: 'InBoard',
x: 0.5,
y: 0.5,
layer: 10,
traySlotIndex: null,
},
{
...pearTray!,
itemInstanceId: 'apple-1',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
clickable: false,
state: 'InTray',
traySlotIndex: 0,
},
{
...appleTray!,
itemInstanceId: 'apple-2',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
clickable: false,
state: 'InTray',
traySlotIndex: 1,
},
{
...baseRun.items[3]!,
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
clickable: false,
state: 'InTray',
traySlotIndex: 2,
},
...baseRun.items.slice(4).map((item) => ({
...item,
clickable: false,
state: 'InBoard' as const,
})),
],
traySlots: baseRun.traySlots.map((slot) => {
if (slot.slotIndex === 0) {
return {
slotIndex: 0,
itemInstanceId: 'apple-1',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
};
}
if (slot.slotIndex === 1) {
return {
slotIndex: 1,
itemInstanceId: 'apple-2',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
};
}
if (slot.slotIndex === 2) {
return {
slotIndex: 2,
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
};
}
return { slotIndex: slot.slotIndex };
}),
};
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
status: 'Accepted' as const,
acceptedItemInstanceId: payload.itemInstanceId,
clearedItemInstanceIds: [],
run: {
...run,
snapshotVersion: run.snapshotVersion + 1,
},
}));
const onOptimisticRunChange = vi.fn();
render(
<Match3DRuntimeShell
run={run}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
onClickItem={onClickItem}
/>,
);
const board = screen.getByTestId('match3d-board');
mockMatch3DBoardRect();
mockMatch3DPointerCapture(board);
Object.defineProperty(screen.getAllByTestId('match3d-tray-slot')[3]!, 'getBoundingClientRect', {
configurable: true,
value: () => ({
bottom: 530,
height: 56,
left: 220,
right: 276,
top: 474,
width: 56,
x: 220,
y: 474,
toJSON: () => ({}),
}),
});
const point = toMatch3DBoardClientPoint(run.items[0]!);
fireMatch3DBoardPointer(board, 'pointerdown', point, 14);
fireMatch3DBoardPointer(board, 'pointerup', point, 14);
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalled());
const optimisticRun = onOptimisticRunChange.mock.calls[0]?.[0] as
| Match3DRunSnapshot
| undefined;
expect(optimisticRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
'apple-1',
'apple-2',
'apple-3',
'pear-1',
null,
null,
null,
]);
expect(
optimisticRun?.items.find((item) => item.itemInstanceId === 'apple-3')
?.traySlotIndex,
).toBe(2);
});
test('三消确认后物品栏播放合成动画并隐藏权威快照中已清除的槽位', async () => {
const baseRun = startLocalMatch3DRun(1);
const [first, second, third, fourth] = baseRun.items.slice(0, 4);
expect(first && second && third).toBeTruthy();
const run: Match3DRunSnapshot = {
...baseRun,
totalItemCount: 4,
items: [
{
...first!,
itemInstanceId: 'apple-1',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
state: 'InTray',
clickable: false,
traySlotIndex: 0,
},
{
...second!,
itemInstanceId: 'apple-2',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
state: 'InTray',
clickable: false,
traySlotIndex: 1,
},
{
...third!,
itemInstanceId: 'apple-3',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
state: 'InBoard',
clickable: true,
x: 0.5,
y: 0.5,
layer: 10,
traySlotIndex: null,
},
{
...(fourth ?? third!),
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
state: 'InTray',
clickable: false,
traySlotIndex: 2,
},
],
traySlots: baseRun.traySlots.map((slot) => {
if (slot.slotIndex === 0) {
return {
slotIndex: 0,
itemInstanceId: 'apple-1',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
};
}
if (slot.slotIndex === 1) {
return {
slotIndex: 1,
itemInstanceId: 'apple-2',
itemTypeId: 'apple',
visualKey: 'block-red-2x4',
};
}
if (slot.slotIndex === 2) {
return {
slotIndex: 2,
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
};
}
return { slotIndex: slot.slotIndex };
}),
};
const acceptedRun: Match3DRunSnapshot = {
...run,
snapshotVersion: run.snapshotVersion + 1,
clearedItemCount: 3,
items: run.items.map((item) =>
item.itemTypeId === 'apple'
? {
...item,
state: 'Cleared' as const,
clickable: false,
traySlotIndex: null,
}
: { ...item, traySlotIndex: 0 },
),
traySlots: run.traySlots.map((slot) =>
slot.slotIndex === 0
? {
slotIndex: 0,
itemInstanceId: 'pear-1',
itemTypeId: 'pear',
visualKey: 'block-blue-1x2',
}
: { slotIndex: slot.slotIndex },
),
};
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
status: 'Accepted' as const,
acceptedItemInstanceId: payload.itemInstanceId,
clearedItemInstanceIds: ['apple-1', 'apple-2', 'apple-3'],
run: acceptedRun,
}));
const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => {
rerender(
<Match3DRuntimeShell
run={nextRun}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
onClickItem={onClickItem}
/>,
);
});
const { rerender } = render(
<Match3DRuntimeShell
run={run}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
onClickItem={onClickItem}
/>,
);
const board = screen.getByTestId('match3d-board');
mockMatch3DBoardRect();
mockMatch3DPointerCapture(board);
screen.getAllByTestId('match3d-tray-slot').forEach((slot, index) => {
Object.defineProperty(slot, 'getBoundingClientRect', {
configurable: true,
value: () => ({
bottom: 530,
height: 56,
left: 52 + index * 58,
right: 108 + index * 58,
top: 474,
width: 56,
x: 52 + index * 58,
y: 474,
toJSON: () => ({}),
}),
});
});
const point = toMatch3DBoardClientPoint(run.items[2]!);
fireMatch3DBoardPointer(board, 'pointerdown', point, 15);
fireMatch3DBoardPointer(board, 'pointerup', point, 15);
await waitFor(() =>
expect(screen.getByTestId('match3d-tray-clear-animation')).toBeTruthy(),
);
expect(screen.getAllByTestId('match3d-tray-clear-token')).toHaveLength(3);
expect(screen.getByTestId('match3d-merge-feedback')).toBeTruthy();
expect(screen.queryByTestId('match3d-merge-feedback')?.querySelector('svg')).toBeNull();
expect(runtimeAudioFeedback.playRuntimeMergeSound).toHaveBeenCalledTimes(1);
const latestRun = onOptimisticRunChange.mock.calls.at(-1)?.[0] as
| Match3DRunSnapshot
| undefined;
expect(latestRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
'pear-1',
null,
null,
null,
null,
null,
null,
]);
});
test('点击物品时播放飞入底部栏位动画并使用第一张物品视图', async () => {
const run = startLocalMatch3DRun(1);
const clickableItem = run.items.find((item) => item.clickable)!;
@@ -1025,9 +1351,10 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
const containerImage = screen.getByTestId(
'match3d-container-image',
) as HTMLImageElement;
expect(containerImage.className).toContain('w-[min(99vw,34rem)]');
expect(containerImage.className).toContain('w-[min(116vw,42rem)]');
expect(containerImage.className).toContain('h-auto');
expect(containerImage.className).toContain('left-1/2');
expect(containerImage.className).toContain('top-[54%]');
expect(containerImage.className).toContain('-translate-x-1/2');
expect(screen.getByTestId('match3d-board').className).toContain(
'bg-transparent',

View File

@@ -4,7 +4,6 @@ import {
Clock3,
RotateCcw,
Settings,
Sparkles,
XCircle,
} from 'lucide-react';
import {
@@ -12,6 +11,7 @@ import {
type PointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -30,20 +30,35 @@ import type {
} from '../../../packages/shared/src/contracts/match3dWorks';
import {
isGeneratedLegacyPath,
readAssetBytes,
resolveAssetReadUrl,
} from '../../services/assetReadUrlService';
import {
getMatch3DGeneratedImageViewSources,
normalizeMatch3DGeneratedItemAssetsForRuntime,
} from '../../services/match3dGeneratedModelCache';
import {
buildMatch3DTrayInsertionPlan,
resolveMatch3DTrayItemIdToSlotIndexMap,
syncMatch3DItemTraySlotIndexes,
} from '../../services/match3d-runtime/match3dTrayLayout';
import {
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
playRuntimeClickSound,
playRuntimeCountdownSound,
playRuntimeLevelClearSound,
playRuntimeMergeSound,
resolveRuntimeCountdownSecondBucket,
} from '../../services/runtimeAudioFeedback';
import { useAuthUi } from '../auth/AuthUiContext';
import {
findMatch3DHitItem,
type Match3DAlphaHitMask,
type Match3DGeneratedItemRelativeSize,
type Match3DResolvedImageSourceEntry,
resolveMatch3DImageSourceEntryForItem,
resolveMatch3DItemSizeScale,
} from './match3dHotspot';
import {
isItemState,
isRunState,
@@ -103,6 +118,19 @@ type Match3DBoardPoint = {
y: number;
};
type Match3DTraySlotLayout = {
left: number;
top: number;
width: number;
height: number;
};
type Match3DTrayMovingItemAnimation = {
itemInstanceId: string;
offsetX: number;
offsetY: number;
};
type Match3DFlyingTrayAnimation = {
id: string;
item: Match3DItemSnapshot;
@@ -116,7 +144,24 @@ type Match3DFlyingTrayAnimation = {
toSize: number;
};
type Match3DGeneratedItemRelativeSize = '大' | '中' | '小';
type Match3DTrayClearAnimation = {
id: string;
items: Array<{
itemInstanceId: string;
itemTypeId: string;
visualKey: string;
imageSrc: string;
itemSize: Match3DGeneratedItemRelativeSize;
fromX: number;
fromY: number;
toX: number;
toY: number;
width: number;
height: number;
}>;
centerX: number;
centerY: number;
};
function resolveTrayPreviewItem(
run: Match3DRunSnapshot,
@@ -168,26 +213,6 @@ function buildClientEventId(itemInstanceId: string) {
)}`;
}
function isPointInsideCircle(
pointX: number,
pointY: number,
item: Match3DItemSnapshot,
) {
const frame = resolveRenderableItemFrame(item);
return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius;
}
function findHitItem(run: Match3DRunSnapshot, pointX: number, pointY: number) {
return run.items
.filter(
(item) =>
isItemState(item.state, 'in_board') &&
item.clickable &&
isPointInsideCircle(pointX, pointY, item),
)
.sort((left, right) => right.layer - left.layer)[0];
}
function resolveBoardPointFromPointerEvent(
event: Pick<PointerEvent<HTMLDivElement>, 'clientX' | 'clientY'>,
stage: HTMLElement | null,
@@ -284,26 +309,47 @@ function resolveStaticMatch3DReadUrlMap(sources: readonly string[]) {
);
}
function buildResolvedMatch3DImageSourcesByType(
function buildResolvedMatch3DImageSourceEntriesByType(
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),
sources.flatMap((rawSource) => {
const source = rawSource.trim();
if (!source) {
return [];
}
const resolvedSource = resolvedImageSources.get(source);
if (resolvedSource) {
return [{ source, resolvedSource }];
}
return isGeneratedLegacyPath(source)
? []
: [{ source, resolvedSource: source }];
}),
]),
);
}
function resolveMatch3DAlphaHitMaskCacheKey(
imageSourceEntriesByType: ReadonlyMap<
string,
readonly Match3DResolvedImageSourceEntry[]
>,
) {
return [
...new Set(
[...imageSourceEntriesByType.values()].flatMap((entries) =>
entries.map((entry) => entry.source.trim()).filter(Boolean),
),
),
]
.sort()
.join('|');
}
function normalizeMatch3DGeneratedItemSize(
itemSize: Match3DGeneratedItemAsset['itemSize'] | null | undefined,
): Match3DGeneratedItemRelativeSize {
@@ -342,35 +388,17 @@ function buildMatch3DItemSizeByType(
);
}
function resolveMatch3DItemSizeScale(
itemSize: Match3DGeneratedItemRelativeSize | undefined,
) {
if (itemSize === '小') {
return 0.58;
}
if (itemSize === '中') {
return 0.78;
}
return 1;
}
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(
function resolveMatch3DResolvedImageForItem(
item: Match3DItemSnapshot,
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
imageSourceEntriesByType: ReadonlyMap<
string,
readonly Match3DResolvedImageSourceEntry[]
>,
) {
const sources = imageSourcesByType.get(item.itemTypeId);
if (!sources || sources.length <= 0) {
return '';
}
return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? '';
return (
resolveMatch3DImageSourceEntryForItem(item, imageSourceEntriesByType)
?.resolvedSource ?? ''
);
}
function hasPendingMatch3DGeneratedImageForItem(
@@ -391,13 +419,6 @@ function hasPendingMatch3DGeneratedImageForItem(
);
}
function resolveMatch3DFirstImageForItem(
item: Match3DItemSnapshot,
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
) {
return imageSourcesByType.get(item.itemTypeId)?.[0] ?? '';
}
function resolveMatch3DItemSizeForType(
item: Pick<Match3DItemSnapshot, 'itemTypeId'>,
itemSizeByType: ReadonlyMap<string, Match3DGeneratedItemRelativeSize>,
@@ -405,38 +426,106 @@ function resolveMatch3DItemSizeForType(
return itemSizeByType.get(item.itemTypeId) ?? '大';
}
function resolveMatch3DSlotLayout(
element: HTMLElement | null,
): Match3DTraySlotLayout | null {
const rect = element?.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) {
return null;
}
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
}
function buildOptimisticRun(
run: Match3DRunSnapshot,
item: Match3DItemSnapshot,
) {
const nextSlot = run.traySlots.find((slot) => !slot.itemInstanceId);
if (!nextSlot) {
const insertion = buildMatch3DTrayInsertionPlan(run.traySlots, item);
if (!insertion) {
return run;
}
const nextItems = run.items.map((entry) =>
entry.itemInstanceId === item.itemInstanceId
? {
...entry,
state: 'Flying' as const,
clickable: false,
traySlotIndex: insertion.slotIndex,
}
: entry,
);
return {
...run,
items: run.items.map((entry) =>
entry.itemInstanceId === item.itemInstanceId
? {
...entry,
state: 'Flying' as const,
clickable: false,
}
: entry,
),
traySlots: run.traySlots.map((slot) =>
slot.slotIndex === nextSlot.slotIndex
? {
slotIndex: slot.slotIndex,
itemInstanceId: item.itemInstanceId,
itemTypeId: item.itemTypeId,
visualKey: item.visualKey,
}
: slot,
),
items: syncMatch3DItemTraySlotIndexes(nextItems, insertion.traySlots),
traySlots: insertion.traySlots,
};
}
function loadMatch3DAlphaHitMaskImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('读取抓大鹅物品热区图片失败'));
image.src = source;
});
}
async function loadMatch3DAlphaHitMask(
source: string,
signal: AbortSignal,
): Promise<Match3DAlphaHitMask> {
const response = await readAssetBytes(source, {
signal,
expireSeconds: 300,
});
const blob = await response.blob();
const canCreateObjectUrl =
typeof URL.createObjectURL === 'function' &&
typeof URL.revokeObjectURL === 'function';
const imageSource = canCreateObjectUrl
? URL.createObjectURL(blob)
: await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ''));
reader.onerror = () => reject(new Error('读取抓大鹅热区图片失败'));
reader.readAsDataURL(blob);
});
try {
const image = await loadMatch3DAlphaHitMaskImage(imageSource);
if (signal.aborted) {
throw new DOMException('热区图片读取已取消', 'AbortError');
}
const width = Math.max(1, image.naturalWidth || image.width || 1);
const height = Math.max(1, image.naturalHeight || image.height || 1);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', {
willReadFrequently: true,
});
if (!context) {
throw new Error('浏览器不支持读取物品热区图片');
}
context.clearRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
const pixels = context.getImageData(0, 0, width, height).data;
const alpha = new Uint8ClampedArray(width * height);
for (let index = 0; index < alpha.length; index += 1) {
alpha[index] = pixels[index * 4 + 3] ?? 0;
}
return { width, height, alpha };
} finally {
if (canCreateObjectUrl) {
URL.revokeObjectURL(imageSource);
}
}
}
function Match3DToken({
item,
imageSrc,
@@ -514,11 +603,15 @@ function Match3DTrayToken({
imageSrc,
itemSize,
isArriving = false,
isClearing = false,
moveAnimation = null,
}: {
slot: Match3DTraySlot;
imageSrc?: string;
itemSize?: Match3DGeneratedItemRelativeSize;
isArriving?: boolean;
isClearing?: boolean;
moveAnimation?: Match3DTrayMovingItemAnimation | null;
}) {
if (!slot.visualKey) {
return (
@@ -526,11 +619,20 @@ function Match3DTrayToken({
);
}
const visualSeed = resolveVisualSeed(slot.visualKey);
const style = moveAnimation
? ({
'--match3d-tray-shift-x': `${moveAnimation.offsetX}px`,
'--match3d-tray-shift-y': `${moveAnimation.offsetY}px`,
} as CSSProperties)
: undefined;
return (
<span
className={`flex h-full w-full items-center justify-center p-1 transition-opacity duration-150 ${
isArriving ? 'opacity-0' : 'opacity-100'
moveAnimation ? 'match3d-tray-token-shift' : ''
} ${isArriving || isClearing ? 'opacity-0' : 'opacity-100'} ${
isClearing ? 'pointer-events-none' : ''
}`}
style={style}
aria-label={visualSeed.label}
>
{imageSrc ? (
@@ -603,6 +705,65 @@ function Match3DFlyingTrayToken({
);
}
function Match3DTrayClearToken({
animation,
onDone,
}: {
animation: Match3DTrayClearAnimation;
onDone: (id: string) => void;
}) {
return (
<div aria-hidden="true" data-testid="match3d-tray-clear-animation">
{animation.items.map((item, index) => {
const visualSeed = resolveVisualSeed(item.visualKey);
const style = {
'--match3d-tray-clear-dx': `${item.toX - item.fromX}px`,
'--match3d-tray-clear-dy': `${item.toY - item.fromY}px`,
height: `${item.height}px`,
left: `${item.fromX}px`,
top: `${item.fromY}px`,
width: `${item.width}px`,
} as CSSProperties;
return (
<div
key={item.itemInstanceId}
className="match3d-tray-token-clear pointer-events-none fixed z-[96] flex items-center justify-center p-1"
data-testid="match3d-tray-clear-token"
style={style}
onAnimationEnd={
index === animation.items.length - 1
? () => onDone(animation.id)
: undefined
}
>
{item.imageSrc ? (
<img
src={item.imageSrc}
alt=""
className="h-full w-full object-contain drop-shadow-[0_7px_11px_rgba(15,23,42,0.28)]"
style={{
transform: `scale(${resolveMatch3DItemSizeScale(item.itemSize)})`,
}}
draggable={false}
/>
) : (
<Match3DVisualIcon visualKey={item.visualKey} />
)}
<span className="sr-only">{visualSeed.label}</span>
</div>
);
})}
<div
className="match3d-tray-clear-flash pointer-events-none fixed z-[97]"
style={{
left: `${animation.centerX}px`,
top: `${animation.centerY}px`,
}}
/>
</div>
);
}
function Match3DSettlement({
run,
hideBackButton,
@@ -692,6 +853,7 @@ export function Match3DRuntimeShell({
const backgroundAudioRef = useRef<HTMLAudioElement | null>(null);
const clearSoundKeyRef = useRef<string | null>(null);
const countdownSoundKeyRef = useRef<string | null>(null);
const mergeSoundKeyRef = useRef<string | null>(null);
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
const [feedbackEvent, setFeedbackEvent] =
useState<Match3DFeedbackEvent | null>(null);
@@ -702,6 +864,15 @@ export function Match3DRuntimeShell({
const pendingClickLockRef = useRef(false);
const [flyingTrayAnimation, setFlyingTrayAnimation] =
useState<Match3DFlyingTrayAnimation | null>(null);
const [trayClearAnimation, setTrayClearAnimation] =
useState<Match3DTrayClearAnimation | null>(null);
const [trayMovingItemAnimations, setTrayMovingItemAnimations] = useState<
Match3DTrayMovingItemAnimation[]
>([]);
const previousTrayItemSlotIndexMapRef = useRef<Map<string, number>>(
new Map(),
);
const trayMovingTimeoutRef = useRef<number | null>(null);
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
const [resolvedBackgroundImageSrc, setResolvedBackgroundImageSrc] =
useState('');
@@ -753,6 +924,80 @@ export function Match3DRuntimeShell({
return () => window.clearTimeout(timer);
}, [flyingTrayAnimation]);
useEffect(() => {
if (!trayClearAnimation) {
return undefined;
}
const timer = window.setTimeout(() => {
setTrayClearAnimation((current) =>
current?.id === trayClearAnimation.id ? null : current,
);
}, 500);
return () => window.clearTimeout(timer);
}, [trayClearAnimation]);
useLayoutEffect(() => {
if (!run) {
previousTrayItemSlotIndexMapRef.current = new Map();
setTrayMovingItemAnimations((current) =>
current.length > 0 ? [] : current,
);
return;
}
const previousMap = previousTrayItemSlotIndexMapRef.current;
const nextMap = resolveMatch3DTrayItemIdToSlotIndexMap(run.traySlots);
const movingAnimations = [...nextMap.entries()].flatMap(
([itemInstanceId, nextSlotIndex]) => {
const previousSlotIndex = previousMap.get(itemInstanceId);
if (
previousSlotIndex === undefined ||
previousSlotIndex === nextSlotIndex
) {
return [];
}
const previousLayout = resolveMatch3DSlotLayout(
traySlotRefs.current[previousSlotIndex] ?? null,
);
const nextLayout = resolveMatch3DSlotLayout(
traySlotRefs.current[nextSlotIndex] ?? null,
);
if (!previousLayout || !nextLayout) {
return [];
}
return [
{
itemInstanceId,
offsetX: previousLayout.left - nextLayout.left,
offsetY: previousLayout.top - nextLayout.top,
},
];
},
);
previousTrayItemSlotIndexMapRef.current = nextMap;
if (movingAnimations.length <= 0) {
return;
}
setTrayMovingItemAnimations(movingAnimations);
if (trayMovingTimeoutRef.current !== null) {
window.clearTimeout(trayMovingTimeoutRef.current);
}
trayMovingTimeoutRef.current = window.setTimeout(() => {
setTrayMovingItemAnimations([]);
trayMovingTimeoutRef.current = null;
}, 260);
}, [run]);
useEffect(
() => () => {
if (trayMovingTimeoutRef.current !== null) {
window.clearTimeout(trayMovingTimeoutRef.current);
}
},
[],
);
useEffect(() => {
if (!run) {
clearSoundKeyRef.current = null;
@@ -845,14 +1090,24 @@ export function Match3DRuntimeShell({
const [failedImageSources, setFailedImageSources] = useState<Set<string>>(
() => new Set(),
);
const resolvedImageSourcesByType = useMemo(
const resolvedImageSourceEntriesByType = useMemo(
() =>
buildResolvedMatch3DImageSourcesByType(
buildResolvedMatch3DImageSourceEntriesByType(
imageSourcesByType,
resolvedImageSources,
),
[imageSourcesByType, resolvedImageSources],
);
const alphaHitMaskCacheKey = useMemo(
() => resolveMatch3DAlphaHitMaskCacheKey(resolvedImageSourceEntriesByType),
[resolvedImageSourceEntriesByType],
);
const [alphaHitMasks, setAlphaHitMasks] = useState<
Map<string, Match3DAlphaHitMask>
>(() => new Map());
const [failedAlphaHitMaskSources, setFailedAlphaHitMaskSources] = useState<
Set<string>
>(() => new Set());
const backgroundMusicSrc =
runtimeGeneratedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
?.backgroundMusic?.audioSrc ?? null;
@@ -1086,12 +1341,84 @@ export function Match3DRuntimeShell({
};
}, [imageReadUrlCacheKey, imageSourcesByType]);
useEffect(() => {
const rawSources = alphaHitMaskCacheKey
? alphaHitMaskCacheKey.split('|').filter(Boolean)
: [];
if (rawSources.length <= 0) {
setAlphaHitMasks((current) => (current.size > 0 ? new Map() : current));
setFailedAlphaHitMaskSources((current) =>
current.size > 0 ? new Set() : current,
);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
const sourceSet = new Set(rawSources);
const nextMasks = new Map<string, Match3DAlphaHitMask>();
const failedSources = new Set<string>();
setAlphaHitMasks((current) => {
const retained = new Map(
[...current.entries()].filter(([source]) => sourceSet.has(source)),
);
retained.forEach((mask, source) => nextMasks.set(source, mask));
return retained.size === current.size ? current : retained;
});
setFailedAlphaHitMaskSources(new Set());
void Promise.all(
rawSources.map(async (source) => {
if (nextMasks.has(source)) {
return;
}
try {
nextMasks.set(
source,
await loadMatch3DAlphaHitMask(source, controller.signal),
);
} catch {
// 中文注释:读不到 alpha 时保留旧圆形粗筛,避免个别跨域旧图导致物品完全不可点。
failedSources.add(source);
}
}),
).then(() => {
if (!cancelled) {
setAlphaHitMasks(new Map(nextMasks));
setFailedAlphaHitMaskSources(failedSources);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [alphaHitMaskCacheKey]);
const trayPreviewItems = useMemo(() => {
if (!run) {
return [];
}
return run.traySlots.map((slot) => resolveTrayPreviewItem(run, slot));
}, [run]);
const trayMovingItemAnimationById = useMemo(
() =>
new Map(
trayMovingItemAnimations.map((animation) => [
animation.itemInstanceId,
animation,
]),
),
[trayMovingItemAnimations],
);
const resolveFirstResolvedImageForItem = useCallback(
(item: Match3DItemSnapshot) => {
return resolvedImageSourceEntriesByType.get(item.itemTypeId)?.[0]
?.resolvedSource ?? '';
},
[resolvedImageSourceEntriesByType],
);
const startFlyingTrayAnimation = useCallback(
(
@@ -1120,10 +1447,7 @@ export function Match3DRuntimeShell({
setFlyingTrayAnimation({
id: animationId,
item,
imageSrc: resolveMatch3DFirstImageForItem(
item,
resolvedImageSourcesByType,
),
imageSrc: resolveFirstResolvedImageForItem(item),
itemSize: resolveMatch3DItemSizeForType(item, itemSizeByType),
fromSize,
fromX: boardRect.left + frame.x * boardRect.width,
@@ -1133,7 +1457,88 @@ export function Match3DRuntimeShell({
toY: slotRect.top + slotRect.height / 2,
});
},
[itemSizeByType, resolvedImageSourcesByType],
[itemSizeByType, resolveFirstResolvedImageForItem],
);
const startTrayClearAnimation = useCallback(
(
animationId: string,
clearedItemInstanceIds: readonly string[],
sourceRun: Match3DRunSnapshot,
) => {
if (clearedItemInstanceIds.length <= 0) {
return;
}
const slots = clearedItemInstanceIds
.map((itemInstanceId) =>
sourceRun.traySlots.find(
(slot) => slot.itemInstanceId === itemInstanceId,
),
)
.filter((slot): slot is Match3DTraySlot => Boolean(slot));
const layouts = slots
.map((slot) => ({
slot,
layout: resolveMatch3DSlotLayout(
traySlotRefs.current[slot.slotIndex] ?? null,
),
}))
.filter(
(entry): entry is { slot: Match3DTraySlot; layout: Match3DTraySlotLayout } =>
Boolean(entry.layout),
);
if (layouts.length <= 0) {
return;
}
const centerX =
layouts.reduce(
(sum, entry) => sum + entry.layout.left + entry.layout.width / 2,
0,
) / layouts.length;
const centerY =
layouts.reduce(
(sum, entry) => sum + entry.layout.top + entry.layout.height / 2,
0,
) / layouts.length;
setTrayClearAnimation({
id: animationId,
centerX,
centerY,
items: layouts.map(({ slot, layout }) => {
const item = sourceRun.items.find(
(entry) => entry.itemInstanceId === slot.itemInstanceId,
);
const itemTypeId = slot.itemTypeId ?? item?.itemTypeId ?? '';
const visualKey = slot.visualKey ?? item?.visualKey ?? '';
return {
itemInstanceId: slot.itemInstanceId ?? '',
itemTypeId,
visualKey,
imageSrc: item
? resolveFirstResolvedImageForItem(item)
: itemTypeId
? (resolvedImageSourceEntriesByType.get(itemTypeId)?.[0]
?.resolvedSource ?? '')
: '',
itemSize:
itemSizeByType.get(itemTypeId) ??
(item ? resolveMatch3DItemSizeForType(item, itemSizeByType) : '大'),
fromX: layout.left + layout.width / 2,
fromY: layout.top + layout.height / 2,
toX: centerX,
toY: centerY,
width: layout.width,
height: layout.height,
};
}),
});
},
[
itemSizeByType,
resolvedImageSourceEntriesByType,
resolveFirstResolvedImageForItem,
],
);
const handleItemClick = async (item: Match3DItemSnapshot) => {
@@ -1148,12 +1553,15 @@ export function Match3DRuntimeShell({
pendingClickLockRef.current = true;
const optimisticRun = buildOptimisticRun(run, item);
const clientEventId = buildClientEventId(item.itemInstanceId);
const targetSlot = run.traySlots.find((slot) => !slot.itemInstanceId);
const targetSlotIndex =
optimisticRun.items.find(
(entry) => entry.itemInstanceId === item.itemInstanceId,
)?.traySlotIndex ?? null;
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
tryPlayBackgroundMusic();
playClickSound(item);
if (targetSlot) {
startFlyingTrayAnimation(item, targetSlot.slotIndex, clientEventId);
if (targetSlotIndex !== null && targetSlotIndex !== undefined) {
startFlyingTrayAnimation(item, targetSlotIndex, clientEventId);
}
setPendingClick({
clientEventId,
@@ -1172,6 +1580,19 @@ export function Match3DRuntimeShell({
});
if (result.status === 'Accepted') {
if (result.clearedItemInstanceIds.length > 0) {
startTrayClearAnimation(
clientEventId,
result.clearedItemInstanceIds,
optimisticRun,
);
// 中文注释:普通三消播放合成音效;最终胜利局由结算音效接管,避免同一手势连播。
if (!isRunState(result.run.status, 'won')) {
const soundKey = `${result.run.runId}:${result.run.snapshotVersion}:merge`;
if (mergeSoundKeyRef.current !== soundKey) {
mergeSoundKeyRef.current = soundKey;
playRuntimeMergeSound(musicVolume);
}
}
setFeedbackEvent({
id: clientEventId,
kind: 'cleared',
@@ -1198,7 +1619,14 @@ export function Match3DRuntimeShell({
return null;
}
const point = resolveBoardPointFromPointerEvent(event, stageRef.current);
return point ? (findHitItem(run, point.x, point.y) ?? null) : null;
return point
? (findMatch3DHitItem(run, point.x, point.y, {
alphaHitMasks,
failedAlphaHitMaskSources,
imageSourceEntriesByType: resolvedImageSourceEntriesByType,
itemSizeByType,
}) ?? null)
: null;
};
const handleBoardPointerDown = (event: PointerEvent<HTMLDivElement>) => {
@@ -1377,9 +1805,9 @@ export function Match3DRuntimeShell({
<Match3DToken
key={item.itemInstanceId}
item={item}
imageSrc={resolveMatch3DImageForItem(
imageSrc={resolveMatch3DResolvedImageForItem(
item,
resolvedImageSourcesByType,
resolvedImageSourceEntriesByType,
)}
itemSize={resolveMatch3DItemSizeForType(item, itemSizeByType)}
disabled={Boolean(pendingClick)}
@@ -1389,9 +1817,10 @@ export function Match3DRuntimeShell({
)}
{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">
<Sparkles size={42} />
</div>
<div
className="match3d-merge-feedback-pulse h-24 w-24 rounded-full bg-white/20 shadow-[0_0_42px_rgba(255,255,255,0.62)] backdrop-blur-sm"
data-testid="match3d-merge-feedback"
/>
</div>
) : null}
</div>
@@ -1415,6 +1844,7 @@ export function Match3DRuntimeShell({
key={slot.slotIndex}
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
data-testid="match3d-tray-slot"
data-slot-index={slot.slotIndex}
ref={(element) => {
traySlotRefs.current[slot.slotIndex] = element;
}}
@@ -1425,12 +1855,24 @@ export function Match3DRuntimeShell({
flyingTrayAnimation?.item.itemInstanceId ===
slot.itemInstanceId
}
isClearing={
Boolean(slot.itemInstanceId) &&
(trayClearAnimation?.items.some(
(clearItem) =>
clearItem.itemInstanceId === slot.itemInstanceId,
) ??
false)
}
moveAnimation={
slot.itemInstanceId
? (trayMovingItemAnimationById.get(
slot.itemInstanceId,
) ?? null)
: null
}
imageSrc={
trayItem
? resolveMatch3DFirstImageForItem(
trayItem,
resolvedImageSourcesByType,
)
? resolveFirstResolvedImageForItem(trayItem)
: ''
}
itemSize={
@@ -1466,6 +1908,17 @@ export function Match3DRuntimeShell({
/>
) : null}
{trayClearAnimation ? (
<Match3DTrayClearToken
animation={trayClearAnimation}
onDone={(id) =>
setTrayClearAnimation((current) =>
current?.id === id ? null : current,
)
}
/>
) : null}
<Match3DSettlement
run={run}
hideBackButton={hideBackButton}

View File

@@ -0,0 +1,124 @@
import { expect, test } from 'vitest';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import {
findMatch3DHitItem,
isPointInsideMatch3DItemAlphaHotspot,
resolveMatch3DItemSizeScale,
type Match3DResolvedImageSourceEntry,
} from './match3dHotspot';
import { resolveRenderableItemFrame } from './match3dRuntimePresentation';
function buildMatch3DHotspotRun(): Match3DRunSnapshot {
return {
runId: 'hotspot-run',
profileId: 'hotspot-profile',
status: 'Running',
snapshotVersion: 1,
startedAtMs: 1,
durationLimitMs: 600_000,
remainingMs: 600_000,
clearCount: 1,
totalItemCount: 1,
clearedItemCount: 0,
traySlots: Array.from({ length: 7 }, (_, slotIndex) => ({ slotIndex })),
items: [
{
clickable: true,
itemInstanceId: 'alpha-hotspot-item',
itemTypeId: 'match3d-type-01',
visualKey: 'block-red-2x2',
x: 0.5,
y: 0.5,
radius: 0.1,
layer: 1,
state: 'InBoard',
},
],
};
}
test('透明像素不作为抓大鹅物品点击热区', () => {
const run = buildMatch3DHotspotRun();
const item = run.items[0]!;
const mask = {
width: 4,
height: 4,
alpha: new Uint8ClampedArray([
0, 0, 0, 0,
0, 255, 255, 0,
0, 255, 255, 0,
0, 0, 0, 0,
]),
};
const imageSourceEntriesByType = new Map<string, Match3DResolvedImageSourceEntry[]>([
[
'match3d-type-01',
[
{
source: '/generated-match3d-assets/item-01.png',
resolvedSource: 'https://oss.example.com/item-01.png',
},
],
],
]);
const alphaHitMasks = new Map([
['/generated-match3d-assets/item-01.png', mask],
]);
const itemSizeByType = new Map([['match3d-type-01', '大' as const]]);
const frame = resolveRenderableItemFrame(item);
expect(
findMatch3DHitItem(run, frame.x - frame.radius * 0.6, 0.5, {
alphaHitMasks,
imageSourceEntriesByType,
itemSizeByType,
}),
).toBeUndefined();
expect(
findMatch3DHitItem(run, 0.5, 0.5, {
alphaHitMasks,
imageSourceEntriesByType,
itemSizeByType,
})?.itemInstanceId,
).toBe('alpha-hotspot-item');
});
test('小尺寸物品只在缩放后的非透明主体内命中', () => {
const item = buildMatch3DHotspotRun().items[0]!;
const mask = {
width: 2,
height: 2,
alpha: new Uint8ClampedArray([255, 255, 255, 255]),
};
expect(
isPointInsideMatch3DItemAlphaHotspot({
item,
pointX: 0.5,
pointY: 0.5,
mask,
itemSize: '小',
}),
).toBe(true);
expect(
isPointInsideMatch3DItemAlphaHotspot({
item,
pointX: 0.38,
pointY: 0.5,
mask,
itemSize: '小',
}),
).toBe(false);
});
test('抓大鹅生成物品大和中也会做一定程度缩小', () => {
expect(resolveMatch3DItemSizeScale('大')).toBeLessThan(1);
expect(resolveMatch3DItemSizeScale('中')).toBeLessThan(0.78);
expect(resolveMatch3DItemSizeScale('大')).toBeGreaterThan(
resolveMatch3DItemSizeScale('中'),
);
expect(resolveMatch3DItemSizeScale('中')).toBeGreaterThan(
resolveMatch3DItemSizeScale('小'),
);
});

View File

@@ -0,0 +1,194 @@
import type {
Match3DItemSnapshot,
Match3DRunSnapshot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
import {
isItemState,
resolveRenderableItemFrame,
} from './match3dRuntimePresentation';
export type Match3DGeneratedItemRelativeSize = '大' | '中' | '小';
export type Match3DAlphaHitMask = {
width: number;
height: number;
alpha: Uint8ClampedArray;
};
export type Match3DResolvedImageSourceEntry = {
source: string;
resolvedSource: string;
};
const MATCH3D_HIT_ALPHA_THRESHOLD = 8;
function isPointInsideCircle(
pointX: number,
pointY: number,
item: Match3DItemSnapshot,
) {
const frame = resolveRenderableItemFrame(item);
return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius;
}
function clampMatch3DHitPixelIndex(value: number, size: number) {
return Math.min(size - 1, Math.max(0, Math.floor(value * size)));
}
export function resolveMatch3DItemSizeScale(
itemSize: Match3DGeneratedItemRelativeSize | undefined,
) {
if (itemSize === '小') {
return 0.58;
}
if (itemSize === '中') {
return 0.68;
}
return 0.88;
}
function isPointInsideAlphaHitMask(
localX: number,
localY: number,
mask: Match3DAlphaHitMask,
itemSize: Match3DGeneratedItemRelativeSize,
) {
if (
mask.width <= 0 ||
mask.height <= 0 ||
mask.alpha.length < mask.width * mask.height ||
localX < 0 ||
localX > 1 ||
localY < 0 ||
localY > 1
) {
return false;
}
const aspectRatio = mask.width / mask.height;
const containWidth = aspectRatio >= 1 ? 1 : aspectRatio;
const containHeight = aspectRatio >= 1 ? 1 / aspectRatio : 1;
const imageScale = resolveMatch3DItemSizeScale(itemSize);
const renderedWidth = containWidth * imageScale;
const renderedHeight = containHeight * imageScale;
const imageLeft = (1 - renderedWidth) / 2;
const imageTop = (1 - renderedHeight) / 2;
if (
localX < imageLeft ||
localX > imageLeft + renderedWidth ||
localY < imageTop ||
localY > imageTop + renderedHeight
) {
return false;
}
const imageX = (localX - imageLeft) / renderedWidth;
const imageY = (localY - imageTop) / renderedHeight;
const pixelX = clampMatch3DHitPixelIndex(imageX, mask.width);
const pixelY = clampMatch3DHitPixelIndex(imageY, mask.height);
return (
(mask.alpha[pixelY * mask.width + pixelX] ?? 0) >
MATCH3D_HIT_ALPHA_THRESHOLD
);
}
export function isPointInsideMatch3DItemAlphaHotspot({
item,
pointX,
pointY,
mask,
itemSize,
}: {
item: Match3DItemSnapshot;
pointX: number;
pointY: number;
mask: Match3DAlphaHitMask;
itemSize: Match3DGeneratedItemRelativeSize;
}) {
const frame = resolveRenderableItemFrame(item);
const diameter = frame.radius * 2;
if (diameter <= 0) {
return false;
}
return isPointInsideAlphaHitMask(
(pointX - (frame.x - frame.radius)) / diameter,
(pointY - (frame.y - frame.radius)) / diameter,
mask,
itemSize,
);
}
export 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;
}
export function resolveMatch3DImageSourceEntryForItem(
item: Match3DItemSnapshot,
imageSourceEntriesByType: ReadonlyMap<
string,
readonly Match3DResolvedImageSourceEntry[]
>,
) {
const sources = imageSourceEntriesByType.get(item.itemTypeId);
if (!sources || sources.length <= 0) {
return null;
}
return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? null;
}
export function findMatch3DHitItem(
run: Match3DRunSnapshot,
pointX: number,
pointY: number,
options: {
imageSourceEntriesByType?: ReadonlyMap<
string,
readonly Match3DResolvedImageSourceEntry[]
>;
alphaHitMasks?: ReadonlyMap<string, Match3DAlphaHitMask>;
failedAlphaHitMaskSources?: ReadonlySet<string>;
itemSizeByType?: ReadonlyMap<string, Match3DGeneratedItemRelativeSize>;
} = {},
) {
return run.items
.filter((item) => {
if (
!isItemState(item.state, 'in_board') ||
!item.clickable ||
!isPointInsideCircle(pointX, pointY, item)
) {
return false;
}
const imageSourceEntry = resolveMatch3DImageSourceEntryForItem(
item,
options.imageSourceEntriesByType ?? new Map(),
);
const mask = imageSourceEntry
? options.alphaHitMasks?.get(imageSourceEntry.source)
: null;
if (!mask) {
return (
!imageSourceEntry ||
!isGeneratedLegacyPath(imageSourceEntry.source) ||
options.failedAlphaHitMaskSources?.has(imageSourceEntry.source) ===
true
);
}
return isPointInsideMatch3DItemAlphaHotspot({
item,
pointX,
pointY,
mask,
itemSize: options.itemSizeByType?.get(item.itemTypeId) ?? '大',
});
})
.sort((left, right) => right.layer - left.layer)[0];
}

View File

@@ -27,7 +27,7 @@ export const MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS =
'relative z-0 h-14 min-w-0 rounded-xl border border-white/52 bg-white/56 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.44)] sm:h-16';
export const MATCH3D_RUNTIME_STAGE_CLASS =
'relative mt-3 flex min-h-0 flex-1 items-center justify-center';
'relative mt-5 flex min-h-0 flex-1 items-center justify-center';
export const MATCH3D_RUNTIME_BOARD_BASE_CLASS =
'relative aspect-square max-w-full';
@@ -41,7 +41,7 @@ export const MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS =
'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)]';
export const MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS =
'pointer-events-none absolute left-1/2 top-1/2 z-0 h-auto w-[min(99vw,34rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]';
'pointer-events-none absolute left-1/2 top-[54%] z-0 h-auto w-[min(116vw,42rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]';
export const MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS =
'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%)]';