1
This commit is contained in:
@@ -1,21 +1,24 @@
|
||||
import { type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||
import { type PointerEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
Match3DItemSnapshot,
|
||||
Match3DRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import { readAssetBytes } from '../../services/assetReadUrlService';
|
||||
import {
|
||||
isItemState,
|
||||
resolveRenderableItemFrame,
|
||||
} from './match3dRuntimePresentation';
|
||||
import {
|
||||
resolveGeometryAsset,
|
||||
type Match3DGeometryAsset,
|
||||
type Match3DGeometryShape,
|
||||
resolveGeometryAsset,
|
||||
} from './match3dVisualAssets';
|
||||
|
||||
type Match3DPhysicsBoardProps = {
|
||||
run: Match3DRunSnapshot;
|
||||
generatedItemAssets?: Match3DGeneratedItemAsset[];
|
||||
disabled: boolean;
|
||||
onClickItem: (item: Match3DItemSnapshot) => void;
|
||||
onFallback: () => void;
|
||||
@@ -30,11 +33,17 @@ type ThreeObject3D = import('three').Object3D;
|
||||
type ThreeScene = import('three').Scene;
|
||||
type ThreeRenderer = import('three').WebGLRenderer;
|
||||
type ThreeCamera = import('three').OrthographicCamera;
|
||||
type Match3DGeneratedModelTemplate = {
|
||||
source: string;
|
||||
scene: ThreeObject3D;
|
||||
};
|
||||
type Match3DGeneratedModelTemplateMap = Map<string, Match3DGeneratedModelTemplate>;
|
||||
|
||||
type PhysicsEntry = {
|
||||
boundaryRadius: number;
|
||||
colliderHeight: number;
|
||||
item: Match3DItemSnapshot;
|
||||
baseVisualScale: import('three').Vector3;
|
||||
body: PhysicsBody;
|
||||
lockReadableTop: boolean;
|
||||
mesh: ThreeObject3D;
|
||||
@@ -101,6 +110,7 @@ type PhysicsRuntime = {
|
||||
animationId: number | null;
|
||||
camera: ThreeCamera;
|
||||
entries: Map<string, PhysicsEntry>;
|
||||
generatedModelTemplates: Match3DGeneratedModelTemplateMap;
|
||||
pendingSpawns: Map<string, PendingPhysicsSpawn>;
|
||||
raycaster: import('three').Raycaster;
|
||||
renderer: ThreeRenderer;
|
||||
@@ -148,12 +158,182 @@ const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0;
|
||||
const MATCH3D_BOARD_CENTER = 0.5;
|
||||
const MATCH3D_PHYSICS_STEP = 1 / 60;
|
||||
const MATCH3D_CAMERA_HALF_SIZE = 6.15;
|
||||
const MATCH3D_GENERATED_MODEL_TARGET_RADIUS_SCALE = 1.9;
|
||||
const MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT = 25;
|
||||
export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape> =
|
||||
new Set([
|
||||
'ring',
|
||||
'arch',
|
||||
]);
|
||||
|
||||
function normalizeMatch3DGeneratedModelSource(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
return asset.modelSrc?.trim() || asset.modelObjectKey?.trim() || '';
|
||||
}
|
||||
|
||||
function compareMatch3DGeneratedTypeId(left: string, right: string) {
|
||||
const leftIndex = Number.parseInt(left.match(/(\d+)$/u)?.[1] ?? '', 10);
|
||||
const rightIndex = Number.parseInt(right.match(/(\d+)$/u)?.[1] ?? '', 10);
|
||||
if (Number.isFinite(leftIndex) && Number.isFinite(rightIndex)) {
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
}
|
||||
|
||||
function resolveMatch3DGeneratedModelTypeIds(items: Match3DItemSnapshot[]) {
|
||||
return [
|
||||
...new Set(
|
||||
items.map((item) => item.itemTypeId.trim()).filter(Boolean),
|
||||
),
|
||||
].sort(compareMatch3DGeneratedTypeId);
|
||||
}
|
||||
|
||||
function buildMatch3DGeneratedAssetTypeMap(
|
||||
run: Match3DRunSnapshot,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
) {
|
||||
const typeIds = resolveMatch3DGeneratedModelTypeIds(run.items);
|
||||
const readyAssets = generatedItemAssets
|
||||
.map((asset) => ({
|
||||
asset,
|
||||
source: normalizeMatch3DGeneratedModelSource(asset),
|
||||
}))
|
||||
.filter(({ source }) => Boolean(source))
|
||||
.slice(0, MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT);
|
||||
const assetMap = new Map<string, Match3DGeneratedItemAsset>();
|
||||
|
||||
typeIds.forEach((itemTypeId, index) => {
|
||||
const resolved = readyAssets[index];
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
assetMap.set(itemTypeId, {
|
||||
...resolved.asset,
|
||||
modelSrc: resolved.source,
|
||||
});
|
||||
});
|
||||
|
||||
return assetMap;
|
||||
}
|
||||
|
||||
function buildGeneratedModelMapSignature(
|
||||
generatedModelByType: Map<string, Match3DGeneratedItemAsset>,
|
||||
) {
|
||||
return [...generatedModelByType.entries()]
|
||||
.map(
|
||||
([itemTypeId, asset]) =>
|
||||
`${itemTypeId}:${normalizeMatch3DGeneratedModelSource(asset)}`,
|
||||
)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function resolveGeneratedModelSourceForItemType(
|
||||
generatedModelByType: Map<string, Match3DGeneratedItemAsset>,
|
||||
itemTypeId: string,
|
||||
) {
|
||||
const asset = generatedModelByType.get(itemTypeId);
|
||||
return asset ? normalizeMatch3DGeneratedModelSource(asset) : '';
|
||||
}
|
||||
|
||||
async function loadMatch3DGeneratedModelTemplate(
|
||||
templateMap: Match3DGeneratedModelTemplateMap,
|
||||
three: ThreeModule,
|
||||
itemTypeId: string,
|
||||
source: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const cached = templateMap.get(itemTypeId);
|
||||
if (cached?.source === source) {
|
||||
return cached.scene;
|
||||
}
|
||||
const response = await readAssetBytes(source, {
|
||||
expireSeconds: 300,
|
||||
signal,
|
||||
});
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength === 0) {
|
||||
throw new Error('抓大鹅 3D 模型内容为空');
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('加载已取消', 'AbortError');
|
||||
}
|
||||
const [{ GLTFLoader }] = await Promise.all([
|
||||
import('three/examples/jsm/loaders/GLTFLoader.js'),
|
||||
]);
|
||||
const loader = new GLTFLoader();
|
||||
const gltf = await loader.parseAsync(bytes, '');
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('加载已取消', 'AbortError');
|
||||
}
|
||||
const scene = gltf.scene;
|
||||
scene.traverse((child) => {
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
});
|
||||
const previous = templateMap.get(itemTypeId);
|
||||
if (previous && previous.source !== source) {
|
||||
disposeThreeObject(previous.scene);
|
||||
}
|
||||
templateMap.set(itemTypeId, {
|
||||
scene,
|
||||
source,
|
||||
});
|
||||
return scene;
|
||||
}
|
||||
|
||||
function cloneThreeObjectWithMaterials(template: ThreeObject3D) {
|
||||
const clone = template.clone(true);
|
||||
clone.traverse((child) => {
|
||||
const maybeMesh = child as import('three').Mesh;
|
||||
if (maybeMesh.geometry) {
|
||||
maybeMesh.geometry = maybeMesh.geometry.clone();
|
||||
}
|
||||
if (maybeMesh.material) {
|
||||
maybeMesh.material = Array.isArray(maybeMesh.material)
|
||||
? maybeMesh.material.map((material) => material.clone())
|
||||
: maybeMesh.material.clone();
|
||||
}
|
||||
});
|
||||
return clone;
|
||||
}
|
||||
|
||||
function createGeneratedModelMesh(
|
||||
three: ThreeModule,
|
||||
item: Match3DItemSnapshot,
|
||||
templateMap: Match3DGeneratedModelTemplateMap | null | undefined,
|
||||
) {
|
||||
const template = templateMap?.get(item.itemTypeId)?.scene;
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
const position = toWorldPosition(item);
|
||||
const model = cloneThreeObjectWithMaterials(template);
|
||||
markObjectForItem(model, item.itemInstanceId);
|
||||
const bounds = new three.Box3().setFromObject(model);
|
||||
const size = bounds.getSize(new three.Vector3());
|
||||
const dimension = Math.max(size.x, size.y, size.z, 0.001);
|
||||
const targetDimension =
|
||||
position.radius * MATCH3D_GENERATED_MODEL_TARGET_RADIUS_SCALE;
|
||||
const scale = targetDimension / dimension;
|
||||
model.scale.multiplyScalar(scale);
|
||||
|
||||
const scaledBounds = new three.Box3().setFromObject(model);
|
||||
const center = scaledBounds.getCenter(new three.Vector3());
|
||||
model.position.sub(center);
|
||||
const bottomY = scaledBounds.min.y - center.y;
|
||||
model.position.y -= bottomY;
|
||||
|
||||
return {
|
||||
lockReadableTop: false,
|
||||
mesh: model,
|
||||
radius: position.radius,
|
||||
shape: 'brick' as Match3DGeometryShape,
|
||||
topRotationY: ((item.layer % 12) / 12) * Math.PI * 2,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
function hasWebGLSupport() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -951,13 +1131,19 @@ export function createMatch3DItemMesh(
|
||||
function createItemMesh(
|
||||
three: ThreeModule,
|
||||
item: Match3DItemSnapshot,
|
||||
templateMap?: Match3DGeneratedModelTemplateMap | null,
|
||||
) {
|
||||
return createMatch3DItemMesh(three, item);
|
||||
return (
|
||||
createGeneratedModelMesh(three, item, templateMap) ??
|
||||
createMatch3DItemMesh(three, item)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMatch3DPhysicsEntrySignature(
|
||||
runId: string,
|
||||
item: Match3DItemSnapshot,
|
||||
generatedModelSource = '',
|
||||
generatedModelRevision = 0,
|
||||
) {
|
||||
return [
|
||||
runId,
|
||||
@@ -966,6 +1152,8 @@ export function buildMatch3DPhysicsEntrySignature(
|
||||
item.visualKey,
|
||||
item.radius.toFixed(5),
|
||||
item.layer,
|
||||
generatedModelSource,
|
||||
generatedModelRevision,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
@@ -984,8 +1172,9 @@ function createPhysicsEntryFromPendingSpawn(
|
||||
runtime: PhysicsRuntime,
|
||||
pendingSpawn: PendingPhysicsSpawn,
|
||||
now: number,
|
||||
templateMap?: Match3DGeneratedModelTemplateMap | null,
|
||||
) {
|
||||
const visual = createItemMesh(runtime.three, pendingSpawn.item);
|
||||
const visual = createItemMesh(runtime.three, pendingSpawn.item, templateMap);
|
||||
const asset = resolveGeometryAsset(pendingSpawn.item.visualKey);
|
||||
const colliderBounds = resolveMatch3DColliderBounds(asset, visual.radius);
|
||||
const boundaryRadius = resolveMatch3DBoundaryRadius(asset, visual.radius);
|
||||
@@ -1042,11 +1231,15 @@ function createPhysicsEntryFromPendingSpawn(
|
||||
0.08,
|
||||
0.08 + (pendingSpawn.item.layer % 4) * 0.02,
|
||||
);
|
||||
visual.mesh.scale.setScalar(MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START);
|
||||
const baseVisualScale = visual.mesh.scale.clone();
|
||||
visual.mesh.scale
|
||||
.copy(baseVisualScale)
|
||||
.multiplyScalar(MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START);
|
||||
|
||||
runtime.world.addBody(body);
|
||||
runtime.scene.add(visual.mesh);
|
||||
runtime.entries.set(pendingSpawn.item.itemInstanceId, {
|
||||
baseVisualScale,
|
||||
body,
|
||||
boundaryRadius,
|
||||
colliderHeight: colliderBounds.height,
|
||||
@@ -1075,7 +1268,12 @@ function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
|
||||
const spawnBudget = runtime.spawnTimingPlan.frameSpawnLimit;
|
||||
readySpawns.slice(0, spawnBudget).forEach(([itemInstanceId, pendingSpawn]) => {
|
||||
runtime.pendingSpawns.delete(itemInstanceId);
|
||||
createPhysicsEntryFromPendingSpawn(runtime, pendingSpawn, now);
|
||||
createPhysicsEntryFromPendingSpawn(
|
||||
runtime,
|
||||
pendingSpawn,
|
||||
now,
|
||||
runtime.generatedModelTemplates,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1089,6 +1287,10 @@ function disposeRuntime(runtime: PhysicsRuntime | null) {
|
||||
runtime.entries.forEach((entry) => {
|
||||
disposeThreeObject(entry.mesh);
|
||||
});
|
||||
runtime.generatedModelTemplates.forEach((template) => {
|
||||
disposeThreeObject(template.scene);
|
||||
});
|
||||
runtime.generatedModelTemplates.clear();
|
||||
runtime.renderer.dispose();
|
||||
runtime.renderer.domElement.remove();
|
||||
}
|
||||
@@ -1097,6 +1299,7 @@ type TrayPreviewRuntime = {
|
||||
animationId: number | null;
|
||||
camera: ThreeCamera;
|
||||
entries: Map<string, ThreeObject3D>;
|
||||
generatedModelTemplates: Match3DGeneratedModelTemplateMap;
|
||||
renderer: ThreeRenderer;
|
||||
scene: ThreeScene;
|
||||
three: ThreeModule;
|
||||
@@ -1114,13 +1317,18 @@ function buildTrayPreviewMeasureKey(item: Match3DItemSnapshot) {
|
||||
function buildTrayPreviewSignature(
|
||||
item: Match3DItemSnapshot,
|
||||
referenceMaxDimension: number,
|
||||
generatedModelSource = '',
|
||||
generatedModelRevision = 0,
|
||||
) {
|
||||
return [
|
||||
item.visualKey,
|
||||
item.itemTypeId,
|
||||
item.radius.toFixed(5),
|
||||
referenceMaxDimension.toFixed(5),
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE.toFixed(5),
|
||||
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE.toFixed(5),
|
||||
generatedModelSource,
|
||||
generatedModelRevision,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
@@ -1215,6 +1423,10 @@ function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
|
||||
disposeThreeObject(mesh);
|
||||
});
|
||||
runtime.entries.clear();
|
||||
runtime.generatedModelTemplates.forEach((template) => {
|
||||
disposeThreeObject(template.scene);
|
||||
});
|
||||
runtime.generatedModelTemplates.clear();
|
||||
runtime.renderer.dispose();
|
||||
runtime.renderer.domElement.remove();
|
||||
}
|
||||
@@ -1255,18 +1467,66 @@ function relayoutTrayPreviewEntries(runtime: TrayPreviewRuntime) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildMatch3DTrayModelSourceMap(
|
||||
referenceItems: Match3DItemSnapshot[],
|
||||
slotItems: Array<Match3DItemSnapshot | null>,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
const itemTypeIds = resolveMatch3DGeneratedModelTypeIds([
|
||||
...referenceItems,
|
||||
...slotItems.filter((item): item is Match3DItemSnapshot => Boolean(item)),
|
||||
]);
|
||||
const readyAssets = generatedItemAssets
|
||||
.map((asset) => ({
|
||||
asset,
|
||||
source: normalizeMatch3DGeneratedModelSource(asset),
|
||||
}))
|
||||
.filter(({ source }) => Boolean(source))
|
||||
.slice(0, MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT);
|
||||
const result = new Map<string, string>();
|
||||
|
||||
itemTypeIds.forEach((itemTypeId, index) => {
|
||||
const resolved = readyAssets[index];
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
result.set(itemTypeId, resolved.source);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function Match3DTrayPreviewBoard({
|
||||
onFallback,
|
||||
referenceItems,
|
||||
slotItems,
|
||||
generatedItemAssets = [],
|
||||
}: {
|
||||
onFallback: () => void;
|
||||
referenceItems: Match3DItemSnapshot[];
|
||||
slotItems: Array<Match3DItemSnapshot | null>;
|
||||
generatedItemAssets?: Match3DGeneratedItemAsset[];
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const runtimeRef = useRef<TrayPreviewRuntime | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const trayModelSourceByType = useMemo(
|
||||
() =>
|
||||
buildMatch3DTrayModelSourceMap(
|
||||
referenceItems,
|
||||
slotItems,
|
||||
generatedItemAssets,
|
||||
),
|
||||
[generatedItemAssets, referenceItems, slotItems],
|
||||
);
|
||||
const trayModelSignature = useMemo(
|
||||
() =>
|
||||
[...trayModelSourceByType.entries()]
|
||||
.map(([type, source]) => `${type}:${source}`)
|
||||
.join('|'),
|
||||
[trayModelSourceByType],
|
||||
);
|
||||
const [trayModelRevision, setTrayModelRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -1342,6 +1602,8 @@ export function Match3DTrayPreviewBoard({
|
||||
animationId: null,
|
||||
camera,
|
||||
entries: runtimeRef.current?.entries ?? new Map(),
|
||||
generatedModelTemplates:
|
||||
runtimeRef.current?.generatedModelTemplates ?? new Map(),
|
||||
renderer,
|
||||
scene,
|
||||
three,
|
||||
@@ -1365,6 +1627,7 @@ export function Match3DTrayPreviewBoard({
|
||||
animationId: window.requestAnimationFrame(animate),
|
||||
camera,
|
||||
entries: new Map(),
|
||||
generatedModelTemplates: new Map(),
|
||||
renderer,
|
||||
scene,
|
||||
three,
|
||||
@@ -1395,6 +1658,61 @@ export function Match3DTrayPreviewBoard({
|
||||
};
|
||||
}, [onFallback]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime) {
|
||||
return undefined;
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
const staleItemTypeIds = new Set(runtime.generatedModelTemplates.keys());
|
||||
trayModelSourceByType.forEach((source, itemTypeId) => {
|
||||
staleItemTypeIds.delete(itemTypeId);
|
||||
const hadFreshTemplate =
|
||||
runtime.generatedModelTemplates.get(itemTypeId)?.source === source;
|
||||
void loadMatch3DGeneratedModelTemplate(
|
||||
runtime.generatedModelTemplates,
|
||||
runtime.three,
|
||||
itemTypeId,
|
||||
source,
|
||||
abortController.signal,
|
||||
)
|
||||
.then(() => {
|
||||
if (hadFreshTemplate) {
|
||||
return;
|
||||
}
|
||||
setTrayModelRevision((current) => current + 1);
|
||||
runtime.entries.forEach((mesh, itemInstanceId) => {
|
||||
const itemType = slotItems.find(
|
||||
(item) => item?.itemInstanceId === itemInstanceId,
|
||||
)?.itemTypeId;
|
||||
if (itemType !== itemTypeId) {
|
||||
return;
|
||||
}
|
||||
runtime.scene.remove(mesh);
|
||||
disposeThreeObject(mesh);
|
||||
runtime.entries.delete(itemInstanceId);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
});
|
||||
});
|
||||
staleItemTypeIds.forEach((itemTypeId) => {
|
||||
const template = runtime.generatedModelTemplates.get(itemTypeId);
|
||||
if (template) {
|
||||
disposeThreeObject(template.scene);
|
||||
}
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [slotItems, trayModelSignature, trayModelSourceByType]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime) {
|
||||
@@ -1430,6 +1748,8 @@ export function Match3DTrayPreviewBoard({
|
||||
const previewSignature = buildTrayPreviewSignature(
|
||||
item,
|
||||
referenceMaxDimension,
|
||||
trayModelSourceByType.get(item.itemTypeId) ?? '',
|
||||
trayModelRevision,
|
||||
);
|
||||
let mesh = runtime.entries.get(item.itemInstanceId);
|
||||
if (mesh && mesh.userData.trayPreviewSignature !== previewSignature) {
|
||||
@@ -1439,7 +1759,11 @@ export function Match3DTrayPreviewBoard({
|
||||
mesh = undefined;
|
||||
}
|
||||
if (!mesh) {
|
||||
const preview = createMatch3DItemMesh(runtime.three, item);
|
||||
const preview = createItemMesh(
|
||||
runtime.three,
|
||||
item,
|
||||
runtime.generatedModelTemplates,
|
||||
);
|
||||
const model = preview.mesh;
|
||||
const rotation = resolveMatch3DTrayPreviewRotation(item.visualKey);
|
||||
model.rotation.set(rotation.x, rotation.y, rotation.z);
|
||||
@@ -1474,7 +1798,14 @@ export function Match3DTrayPreviewBoard({
|
||||
});
|
||||
|
||||
runtime.renderer.render(runtime.scene, runtime.camera);
|
||||
}, [ready, referenceItems, slotItems]);
|
||||
}, [
|
||||
ready,
|
||||
referenceItems,
|
||||
slotItems,
|
||||
trayModelRevision,
|
||||
trayModelSignature,
|
||||
trayModelSourceByType,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1487,6 +1818,7 @@ export function Match3DTrayPreviewBoard({
|
||||
|
||||
export function Match3DPhysicsBoard({
|
||||
run,
|
||||
generatedItemAssets = [],
|
||||
disabled,
|
||||
onClickItem,
|
||||
onFallback,
|
||||
@@ -1496,7 +1828,16 @@ export function Match3DPhysicsBoard({
|
||||
const disabledRef = useRef(disabled);
|
||||
const fallbackRef = useRef(onFallback);
|
||||
const runRef = useRef(run);
|
||||
const generatedModelByType = useMemo(
|
||||
() => buildMatch3DGeneratedAssetTypeMap(run, generatedItemAssets),
|
||||
[generatedItemAssets, run],
|
||||
);
|
||||
const generatedModelSignature = useMemo(
|
||||
() => buildGeneratedModelMapSignature(generatedModelByType),
|
||||
[generatedModelByType],
|
||||
);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [generatedModelRevision, setGeneratedModelRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fallbackRef.current = onFallback;
|
||||
@@ -1686,6 +2027,7 @@ export function Match3DPhysicsBoard({
|
||||
animationId: null,
|
||||
camera,
|
||||
entries: new Map(),
|
||||
generatedModelTemplates: new Map(),
|
||||
pendingSpawns: new Map(),
|
||||
raycaster: new three.Raycaster(),
|
||||
renderer,
|
||||
@@ -1737,7 +2079,7 @@ export function Match3DPhysicsBoard({
|
||||
constrainBodyInsidePot(entry);
|
||||
const spawnProgress = resolveSpawnAnimationProgress(entry, now);
|
||||
const spawnScale = resolveMatch3DSpawnVisualScale(spawnProgress);
|
||||
entry.mesh.scale.setScalar(spawnScale);
|
||||
entry.mesh.scale.copy(entry.baseVisualScale).multiplyScalar(spawnScale);
|
||||
entry.mesh.position.set(
|
||||
entry.body.position.x,
|
||||
entry.body.position.y -
|
||||
@@ -1787,6 +2129,65 @@ export function Match3DPhysicsBoard({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime) {
|
||||
return undefined;
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
const staleItemTypeIds = new Set(runtime.generatedModelTemplates.keys());
|
||||
generatedModelByType.forEach((asset, itemTypeId) => {
|
||||
const source = normalizeMatch3DGeneratedModelSource(asset);
|
||||
staleItemTypeIds.delete(itemTypeId);
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
const hadFreshTemplate =
|
||||
runtime.generatedModelTemplates.get(itemTypeId)?.source === source;
|
||||
void loadMatch3DGeneratedModelTemplate(
|
||||
runtime.generatedModelTemplates,
|
||||
runtime.three,
|
||||
itemTypeId,
|
||||
source,
|
||||
abortController.signal,
|
||||
)
|
||||
.then(() => {
|
||||
if (hadFreshTemplate) {
|
||||
return;
|
||||
}
|
||||
setGeneratedModelRevision((current) => current + 1);
|
||||
const hasActiveEntry = [...runtime.entries.values()].some(
|
||||
(entry) => entry.item.itemTypeId === itemTypeId,
|
||||
);
|
||||
if (!hasActiveEntry) {
|
||||
return;
|
||||
}
|
||||
runtime.entries.forEach((entry, itemInstanceId) => {
|
||||
if (entry.item.itemTypeId === itemTypeId) {
|
||||
removePhysicsEntry(runtime, itemInstanceId, entry);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
});
|
||||
});
|
||||
staleItemTypeIds.forEach((itemTypeId) => {
|
||||
const template = runtime.generatedModelTemplates.get(itemTypeId);
|
||||
if (template) {
|
||||
disposeThreeObject(template.scene);
|
||||
}
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [generatedModelByType, generatedModelSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime) {
|
||||
@@ -1823,6 +2224,11 @@ export function Match3DPhysicsBoard({
|
||||
const renderSignature = buildMatch3DPhysicsEntrySignature(
|
||||
run.runId,
|
||||
item,
|
||||
resolveGeneratedModelSourceForItemType(
|
||||
generatedModelByType,
|
||||
item.itemTypeId,
|
||||
),
|
||||
generatedModelRevision,
|
||||
);
|
||||
const existing = runtime.entries.get(item.itemInstanceId);
|
||||
if (existing) {
|
||||
@@ -1873,7 +2279,16 @@ export function Match3DPhysicsBoard({
|
||||
resolveMatch3DStackTargetY(run.totalItemCount, activeItemIds.size, 0),
|
||||
});
|
||||
});
|
||||
}, [ready, run.items, run.runId, run.snapshotVersion]);
|
||||
}, [
|
||||
generatedModelSignature,
|
||||
generatedModelRevision,
|
||||
generatedModelByType,
|
||||
ready,
|
||||
run,
|
||||
run.items,
|
||||
run.runId,
|
||||
run.snapshotVersion,
|
||||
]);
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
Reference in New Issue
Block a user