This commit is contained in:
2026-05-11 16:15:48 +08:00
parent 0c9254502c
commit e30b733b17
87 changed files with 3527 additions and 1261 deletions

View File

@@ -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();