Files
Genarrative/src/components/match3d-runtime/Match3DRuntimeShell.tsx
T
kdletters cfc0c0eadf 合入最新 master
合并 origin/master 并保留平台入口运行态与推荐链路语义
修正合并后基于 tab 语义变化的前端断言
2026-06-11 22:59:35 +08:00

2359 lines
71 KiB
TypeScript

import {
ArrowLeft,
CheckCircle2,
Clock,
XCircle,
} from 'lucide-react';
import {
type CSSProperties,
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import match3DRuntimeLevelLogo from '../../../media/logo-runtime-hud.webp';
import type {
Match3DClickItemRequest,
Match3DClickItemResult,
Match3DItemSnapshot,
Match3DRunSnapshot,
Match3DTraySlot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
} from '../../../packages/shared/src/contracts/match3dWorks';
import {
isGeneratedLegacyPath,
readAssetBytes,
resolveAssetReadUrl,
} from '../../services/assetReadUrlService';
import {
buildMatch3DTrayInsertionPlan,
resolveMatch3DTrayItemIdToSlotIndexMap,
syncMatch3DItemTraySlotIndexes,
} from '../../services/match3d-runtime/match3dTrayLayout';
import {
getMatch3DGeneratedImageViewSources,
normalizeMatch3DGeneratedItemAssetsForRuntime,
} from '../../services/match3dGeneratedModelCache';
import {
buildMatch3DItemSpritesheetViewRegions,
loadMatch3DSpritesheetAssetRegions,
type Match3DDecodedSpritesheetRegion,
} from '../../services/match3dSpritesheetParser';
import {
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
playRuntimeClickSound,
playRuntimeCountdownSound,
playRuntimeLevelClearSound,
playRuntimeMergeSound,
resolveRuntimeCountdownSecondBucket,
} from '../../services/runtimeAudioFeedback';
import { useAuthUi } from '../auth/AuthUiContext';
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
import {
findMatch3DHitItem,
type Match3DAlphaHitMask,
type Match3DGeneratedItemRelativeSize,
type Match3DResolvedImageSourceEntry,
resolveMatch3DImageSourceEntryForItem,
resolveMatch3DItemSizeScale,
} from './match3dHotspot';
import {
isItemState,
isRunState,
resolveRenderableItemFrame,
} from './match3dRuntimePresentation';
import {
MATCH3D_RUNTIME_BOARD_BASE_CLASS,
MATCH3D_RUNTIME_BOARD_WIDTH,
MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS,
MATCH3D_RUNTIME_STAGE_CLASS,
} from './match3dRuntimeUiStyles';
import { Match3DVisualIcon, resolveVisualSeed } from './match3dVisualAssets';
type Match3DRuntimeShellProps = {
run: Match3DRunSnapshot | null;
generatedItemAssets?: Match3DGeneratedItemAsset[];
generatedBackgroundAsset?: Match3DGeneratedBackgroundAsset | null;
backgroundImageSrc?: string | null;
isBusy?: boolean;
error?: string | null;
embedded?: boolean;
hideBackButton?: boolean;
levelName?: string | null;
onBack: () => void;
onRestart: () => void;
onOptimisticRunChange: (run: Match3DRunSnapshot) => void;
onClickItem: (
payload: Match3DClickItemRequest,
) => Promise<Match3DClickItemResult>;
onTimeExpired?: () => void;
};
type PendingClick = {
clientEventId: string;
itemInstanceId: string;
previousRun: Match3DRunSnapshot;
};
type Match3DFeedbackEvent = {
id: string;
kind: 'cleared' | 'rejected';
itemIds: string[];
};
type Match3DBoardPoint = {
x: number;
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;
imageSrc: string;
itemSize: Match3DGeneratedItemRelativeSize;
fromX: number;
fromY: number;
toX: number;
toY: number;
fromSize: number;
toSize: number;
};
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;
};
type Match3DItemSpritesheetViewGroup = {
itemIndex: number;
itemName: string;
regions: Match3DDecodedSpritesheetRegion[];
};
function resolveTrayPreviewItem(
run: Match3DRunSnapshot,
slot: Match3DTraySlot,
) {
if (!slot.itemInstanceId) {
return null;
}
const item = run.items.find(
(entry) => entry.itemInstanceId === slot.itemInstanceId,
);
if (!item) {
return null;
}
return {
...item,
itemTypeId: slot.itemTypeId ?? item.itemTypeId,
visualKey: slot.visualKey ?? item.visualKey,
};
}
const DEFAULT_MATCH3D_MUSIC_VOLUME = 0.42;
const EMPTY_MATCH3D_GENERATED_ITEM_ASSETS: Match3DGeneratedItemAsset[] = [];
const MATCH3D_CONTAINER_REFERENCE_SRC =
'/match3d-background-references/pot-fused-reference.png';
const MATCH3D_UI_SPRITESHEET_LABELS = [
'返回',
'设置',
'方格',
'移出',
'凑齐',
'打乱',
] as const;
const MATCH3D_PROP_BUTTONS = [
['移出', 'match3d-ui-sprite-prop-remove'],
['凑齐', 'match3d-ui-sprite-prop-collect'],
['打乱', 'match3d-ui-sprite-prop-shuffle'],
] as const;
function formatTimer(value: number) {
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
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);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function buildClientEventId(itemInstanceId: string) {
return `match3d-click-${itemInstanceId}-${Date.now()}-${Math.round(
Math.random() * 1_000_000,
)}`;
}
function resolveBoardPointFromPointerEvent(
event: Pick<PointerEvent<HTMLDivElement>, 'clientX' | 'clientY'>,
stage: HTMLElement | null,
): Match3DBoardPoint | null {
const rect = stage?.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) {
return null;
}
return {
x: (event.clientX - rect.left) / rect.width,
y: (event.clientY - rect.top) / rect.height,
};
}
function compareMatch3DGeneratedTypeId(left: string, right: string) {
const leftIndex = resolveMatch3DGeneratedItemIndex(left);
const rightIndex = resolveMatch3DGeneratedItemIndex(right);
if (leftIndex !== null && rightIndex !== null) {
return leftIndex - rightIndex;
}
return left.localeCompare(right);
}
function resolveMatch3DGeneratedTypeIds(run: Match3DRunSnapshot) {
return [...new Set(run.items.map((item) => item.itemTypeId.trim()))]
.filter(Boolean)
.sort(compareMatch3DGeneratedTypeId);
}
function resolveMatch3DGeneratedItemIndex(value: string | null | undefined) {
const parsed = Number.parseInt(
value?.match(/^match3d-(?:item|type)-0*(\d+)$/u)?.[1] ?? '',
10,
);
return Number.isFinite(parsed) && parsed > 0 ? parsed - 1 : null;
}
function buildMatch3DImageSourcesByType(
run: Match3DRunSnapshot | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
itemSpritesheetViewGroups: readonly Match3DItemSpritesheetViewGroup[] = [],
) {
if (!run) {
return new Map<string, string[]>();
}
const typeIds = resolveMatch3DGeneratedTypeIds(run);
const readyAssets = generatedItemAssets.flatMap((asset, fallbackIndex) => {
const sources = getMatch3DGeneratedImageViewSources(asset);
return sources.length > 0
? [
{
fallbackIndex,
itemIndex: resolveMatch3DGeneratedItemIndex(asset.itemId),
sources,
},
]
: [];
});
const parsedAssets = itemSpritesheetViewGroups.flatMap((group) => {
const sources = group.regions
.map((region) => region.imageSrc.trim())
.filter(Boolean);
return sources.length > 0
? [
{
fallbackIndex: group.itemIndex,
itemIndex: group.itemIndex,
sources,
},
]
: [];
});
return new Map(
typeIds.flatMap((typeId, index) => {
const directIndex = resolveMatch3DGeneratedItemIndex(typeId);
const asset =
readyAssets.find(
(entry) => directIndex !== null && entry.itemIndex === directIndex,
) ??
readyAssets.find((entry) => entry.fallbackIndex === index) ??
parsedAssets.find(
(entry) => directIndex !== null && entry.itemIndex === directIndex,
) ??
parsedAssets.find((entry) => entry.fallbackIndex === index);
return asset ? [[typeId, asset.sources] as const] : [];
}),
);
}
function resolveMatch3DUiSpritesheetSource(
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
generatedBackgroundAsset?.uiSpritesheetImageSrc?.trim() ||
generatedBackgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
''
);
}
function resolveMatch3DItemSpritesheetSource(
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
return (
generatedBackgroundAsset?.itemSpritesheetImageSrc?.trim() ||
generatedBackgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
generatedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
''
);
}
function resolveMatch3DGeneratedContainerSource(
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
const normalize = (value: string | null | undefined) =>
value?.trim().replace(/^\/+/u, '') ?? '';
const resolveAssetContainerSource = (
asset: Match3DGeneratedBackgroundAsset | null | undefined,
) => {
const containerSrc = asset?.containerImageSrc?.trim() || '';
const containerObjectKey = asset?.containerImageObjectKey?.trim() || '';
const uiSrc = asset?.uiSpritesheetImageSrc?.trim() || '';
const uiObjectKey = asset?.uiSpritesheetImageObjectKey?.trim() || '';
if (
normalize(containerSrc) &&
normalize(containerSrc) !== normalize(uiSrc)
) {
return containerSrc;
}
if (
normalize(containerObjectKey) &&
normalize(containerObjectKey) !== normalize(uiObjectKey)
) {
return containerObjectKey;
}
return '';
};
return (
resolveAssetContainerSource(generatedBackgroundAsset) ||
generatedItemAssets
.map((asset) => resolveAssetContainerSource(asset.backgroundAsset))
.find(Boolean) ||
''
);
}
function indexMatch3DUiSpritesheetRegions(
regions: readonly Match3DDecodedSpritesheetRegion[],
) {
return new Map(regions.map((region) => [region.label, region]));
}
function resolveMatch3DImageReadUrlSources(
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
) {
return [
...new Set(
[...imageSourcesByType.values()]
.flatMap((sources) => sources)
.map((source) => source.trim())
.filter(Boolean),
),
]
.sort()
.filter(Boolean);
}
function resolveStaticMatch3DReadUrlMap(sources: readonly string[]) {
return new Map(
sources.flatMap((source) =>
isGeneratedLegacyPath(source) ? [] : [[source, source] as const],
),
);
}
function buildResolvedMatch3DImageSourceEntriesByType(
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
resolvedImageSources: ReadonlyMap<string, string>,
) {
return new Map(
[...imageSourcesByType.entries()].map(([typeId, sources]) => [
typeId,
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 {
const normalized = String(itemSize ?? '').trim();
if (normalized === '小' || normalized.toLowerCase() === 'small') {
return '小';
}
if (normalized === '中' || normalized.toLowerCase() === 'medium') {
return '中';
}
return '大';
}
function buildMatch3DItemSizeByType(
run: Match3DRunSnapshot | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
if (!run) {
return new Map<string, Match3DGeneratedItemRelativeSize>();
}
const typeIds = resolveMatch3DGeneratedTypeIds(run);
const assets = generatedItemAssets.map((asset, fallbackIndex) => ({
fallbackIndex,
itemIndex: resolveMatch3DGeneratedItemIndex(asset.itemId),
itemSize: normalizeMatch3DGeneratedItemSize(asset.itemSize),
}));
return new Map(
typeIds.flatMap((typeId, index) => {
const directIndex = resolveMatch3DGeneratedItemIndex(typeId);
const asset =
assets.find(
(entry) => directIndex !== null && entry.itemIndex === directIndex,
) ?? assets.find((entry) => entry.fallbackIndex === index);
return asset ? [[typeId, asset.itemSize] as const] : [];
}),
);
}
function resolveMatch3DResolvedImageForItem(
item: Match3DItemSnapshot,
imageSourceEntriesByType: ReadonlyMap<
string,
readonly Match3DResolvedImageSourceEntry[]
>,
) {
return (
resolveMatch3DImageSourceEntryForItem(item, imageSourceEntriesByType)
?.resolvedSource ?? ''
);
}
function hasPendingMatch3DGeneratedImageForItem(
item: Match3DItemSnapshot,
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
resolvedImageSources: ReadonlyMap<string, string>,
failedImageSources: ReadonlySet<string>,
) {
const sources = imageSourcesByType.get(item.itemTypeId);
if (!sources?.length) {
return false;
}
return sources.some(
(source) =>
isGeneratedLegacyPath(source) &&
!resolvedImageSources.has(source) &&
!failedImageSources.has(source),
);
}
function resolveMatch3DItemSizeForType(
item: Pick<Match3DItemSnapshot, 'itemTypeId'>,
itemSizeByType: ReadonlyMap<string, Match3DGeneratedItemRelativeSize>,
) {
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 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: 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,
itemSize,
disabled,
selected,
}: {
item: Match3DItemSnapshot;
imageSrc?: string;
itemSize?: Match3DGeneratedItemRelativeSize;
disabled: boolean;
selected: boolean;
}) {
const visualSeed = resolveVisualSeed(item.visualKey);
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'
: 'opacity-48';
const selectedClass = selected
? 'scale-110 rounded-full bg-white/18 ring-4 ring-amber-100/85 drop-shadow-[0_0_22px_rgba(254,240,138,0.92)]'
: '';
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 bg-transparent p-0 transition-all duration-150 ${itemStateClass} ${selectedClass}`}
style={{
left: `${frame.x * 100}%`,
top: `${frame.y * 100}%`,
width: size,
height: size,
zIndex: item.layer + 10,
}}
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
aria-pressed={selected}
data-testid={`match3d-item-${item.itemInstanceId}`}
disabled={
disabled || !item.clickable || !isItemState(item.state, 'in_board')
}
>
{imageSrc ? (
<img
src={imageSrc}
alt=""
aria-hidden="true"
data-testid="match3d-token-image"
className="relative z-10 h-full w-full object-contain drop-shadow-[0_10px_14px_rgba(15,23,42,0.34)]"
style={{
transform: `scale(${resolveMatch3DItemSizeScale(itemSize)})`,
}}
draggable={false}
/>
) : (
<Match3DVisualIcon
visualKey={item.visualKey}
className="relative z-10"
/>
)}
</button>
);
}
function Match3DSpriteImage({
region,
testId,
className = 'h-full w-full object-contain',
}: {
region: Match3DDecodedSpritesheetRegion | null | undefined;
testId: string;
className?: string;
}) {
if (!region?.imageSrc) {
return null;
}
return (
<img
src={region.imageSrc}
alt=""
aria-hidden="true"
data-testid={testId}
className={className}
draggable={false}
/>
);
}
function Match3DTrayToken({
slot,
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 (
<span className="h-full w-full rounded-none border border-dashed border-white/18 bg-transparent" />
);
}
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 ${
moveAnimation ? 'match3d-tray-token-shift' : ''
} ${isArriving || isClearing ? 'opacity-0' : 'opacity-100'} ${
isClearing ? 'pointer-events-none' : ''
}`}
style={style}
aria-label={visualSeed.label}
>
{imageSrc ? (
<img
src={imageSrc}
alt=""
aria-hidden="true"
data-testid="match3d-tray-image"
className="h-full w-full object-contain drop-shadow-[0_5px_8px_rgba(15,23,42,0.26)]"
style={{
transform: `scale(${resolveMatch3DItemSizeScale(itemSize)})`,
}}
draggable={false}
/>
) : (
<Match3DVisualIcon visualKey={slot.visualKey} />
)}
</span>
);
}
function Match3DFlyingTrayToken({
animation,
onDone,
}: {
animation: Match3DFlyingTrayAnimation;
onDone: (id: string) => void;
}) {
const visualSeed = resolveVisualSeed(animation.item.visualKey);
const style = {
'--match3d-fly-dx': `${animation.toX - animation.fromX}px`,
'--match3d-fly-dy': `${animation.toY - animation.fromY}px`,
'--match3d-fly-scale': String(
Math.min(1.05, Math.max(0.42, animation.toSize / animation.fromSize)),
),
height: `${animation.fromSize}px`,
left: `${animation.fromX}px`,
top: `${animation.fromY}px`,
width: `${animation.fromSize}px`,
} as CSSProperties;
return (
<div
className="match3d-token-fly-to-tray pointer-events-none fixed z-[95] flex items-center justify-center"
style={style}
aria-hidden="true"
data-testid="match3d-flying-token"
onAnimationEnd={() => onDone(animation.id)}
>
{animation.imageSrc ? (
<img
src={animation.imageSrc}
alt=""
className="h-full w-full object-contain drop-shadow-[0_12px_18px_rgba(15,23,42,0.32)]"
style={{
transform: `scale(${resolveMatch3DItemSizeScale(animation.itemSize)})`,
}}
data-testid="match3d-flying-token-image"
draggable={false}
/>
) : (
<Match3DVisualIcon
visualKey={animation.item.visualKey}
className="relative z-10"
/>
)}
<span className="absolute inset-[12%] -z-10 rounded-full bg-white/24 blur-md" />
<span className="sr-only">{visualSeed.label}</span>
</div>
);
}
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,
onBack,
onRestart,
}: {
run: Match3DRunSnapshot;
hideBackButton?: boolean;
onBack: () => void;
onRestart: () => void;
}) {
if (isRunState(run.status, 'running')) {
return null;
}
const won = isRunState(run.status, 'won');
const stopped = isRunState(run.status, 'stopped');
const title = won ? '通关完成' : stopped ? '已停止' : '本轮失败';
const description = won
? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}`
: `已清除 ${run.clearedItemCount}/${run.totalItemCount}`;
return (
<Match3DRuntimeResultModalShell
title={title}
description={description}
tone={won ? 'success' : 'danger'}
icon={won ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
footer={
<Match3DSettlementActions
hideBackButton={hideBackButton}
onBack={onBack}
onRestart={onRestart}
/>
}
data-testid="match3d-runtime-settlement-modal"
/>
);
}
type Match3DRuntimeResultModalTone = 'success' | 'danger';
type Match3DRuntimeResultModalShellProps = {
title: string;
description: string;
tone: Match3DRuntimeResultModalTone;
icon: ReactNode;
footer: ReactNode;
'data-testid'?: string;
};
const MATCH3D_RUNTIME_RESULT_ICON_CLASS: Record<
Match3DRuntimeResultModalTone,
string
> = {
success: 'bg-emerald-100 text-emerald-700',
danger: 'bg-rose-100 text-rose-700',
};
// 中文注释:运行态结算弹窗保留抓大鹅的白底品牌质感,只把遮罩、面板和 footer 骨架集中到本文件内复用。
function Match3DRuntimeResultModalShell({
title,
description,
tone,
icon,
footer,
'data-testid': testId,
}: Match3DRuntimeResultModalShellProps) {
return (
<div
className="absolute inset-0 z-[80] flex items-center justify-center bg-slate-950/62 px-5 backdrop-blur-sm"
data-testid="match3d-runtime-modal-overlay"
>
<section
className="w-full max-w-sm rounded-[1.5rem] border border-white/18 bg-white/94 p-5 text-slate-950 shadow-[0_26px_70px_rgba(15,23,42,0.34)]"
role="dialog"
aria-modal="true"
aria-label={title}
data-testid={testId}
>
<div className="mb-4 flex items-center gap-3">
<span
className={`flex h-11 w-11 items-center justify-center rounded-full ${MATCH3D_RUNTIME_RESULT_ICON_CLASS[tone]}`}
aria-hidden="true"
>
{icon}
</span>
<div>
<h2 className="text-xl font-black">{title}</h2>
<p className="text-sm font-semibold text-slate-500">
{description}
</p>
</div>
</div>
{footer}
</section>
</div>
);
}
function Match3DSettlementActions({
hideBackButton,
onBack,
onRestart,
}: {
hideBackButton?: boolean;
onBack: () => void;
onRestart: () => void;
}) {
return (
<div
className={`grid gap-2 ${hideBackButton ? '' : 'grid-cols-2'}`}
data-testid="match3d-runtime-settlement-actions"
>
{!hideBackButton ? (
<Match3DSettlementActionButton variant="secondary" onClick={onBack}>
返回
</Match3DSettlementActionButton>
) : null}
<Match3DSettlementActionButton variant="primary" onClick={onRestart}>
再来一局
</Match3DSettlementActionButton>
</div>
);
}
function Match3DSettlementActionButton({
variant,
onClick,
children,
}: {
variant: 'primary' | 'secondary';
onClick: () => void;
children: ReactNode;
}) {
const className =
variant === 'primary'
? 'rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white'
: 'rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-black text-slate-700';
return (
<button type="button" className={className} onClick={onClick}>
{children}
</button>
);
}
export function Match3DRuntimeShell({
run,
generatedItemAssets = EMPTY_MATCH3D_GENERATED_ITEM_ASSETS,
generatedBackgroundAsset = null,
backgroundImageSrc = null,
isBusy = false,
error = null,
embedded = false,
hideBackButton = false,
levelName = null,
onBack,
onRestart,
onOptimisticRunChange,
onClickItem,
onTimeExpired,
}: Match3DRuntimeShellProps) {
const authUi = useAuthUi();
const stageRef = useRef<HTMLDivElement | null>(null);
const traySlotRefs = useRef<Array<HTMLDivElement | null>>([]);
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);
const [pressedItemInstanceId, setPressedItemInstanceId] = useState<
string | null
>(null);
const activePointerIdRef = useRef<number | null>(null);
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('');
const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
const runtimeGeneratedItemAssets = useMemo(
() => normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
[generatedItemAssets],
);
const uiSpritesheetSource = useMemo(
() =>
resolveMatch3DUiSpritesheetSource(
generatedBackgroundAsset,
runtimeGeneratedItemAssets,
),
[generatedBackgroundAsset, runtimeGeneratedItemAssets],
);
const itemSpritesheetSource = useMemo(
() =>
resolveMatch3DItemSpritesheetSource(
generatedBackgroundAsset,
runtimeGeneratedItemAssets,
),
[generatedBackgroundAsset, runtimeGeneratedItemAssets],
);
const [uiSpritesheetRegions, setUiSpritesheetRegions] = useState<
Match3DDecodedSpritesheetRegion[]
>([]);
const [itemSpritesheetViewGroups, setItemSpritesheetViewGroups] = useState<
Match3DItemSpritesheetViewGroup[]
>([]);
const [isUiSpritesheetResolving, setIsUiSpritesheetResolving] =
useState(false);
const [isItemSpritesheetResolving, setIsItemSpritesheetResolving] =
useState(false);
const uiSpritesheetRegionByLabel = useMemo(
() => indexMatch3DUiSpritesheetRegions(uiSpritesheetRegions),
[uiSpritesheetRegions],
);
useEffect(() => {
setTimeLeftMs(run?.remainingMs ?? 0);
}, [run?.remainingMs, run?.snapshotVersion]);
useEffect(() => {
if (!run || !isRunState(run.status, 'running')) {
return undefined;
}
const timer = window.setInterval(() => {
setTimeLeftMs((current) => {
const next = Math.max(0, current - 1000);
if (next <= 0) {
onTimeExpired?.();
}
return next;
});
}, 1000);
return () => window.clearInterval(timer);
}, [onTimeExpired, run]);
useEffect(() => {
if (!uiSpritesheetSource) {
setUiSpritesheetRegions((current) =>
current.length > 0 ? [] : current,
);
setIsUiSpritesheetResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setIsUiSpritesheetResolving(true);
void loadMatch3DSpritesheetAssetRegions({
source: uiSpritesheetSource,
labels: MATCH3D_UI_SPRITESHEET_LABELS,
maxRegions: MATCH3D_UI_SPRITESHEET_LABELS.length,
minArea: 16,
alphaThreshold: 8,
signal: controller.signal,
})
.then((regions) => {
if (!cancelled) {
setUiSpritesheetRegions(regions);
setIsUiSpritesheetResolving(false);
}
})
.catch(() => {
if (!cancelled) {
setUiSpritesheetRegions([]);
setIsUiSpritesheetResolving(false);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [uiSpritesheetSource]);
useEffect(() => {
if (!itemSpritesheetSource) {
setItemSpritesheetViewGroups((current) =>
current.length > 0 ? [] : current,
);
setIsItemSpritesheetResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setIsItemSpritesheetResolving(true);
void loadMatch3DSpritesheetAssetRegions({
source: itemSpritesheetSource,
maxRegions: 100,
minArea: 16,
alphaThreshold: 8,
signal: controller.signal,
})
.then((regions) => {
if (!cancelled) {
setItemSpritesheetViewGroups(
buildMatch3DItemSpritesheetViewRegions(
regions,
runtimeGeneratedItemAssets.map((asset) => asset.itemName),
),
);
setIsItemSpritesheetResolving(false);
}
})
.catch(() => {
if (!cancelled) {
setItemSpritesheetViewGroups([]);
setIsItemSpritesheetResolving(false);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [itemSpritesheetSource, runtimeGeneratedItemAssets]);
useEffect(() => {
if (!feedbackEvent) {
return undefined;
}
const timer = window.setTimeout(() => setFeedbackEvent(null), 520);
return () => window.clearTimeout(timer);
}, [feedbackEvent]);
useEffect(() => {
if (!flyingTrayAnimation) {
return undefined;
}
const timer = window.setTimeout(() => {
setFlyingTrayAnimation((current) =>
current?.id === flyingTrayAnimation.id ? null : current,
);
}, 520);
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;
return;
}
if (!isRunState(run.status, 'won')) {
return;
}
const soundKey = `${run.runId}:${run.snapshotVersion}:won`;
if (clearSoundKeyRef.current === soundKey) {
return;
}
clearSoundKeyRef.current = soundKey;
playRuntimeLevelClearSound(musicVolume);
}, [musicVolume, run, run?.runId, run?.snapshotVersion, run?.status]);
useEffect(() => {
if (!run || !isRunState(run.status, 'running')) {
countdownSoundKeyRef.current = null;
return;
}
const secondBucket =
timeLeftMs <= levelAudioConfig.countdownWarningThresholdMs
? resolveRuntimeCountdownSecondBucket(timeLeftMs)
: null;
if (secondBucket === null) {
countdownSoundKeyRef.current = null;
return;
}
const soundKey = `${run.runId}:${run.startedAtMs}:${secondBucket}`;
if (countdownSoundKeyRef.current === soundKey) {
return;
}
countdownSoundKeyRef.current = soundKey;
playRuntimeCountdownSound(musicVolume);
}, [
levelAudioConfig.countdownWarningThresholdMs,
musicVolume,
run,
run?.runId,
run?.startedAtMs,
run?.status,
timeLeftMs,
]);
const backgroundAssetSrc =
backgroundImageSrc?.trim() ||
generatedBackgroundAsset?.imageSrc?.trim() ||
generatedBackgroundAsset?.imageObjectKey?.trim() ||
runtimeGeneratedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
'';
const generatedContainerAssetSrc = resolveMatch3DGeneratedContainerSource(
generatedBackgroundAsset,
runtimeGeneratedItemAssets,
);
const containerAssetSrc =
generatedContainerAssetSrc || MATCH3D_CONTAINER_REFERENCE_SRC;
const imageSourcesByType = useMemo(
() =>
buildMatch3DImageSourcesByType(
run,
runtimeGeneratedItemAssets,
itemSpritesheetViewGroups,
),
[itemSpritesheetViewGroups, runtimeGeneratedItemAssets, run],
);
const imageReadUrlSources = useMemo(
() => resolveMatch3DImageReadUrlSources(imageSourcesByType),
[imageSourcesByType],
);
const itemSizeByType = useMemo(
() => buildMatch3DItemSizeByType(run, runtimeGeneratedItemAssets),
[runtimeGeneratedItemAssets, run],
);
const imageReadUrlCacheKey = useMemo(
() => imageReadUrlSources.join('|'),
[imageReadUrlSources],
);
const [resolvedImageSources, setResolvedImageSources] = useState<
Map<string, string>
>(() => new Map());
const [failedImageSources, setFailedImageSources] = useState<Set<string>>(
() => new Set(),
);
const [isImageSourcesResolving, setIsImageSourcesResolving] =
useState(false);
const resolvedImageSourceEntriesByType = useMemo(
() =>
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;
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] =
useState('');
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] =
useState('');
const [isBackgroundMusicResolving, setIsBackgroundMusicResolving] =
useState(false);
const [isBackgroundImageResolving, setIsBackgroundImageResolving] =
useState(false);
const [isContainerImageResolving, setIsContainerImageResolving] =
useState(false);
const clickSoundByTypeId = useMemo(() => {
if (!run) {
return new Map<string, string>();
}
const readyAssets = runtimeGeneratedItemAssets.filter(
(asset) => asset.clickSound?.audioSrc,
);
const sortedTypes = [
...new Set(run.items.map((item) => item.itemTypeId)),
].sort();
return new Map(
sortedTypes.flatMap((typeId, index) => {
const src = readyAssets[index]?.clickSound?.audioSrc?.trim();
return src ? [[typeId, src] as const] : [];
}),
);
}, [runtimeGeneratedItemAssets, run]);
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
if (
!audio ||
!resolvedBackgroundMusicSrc ||
!run ||
!isRunState(run.status, 'running')
) {
if (audio) {
audio.pause();
}
return;
}
audio.volume = Math.max(0, Math.min(1, musicVolume));
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, run]);
useEffect(() => {
tryPlayBackgroundMusic();
}, [tryPlayBackgroundMusic]);
useEffect(() => {
const source = backgroundMusicSrc?.trim() ?? '';
if (!source) {
setResolvedBackgroundMusicSrc('');
setIsBackgroundMusicResolving(false);
return undefined;
}
if (!isGeneratedLegacyPath(source)) {
setResolvedBackgroundMusicSrc(source);
setIsBackgroundMusicResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setResolvedBackgroundMusicSrc('');
setIsBackgroundMusicResolving(true);
void resolveAssetReadUrl(source, {
signal: controller.signal,
expireSeconds: 300,
})
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedBackgroundMusicSrc(resolvedSrc);
}
})
.catch(() => {
if (!cancelled) {
setResolvedBackgroundMusicSrc('');
}
})
.finally(() => {
if (!cancelled) {
setIsBackgroundMusicResolving(false);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [backgroundMusicSrc]);
const playClickSound = useCallback(
(item: Match3DItemSnapshot) => {
const src = clickSoundByTypeId.get(item.itemTypeId);
playRuntimeClickSound(src, musicVolume);
},
[clickSoundByTypeId, musicVolume],
);
useEffect(() => {
if (!backgroundAssetSrc) {
setResolvedBackgroundImageSrc('');
setIsBackgroundImageResolving(false);
return undefined;
}
if (!isGeneratedLegacyPath(backgroundAssetSrc)) {
setResolvedBackgroundImageSrc(backgroundAssetSrc);
setIsBackgroundImageResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setResolvedBackgroundImageSrc('');
setIsBackgroundImageResolving(true);
void resolveAssetReadUrl(backgroundAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
})
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedBackgroundImageSrc(resolvedSrc);
}
})
.catch(() => {
if (!cancelled) {
setResolvedBackgroundImageSrc('');
}
})
.finally(() => {
if (!cancelled) {
setIsBackgroundImageResolving(false);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [backgroundAssetSrc]);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
setResolvedContainerImageSrc('');
if (!isGeneratedLegacyPath(containerAssetSrc)) {
setResolvedContainerImageSrc(containerAssetSrc);
setIsContainerImageResolving(false);
return undefined;
}
setIsContainerImageResolving(true);
void resolveAssetReadUrl(containerAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
})
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedContainerImageSrc(resolvedSrc);
}
})
.catch(() => {
if (!cancelled) {
setResolvedContainerImageSrc(
containerAssetSrc === MATCH3D_CONTAINER_REFERENCE_SRC
? ''
: MATCH3D_CONTAINER_REFERENCE_SRC,
);
}
})
.finally(() => {
if (!cancelled) {
setIsContainerImageResolving(false);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [containerAssetSrc]);
useEffect(() => {
const rawSources = imageReadUrlSources;
if (rawSources.length <= 0) {
setResolvedImageSources((current) =>
current.size > 0 ? new Map() : current,
);
setFailedImageSources((current) =>
current.size > 0 ? new Set() : current,
);
setIsImageSourcesResolving(false);
return undefined;
}
if (rawSources.every((source) => !isGeneratedLegacyPath(source))) {
setResolvedImageSources(resolveStaticMatch3DReadUrlMap(rawSources));
setFailedImageSources((current) =>
current.size > 0 ? new Set() : current,
);
setIsImageSourcesResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
const sourceSet = new Set(rawSources);
const nextSources = new Map<string, string>();
const failedSources = new Set<string>();
setResolvedImageSources((current) => {
const retained = new Map(
[...current.entries()].filter(([source]) => sourceSet.has(source)),
);
retained.forEach((value, source) => nextSources.set(source, value));
return retained;
});
setFailedImageSources(new Set());
setIsImageSourcesResolving(true);
void Promise.all(
rawSources.map(async (source) => {
if (nextSources.has(source)) {
return;
}
if (!isGeneratedLegacyPath(source)) {
nextSources.set(source, source);
return;
}
try {
const resolvedSource = await resolveAssetReadUrl(source, {
signal: controller.signal,
expireSeconds: 300,
});
nextSources.set(source, resolvedSource || source);
} catch {
failedSources.add(source);
}
}),
)
.then(() => {
if (!cancelled) {
setResolvedImageSources(nextSources);
setFailedImageSources(failedSources);
}
})
.catch(() => {
if (!cancelled) {
setFailedImageSources(new Set(rawSources));
}
})
.finally(() => {
if (!cancelled) {
setIsImageSourcesResolving(false);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [imageReadUrlCacheKey, imageReadUrlSources]);
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(
(
item: Match3DItemSnapshot,
targetSlotIndex: number,
animationId: string,
) => {
const boardRect = stageRef.current?.getBoundingClientRect();
const slotRect =
traySlotRefs.current[targetSlotIndex]?.getBoundingClientRect();
if (
!boardRect ||
!slotRect ||
boardRect.width <= 0 ||
slotRect.width <= 0
) {
return;
}
const frame = resolveRenderableItemFrame(item);
const fromSize = Math.max(28, frame.radius * boardRect.width * 2);
const toSize = Math.max(
24,
Math.min(slotRect.width, slotRect.height) * 0.78,
);
setFlyingTrayAnimation({
id: animationId,
item,
imageSrc: resolveFirstResolvedImageForItem(item),
itemSize: resolveMatch3DItemSizeForType(item, itemSizeByType),
fromSize,
fromX: boardRect.left + frame.x * boardRect.width,
fromY: boardRect.top + frame.y * boardRect.height,
toSize,
toX: slotRect.left + slotRect.width / 2,
toY: slotRect.top + slotRect.height / 2,
});
},
[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) => {
if (
!run ||
!isRunState(run.status, 'running') ||
pendingClick ||
pendingClickLockRef.current
) {
return;
}
pendingClickLockRef.current = true;
const optimisticRun = buildOptimisticRun(run, item);
const clientEventId = buildClientEventId(item.itemInstanceId);
const targetSlotIndex =
optimisticRun.items.find(
(entry) => entry.itemInstanceId === item.itemInstanceId,
)?.traySlotIndex ?? null;
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
tryPlayBackgroundMusic();
playClickSound(item);
if (targetSlotIndex !== null && targetSlotIndex !== undefined) {
startFlyingTrayAnimation(item, targetSlotIndex, clientEventId);
}
setPendingClick({
clientEventId,
itemInstanceId: item.itemInstanceId,
previousRun: run,
});
onOptimisticRunChange(optimisticRun);
try {
const result = await onClickItem({
runId: run.runId,
itemInstanceId: item.itemInstanceId,
clientSnapshotVersion: run.snapshotVersion,
clientEventId,
clickedAtMs: Date.now(),
});
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',
itemIds: result.clearedItemInstanceIds,
});
}
onOptimisticRunChange(result.run);
} else {
setFeedbackEvent({
id: clientEventId,
kind: 'rejected',
itemIds: [item.itemInstanceId],
});
onOptimisticRunChange(result.run ?? run);
}
} finally {
pendingClickLockRef.current = false;
setPendingClick(null);
}
};
const resolvePointerCandidate = (event: PointerEvent<HTMLDivElement>) => {
if (!run || !isRunState(run.status, 'running') || pendingClick) {
return null;
}
const point = resolveBoardPointFromPointerEvent(event, stageRef.current);
return point
? (findMatch3DHitItem(run, point.x, point.y, {
alphaHitMasks,
failedAlphaHitMaskSources,
imageSourceEntriesByType: resolvedImageSourceEntriesByType,
itemSizeByType,
}) ?? null)
: null;
};
const handleBoardPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!run || !isRunState(run.status, 'running') || pendingClick) {
setPressedItemInstanceId(null);
return;
}
activePointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture?.(event.pointerId);
setPressedItemInstanceId(
resolvePointerCandidate(event)?.itemInstanceId ?? null,
);
};
const handleBoardPointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (activePointerIdRef.current !== event.pointerId) {
return;
}
setPressedItemInstanceId(
resolvePointerCandidate(event)?.itemInstanceId ?? null,
);
};
const handleBoardPointerCancel = (event: PointerEvent<HTMLDivElement>) => {
if (activePointerIdRef.current !== event.pointerId) {
return;
}
activePointerIdRef.current = null;
setPressedItemInstanceId(null);
event.currentTarget.releasePointerCapture?.(event.pointerId);
};
const handleBoardPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (activePointerIdRef.current !== event.pointerId) {
return;
}
const item = resolvePointerCandidate(event);
activePointerIdRef.current = null;
setPressedItemInstanceId(null);
event.currentTarget.releasePointerCapture?.(event.pointerId);
if (item) {
void handleItemClick(item);
}
};
if (!run) {
return (
<div
className={`flex ${embedded ? 'h-full min-h-0 w-full' : 'min-h-dvh'} items-center justify-center bg-slate-950 text-white`}
>
{isBusy ? '载入中' : (error ?? '暂无运行态')}
</div>
);
}
const displayLevelName = levelName?.trim() || '抓大鹅';
const timerClassName =
timeLeftMs <= levelAudioConfig.countdownWarningThresholdMs &&
isRunState(run.status, 'running')
? 'puzzle-runtime-timer--urgent'
: 'puzzle-runtime-timer';
return (
<main
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#16221f] text-white`}
>
<RuntimeResourcePendingMarker
source={backgroundMusicSrc}
kind="audio"
isPending={isBackgroundMusicResolving}
/>
<RuntimeResourcePendingMarker
source={backgroundAssetSrc}
kind="image"
isPending={isBackgroundImageResolving}
/>
<RuntimeResourcePendingMarker
source={containerAssetSrc}
kind="image"
isPending={isContainerImageResolving}
/>
<RuntimeResourcePendingMarker
source={uiSpritesheetSource}
kind="image"
isPending={isUiSpritesheetResolving}
/>
<RuntimeResourcePendingMarker
source={itemSpritesheetSource}
kind="image"
isPending={isItemSpritesheetResolving}
/>
{imageReadUrlSources.map((source) => (
<RuntimeResourcePendingMarker
key={`match3d-runtime-resource:${source}`}
source={source}
kind="image"
isPending={
isImageSourcesResolving &&
isGeneratedLegacyPath(source) &&
!resolvedImageSources.has(source) &&
!failedImageSources.has(source)
}
/>
))}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
{resolvedBackgroundImageSrc ? (
<img
src={resolvedBackgroundImageSrc}
alt=""
aria-hidden="true"
data-testid="match3d-background-image"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : null}
{resolvedBackgroundMusicSrc ? (
<audio
ref={backgroundAudioRef}
src={resolvedBackgroundMusicSrc}
loop
preload="auto"
aria-label="抓大鹅背景音乐"
/>
) : null}
<div
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]`}
style={{
boxSizing: 'border-box',
maxWidth: '100vw',
width: 'min(100vw, 28rem)',
}}
>
<header className="relative z-10 grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-start gap-2 sm:grid-cols-[2.75rem_minmax(0,1fr)_2.75rem]">
{hideBackButton ? (
<div aria-hidden="true" />
) : (
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-transparent bg-transparent text-white shadow-none transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30 sm:h-11 sm:w-11"
onClick={onBack}
aria-label="返回"
>
<Match3DSpriteImage
region={uiSpritesheetRegionByLabel.get('返回')}
testId="match3d-ui-sprite-back"
className="h-7 w-7 object-contain"
/>
{!uiSpritesheetRegionByLabel.get('返回') ? (
<ArrowLeft size={20} />
) : null}
</button>
)}
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(18.5rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center text-center sm:max-w-[22rem]">
<div className="puzzle-runtime-level-title-card flex max-w-full items-center justify-center gap-2 px-3.5 py-1.5 pr-4 sm:px-4 sm:pr-5">
<span aria-hidden="true" className="puzzle-runtime-level-logo">
<img
src={match3DRuntimeLevelLogo}
alt=""
data-testid="match3d-runtime-level-logo"
className="puzzle-runtime-level-logo__image"
draggable={false}
/>
</span>
<span className="puzzle-runtime-level-badge shrink-0 text-[0.92rem] font-black sm:text-base">
1
</span>
<span className="min-w-0 truncate text-[0.92rem] font-black sm:text-base">
{displayLevelName}
</span>
</div>
<div
className={`puzzle-runtime-timer-card -mt-px inline-flex items-center gap-1.5 px-3.5 py-1.5 font-mono text-lg font-black leading-none sm:text-xl ${timerClassName}`}
>
<Clock className="h-4 w-4 sm:h-5 sm:w-5" />
{formatTimer(timeLeftMs)}
</div>
</div>
<div aria-hidden="true" />
</header>
<section className={MATCH3D_RUNTIME_STAGE_CLASS}>
<div
ref={stageRef}
className={`${MATCH3D_RUNTIME_BOARD_BASE_CLASS} overflow-hidden rounded-[50%] border border-white/14 bg-transparent shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]`}
style={{
width: MATCH3D_RUNTIME_BOARD_WIDTH,
}}
onPointerDown={handleBoardPointerDown}
onPointerMove={handleBoardPointerMove}
onPointerCancel={handleBoardPointerCancel}
onPointerUp={handleBoardPointerUp}
data-testid="match3d-board"
>
{resolvedContainerImageSrc ? (
<img
src={resolvedContainerImageSrc}
alt=""
aria-hidden="true"
className={`${MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS} opacity-0`}
data-testid="match3d-container-image"
/>
) : (
<div className="pointer-events-none absolute inset-0 z-0 rounded-full border border-white/10 bg-transparent" />
)}
{run.items.map((item) =>
hasPendingMatch3DGeneratedImageForItem(
item,
imageSourcesByType,
resolvedImageSources,
failedImageSources,
) ? null : (
<Match3DToken
key={item.itemInstanceId}
item={item}
imageSrc={resolveMatch3DResolvedImageForItem(
item,
resolvedImageSourceEntriesByType,
)}
itemSize={resolveMatch3DItemSizeForType(item, itemSizeByType)}
disabled={Boolean(pendingClick)}
selected={pressedItemInstanceId === item.itemInstanceId}
/>
),
)}
{feedbackEvent?.kind === 'cleared' ? (
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center">
<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>
</section>
<section className="mt-3 w-full min-w-0">
<div
className="relative grid grid-cols-7 gap-1.5"
data-testid="match3d-tray"
>
{run.traySlots.map((slot) => {
const trayItem =
trayPreviewItems[slot.slotIndex] ??
(slot.itemInstanceId
? run.items.find(
(item) => item.itemInstanceId === slot.itemInstanceId,
)
: null);
return (
<div
key={slot.slotIndex}
className="relative z-0 h-14 min-w-0 rounded-none border border-transparent bg-transparent p-0 sm:h-16"
data-testid="match3d-tray-slot"
data-slot-index={slot.slotIndex}
ref={(element) => {
traySlotRefs.current[slot.slotIndex] = element;
}}
>
<Match3DSpriteImage
region={uiSpritesheetRegionByLabel.get('方格')}
testId={`match3d-ui-sprite-grid-${slot.slotIndex}`}
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
/>
<Match3DTrayToken
slot={slot}
isArriving={
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
? resolveFirstResolvedImageForItem(trayItem)
: ''
}
itemSize={
trayItem
? resolveMatch3DItemSizeForType(
trayItem,
itemSizeByType,
)
: '大'
}
/>
</div>
);
})}
</div>
</section>
<section
className="relative z-10 mt-2 grid grid-cols-3 gap-2"
aria-label="抓大鹅道具"
>
{MATCH3D_PROP_BUTTONS.map(([label, testId]) => {
const region = uiSpritesheetRegionByLabel.get(label);
return (
<button
key={label}
type="button"
className="flex min-h-12 items-center justify-center overflow-hidden rounded-none border border-transparent bg-transparent px-1 py-2 text-sm font-black text-white shadow-none transition hover:bg-white/10"
aria-label={label}
>
<Match3DSpriteImage
region={region}
testId={testId}
className="h-10 w-full object-contain"
/>
{!region ? label : null}
</button>
);
})}
</section>
</div>
{feedbackEvent?.kind === 'rejected' ? (
<div className="pointer-events-none absolute left-1/2 top-24 z-[90] -translate-x-1/2 rounded-full border border-rose-200/60 bg-rose-500/88 px-4 py-2 text-xs font-black text-white shadow-lg">
已校正
</div>
) : null}
{flyingTrayAnimation ? (
<Match3DFlyingTrayToken
animation={flyingTrayAnimation}
onDone={(id) =>
setFlyingTrayAnimation((current) =>
current?.id === id ? null : current,
)
}
/>
) : null}
{trayClearAnimation ? (
<Match3DTrayClearToken
animation={trayClearAnimation}
onDone={(id) =>
setTrayClearAnimation((current) =>
current?.id === id ? null : current,
)
}
/>
) : null}
<Match3DSettlement
run={run}
hideBackButton={hideBackButton}
onBack={onBack}
onRestart={onRestart}
/>
</main>
);
}
export default Match3DRuntimeShell;