cfc0c0eadf
合并 origin/master 并保留平台入口运行态与推荐链路语义 修正合并后基于 tab 语义变化的前端断言
2359 lines
71 KiB
TypeScript
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;
|