import { ArrowLeft, CheckCircle2, Clock3, RotateCcw, Settings, XCircle, } from 'lucide-react'; import { type CSSProperties, type PointerEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; 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 { getMatch3DGeneratedImageViewSources, normalizeMatch3DGeneratedItemAssetsForRuntime, } from '../../services/match3dGeneratedModelCache'; import { buildMatch3DItemSpritesheetViewRegions, loadMatch3DSpritesheetAssetRegions, type Match3DDecodedSpritesheetRegion, } from '../../services/match3dSpritesheetParser'; 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, resolveRenderableItemFrame, } from './match3dRuntimePresentation'; import { MATCH3D_RUNTIME_BOARD_BASE_CLASS, MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS, MATCH3D_RUNTIME_BOARD_WIDTH, MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS, MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS, MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS, MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS, MATCH3D_RUNTIME_GLASS_TRAY_CLASS, MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS, MATCH3D_RUNTIME_HEADER_CARD_CLASS, MATCH3D_RUNTIME_LEVEL_BADGE_CLASS, MATCH3D_RUNTIME_STAGE_CLASS, MATCH3D_RUNTIME_TIMER_CLASS, MATCH3D_RUNTIME_TIMER_URGENT_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; 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, '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(); } 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 resolveMatch3DImageReadUrlCacheKey( imageSourcesByType: ReadonlyMap, ) { return resolveMatch3DImageReadUrlSources(imageSourcesByType).join('|'); } function resolveMatch3DImageReadUrlSources( imageSourcesByType: ReadonlyMap, ) { 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, resolvedImageSources: ReadonlyMap, ) { 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(); } 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, resolvedImageSources: ReadonlyMap, failedImageSources: ReadonlySet, ) { 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, itemSizeByType: ReadonlyMap, ) { 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((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 { 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((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 ( ); } 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 ( ); } 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 ( ); } 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 ( {imageSrc ? ( ) : ( )} ); } 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 ( ); } function Match3DTrayClearToken({ animation, onDone, }: { animation: Match3DTrayClearAnimation; onDone: (id: string) => void; }) { return (