Prune stale docs and update .hermes content
Delete a large set of outdated documentation (many files under docs/ and .hermes/plans/, including audits, design, prd, technical, planning, assets, and todos). Update and consolidate .hermes content: refresh shared-memory pages (decision-log, development-workflow, document-map, pitfalls, project-overview, team-conventions) and several skills/references under .hermes/skills. Also modify AGENTS.md, README.md, UI_CODING_STANDARD.md, docs/README.md and .encoding-check-ignore. Purpose: clean up stale planning/audit material and keep current hermes documentation and related top-level docs in sync.
This commit is contained in:
@@ -555,6 +555,7 @@ function getMatch3DGeneratedItemAssetPersistenceSignature(
|
||||
return [
|
||||
asset.itemId.trim(),
|
||||
asset.itemName.trim(),
|
||||
asset.itemSize?.trim() ?? '',
|
||||
asset.imageSrc?.trim() ?? '',
|
||||
asset.imageObjectKey?.trim() ?? '',
|
||||
...(asset.imageViews ?? []).flatMap((view) => [
|
||||
@@ -623,6 +624,7 @@ function mergeMatch3DGeneratedItemAsset(
|
||||
return {
|
||||
...base,
|
||||
itemName: override.itemName.trim() || base.itemName,
|
||||
itemSize: override.itemSize ?? base.itemSize ?? null,
|
||||
imageSrc: override.imageSrc?.trim()
|
||||
? override.imageSrc
|
||||
: (base.imageSrc ?? null),
|
||||
|
||||
@@ -28,6 +28,11 @@ type Match3DPhysicsBoardProps = {
|
||||
onFallback: () => void;
|
||||
};
|
||||
|
||||
type Match3DPhysicsPointerSelection = {
|
||||
itemInstanceId: string | null;
|
||||
pointerId: number;
|
||||
};
|
||||
|
||||
type ThreeModule = typeof import('three');
|
||||
type CannonModule = typeof import('cannon-es');
|
||||
type PhysicsBody = import('cannon-es').Body;
|
||||
@@ -41,7 +46,10 @@ type Match3DGeneratedModelTemplate = {
|
||||
source: string;
|
||||
scene: ThreeObject3D;
|
||||
};
|
||||
type Match3DGeneratedModelTemplateMap = Map<string, Match3DGeneratedModelTemplate>;
|
||||
type Match3DGeneratedModelTemplateMap = Map<
|
||||
string,
|
||||
Match3DGeneratedModelTemplate
|
||||
>;
|
||||
|
||||
type PhysicsEntry = {
|
||||
boundaryRadius: number;
|
||||
@@ -166,11 +174,9 @@ 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;
|
||||
const MATCH3D_SELECTED_MODEL_SCALE = 1.1;
|
||||
export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape> =
|
||||
new Set([
|
||||
'ring',
|
||||
'arch',
|
||||
]);
|
||||
new Set(['ring', 'arch']);
|
||||
|
||||
function normalizeMatch3DGeneratedModelSource(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
@@ -189,9 +195,7 @@ function compareMatch3DGeneratedTypeId(left: string, right: string) {
|
||||
|
||||
function resolveMatch3DGeneratedModelTypeIds(items: Match3DItemSnapshot[]) {
|
||||
return [
|
||||
...new Set(
|
||||
items.map((item) => item.itemTypeId.trim()).filter(Boolean),
|
||||
),
|
||||
...new Set(items.map((item) => item.itemTypeId.trim()).filter(Boolean)),
|
||||
].sort(compareMatch3DGeneratedTypeId);
|
||||
}
|
||||
|
||||
@@ -264,10 +268,7 @@ function warnMatch3DGeneratedModelLoadFailure(
|
||||
});
|
||||
}
|
||||
|
||||
function debugMatch3DGeneratedModelLoaded(
|
||||
itemTypeId: string,
|
||||
source: string,
|
||||
) {
|
||||
function debugMatch3DGeneratedModelLoaded(itemTypeId: string, source: string) {
|
||||
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
|
||||
return;
|
||||
}
|
||||
@@ -277,10 +278,7 @@ function debugMatch3DGeneratedModelLoaded(
|
||||
});
|
||||
}
|
||||
|
||||
function debugMatch3DGeneratedModelMapped(
|
||||
itemTypeId: string,
|
||||
source: string,
|
||||
) {
|
||||
function debugMatch3DGeneratedModelMapped(itemTypeId: string, source: string) {
|
||||
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
|
||||
return;
|
||||
}
|
||||
@@ -393,9 +391,7 @@ function createGeneratedModelMesh(
|
||||
function hasWebGLSupport() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
return Boolean(
|
||||
canvas.getContext('webgl2') ?? canvas.getContext('webgl'),
|
||||
);
|
||||
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -446,16 +442,13 @@ export function resolveMatch3DBoardDepthPlan(
|
||||
MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE +
|
||||
normalizedTotalItemCount * MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE,
|
||||
);
|
||||
const remainingRatio =
|
||||
normalizedActiveItemCount / normalizedTotalItemCount;
|
||||
const remainingRatio = normalizedActiveItemCount / normalizedTotalItemCount;
|
||||
const activeDepth =
|
||||
normalizedActiveItemCount <= 1 ? 0 : initialDepth * remainingRatio;
|
||||
const pressureRatio = Math.min(1, volumePressure / 210);
|
||||
const layerCapacity = Math.max(
|
||||
MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_MIN,
|
||||
Math.round(
|
||||
MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE - pressureRatio * 8,
|
||||
),
|
||||
Math.round(MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE - pressureRatio * 8),
|
||||
);
|
||||
const layerCount = Math.max(
|
||||
1,
|
||||
@@ -545,8 +538,7 @@ export function resolveMatch3DSpawnY(
|
||||
}
|
||||
|
||||
// 中文注释:新物体生成时先避开同位置已有堆叠顶部,避免最后一波直接塞进未稳定的上层模型。
|
||||
const obstacleTopY =
|
||||
obstacle.y + Math.max(0, obstacle.colliderHeight) / 2;
|
||||
const obstacleTopY = obstacle.y + Math.max(0, obstacle.colliderHeight) / 2;
|
||||
return Math.max(
|
||||
spawnY,
|
||||
obstacleTopY + selfHalfHeight + MATCH3D_ITEM_SPAWN_STACK_CLEARANCE,
|
||||
@@ -571,9 +563,7 @@ export function resolveMatch3DSpawnDelay(
|
||||
1,
|
||||
timingPlan?.burstSize ?? normalizedLayerCapacity,
|
||||
);
|
||||
const layerIndex = Math.floor(
|
||||
normalizedLayerRank / normalizedLayerCapacity,
|
||||
);
|
||||
const layerIndex = Math.floor(normalizedLayerRank / normalizedLayerCapacity);
|
||||
const burstIndex = Math.floor(normalizedLayerRank / burstSize);
|
||||
return (
|
||||
Math.max(layerIndex, burstIndex) * layerDelayMs +
|
||||
@@ -654,17 +644,14 @@ function buildMatch3DStackHeightTargets(
|
||||
activeItems.length,
|
||||
);
|
||||
activeItems.forEach((item, activeLayerRank) => {
|
||||
targets.set(
|
||||
item.itemInstanceId,
|
||||
{
|
||||
targets.set(item.itemInstanceId, {
|
||||
activeLayerRank,
|
||||
targetY: resolveMatch3DStackTargetY(
|
||||
run.totalItemCount,
|
||||
activeItems.length,
|
||||
activeLayerRank,
|
||||
targetY: resolveMatch3DStackTargetY(
|
||||
run.totalItemCount,
|
||||
activeItems.length,
|
||||
activeLayerRank,
|
||||
),
|
||||
},
|
||||
);
|
||||
),
|
||||
});
|
||||
});
|
||||
return {
|
||||
layerCapacity: depthPlan.layerCapacity,
|
||||
@@ -740,8 +727,7 @@ function syncRuntimeStabilityPlan(
|
||||
) {
|
||||
const stabilityPlan = resolveMatch3DPhysicsStabilityPlan(totalItemCount);
|
||||
runtime.stabilityPlan = stabilityPlan;
|
||||
runtime.world.defaultContactMaterial.friction =
|
||||
stabilityPlan.contactFriction;
|
||||
runtime.world.defaultContactMaterial.friction = stabilityPlan.contactFriction;
|
||||
runtime.world.defaultContactMaterial.restitution =
|
||||
stabilityPlan.contactRestitution;
|
||||
const solver = runtime.world.solver as import('cannon-es').GSSolver;
|
||||
@@ -789,10 +775,7 @@ function constrainBodyInsidePot(entry: PhysicsEntry) {
|
||||
function resolveSpawnAnimationProgress(entry: PhysicsEntry, now: number) {
|
||||
return Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(now - entry.spawnStartedAt) / MATCH3D_ITEM_SPAWN_ANIMATION_MS,
|
||||
),
|
||||
Math.max(0, (now - entry.spawnStartedAt) / MATCH3D_ITEM_SPAWN_ANIMATION_MS),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -827,7 +810,10 @@ function applyCenterGravity(entry: PhysicsEntry) {
|
||||
MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05,
|
||||
);
|
||||
const edgePressure = Math.min(1, horizontalDistance / maxDistance);
|
||||
const centerFalloff = Math.min(1, Math.max(0, (horizontalDistance - 1.15) / maxDistance));
|
||||
const centerFalloff = Math.min(
|
||||
1,
|
||||
Math.max(0, (horizontalDistance - 1.15) / maxDistance),
|
||||
);
|
||||
const forceStrength =
|
||||
MATCH3D_CENTER_GRAVITY_COEFFICIENT *
|
||||
entry.body.mass *
|
||||
@@ -886,7 +872,8 @@ export function resolveMatch3DColliderBounds(
|
||||
default:
|
||||
return {
|
||||
depth: radius * (0.9 + asset.studsY * 0.62),
|
||||
height: Math.max(radius * 0.24, radius * asset.heightScale) + radius * 0.12,
|
||||
height:
|
||||
Math.max(radius * 0.24, radius * asset.heightScale) + radius * 0.12,
|
||||
width: radius * (0.9 + asset.studsX * 0.62),
|
||||
};
|
||||
}
|
||||
@@ -911,11 +898,7 @@ export function createMatch3DCannonShape(
|
||||
return new cannon.Cylinder(0, bounds.width / 2, bounds.height, 24);
|
||||
default:
|
||||
return new cannon.Box(
|
||||
new cannon.Vec3(
|
||||
bounds.width / 2,
|
||||
bounds.height / 2,
|
||||
bounds.depth / 2,
|
||||
),
|
||||
new cannon.Vec3(bounds.width / 2, bounds.height / 2, bounds.depth / 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1004,7 +987,12 @@ export function createMatch3DThreeGeometry(
|
||||
|
||||
switch (shape) {
|
||||
case 'cylinder':
|
||||
return new three.CylinderGeometry(radius * 0.72, radius * 0.72, radius * 1.35, 26);
|
||||
return new three.CylinderGeometry(
|
||||
radius * 0.72,
|
||||
radius * 0.72,
|
||||
radius * 1.35,
|
||||
26,
|
||||
);
|
||||
case 'cone':
|
||||
return new three.ConeGeometry(radius * 0.78, radius * 1.62, 28);
|
||||
case 'tile':
|
||||
@@ -1028,7 +1016,12 @@ function createRoundedBlockBase(
|
||||
}
|
||||
|
||||
function createStudGeometry(three: ThreeModule, radius: number) {
|
||||
return new three.CylinderGeometry(radius * 0.18, radius * 0.18, radius * 0.12, 20);
|
||||
return new three.CylinderGeometry(
|
||||
radius * 0.18,
|
||||
radius * 0.18,
|
||||
radius * 0.12,
|
||||
20,
|
||||
);
|
||||
}
|
||||
|
||||
function createSlopeGeometry(
|
||||
@@ -1043,18 +1036,27 @@ function createSlopeGeometry(
|
||||
const halfD = depth / 2;
|
||||
const halfH = height / 2;
|
||||
const vertices = new Float32Array([
|
||||
-halfW, -halfH, -halfD,
|
||||
halfW, -halfH, -halfD,
|
||||
halfW, -halfH, halfD,
|
||||
-halfW, -halfH, halfD,
|
||||
halfW, halfH, -halfD,
|
||||
halfW, halfH, halfD,
|
||||
-halfW,
|
||||
-halfH,
|
||||
-halfD,
|
||||
halfW,
|
||||
-halfH,
|
||||
-halfD,
|
||||
halfW,
|
||||
-halfH,
|
||||
halfD,
|
||||
-halfW,
|
||||
-halfH,
|
||||
halfD,
|
||||
halfW,
|
||||
halfH,
|
||||
-halfD,
|
||||
halfW,
|
||||
halfH,
|
||||
halfD,
|
||||
]);
|
||||
const indices = [
|
||||
0, 1, 2, 0, 2, 3,
|
||||
1, 4, 5, 1, 5, 2,
|
||||
3, 2, 5, 3, 5, 0,
|
||||
0, 5, 4, 0, 4, 1,
|
||||
0, 1, 2, 0, 2, 3, 1, 4, 5, 1, 5, 2, 3, 2, 5, 3, 5, 0, 0, 5, 4, 0, 4, 1,
|
||||
];
|
||||
const geometry = new three.BufferGeometry();
|
||||
geometry.setAttribute('position', new three.BufferAttribute(vertices, 3));
|
||||
@@ -1076,7 +1078,8 @@ function addBrickStuds(
|
||||
const studGeometry = createStudGeometry(three, radius);
|
||||
const width = radius * (0.9 + asset.studsX * 0.62);
|
||||
const depth = radius * (0.9 + asset.studsY * 0.62);
|
||||
const y = Math.max(radius * 0.24, radius * asset.heightScale) / 2 + radius * 0.06;
|
||||
const y =
|
||||
Math.max(radius * 0.24, radius * asset.heightScale) / 2 + radius * 0.06;
|
||||
for (let row = 0; row < asset.studsY; row += 1) {
|
||||
for (let column = 0; column < asset.studsX; column += 1) {
|
||||
const stud = new three.Mesh(studGeometry.clone(), material);
|
||||
@@ -1101,7 +1104,12 @@ function createBlockMesh(
|
||||
if (asset.shape === 'slope') {
|
||||
baseGeometry = createSlopeGeometry(three, asset, radius);
|
||||
} else if (asset.shape === 'cylinder') {
|
||||
baseGeometry = new three.CylinderGeometry(radius * 0.58, radius * 0.58, radius * 1.18, 28);
|
||||
baseGeometry = new three.CylinderGeometry(
|
||||
radius * 0.58,
|
||||
radius * 0.58,
|
||||
radius * 1.18,
|
||||
28,
|
||||
);
|
||||
} else if (asset.shape === 'cone') {
|
||||
baseGeometry = new three.ConeGeometry(radius * 0.68, radius * 1.48, 30);
|
||||
} else if (MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape)) {
|
||||
@@ -1116,7 +1124,10 @@ function createBlockMesh(
|
||||
addBrickStuds(three, group, asset, radius, material);
|
||||
}
|
||||
if (asset.shape === 'cylinder') {
|
||||
const topStud = new three.Mesh(createStudGeometry(three, radius * 1.2), material);
|
||||
const topStud = new three.Mesh(
|
||||
createStudGeometry(three, radius * 1.2),
|
||||
material,
|
||||
);
|
||||
topStud.position.y = radius * 0.65;
|
||||
group.add(topStud);
|
||||
}
|
||||
@@ -1240,6 +1251,35 @@ function removePhysicsEntry(
|
||||
runtime.entries.delete(itemInstanceId);
|
||||
}
|
||||
|
||||
function resolveMatch3DPhysicsHitItemId(
|
||||
runtime: PhysicsRuntime,
|
||||
container: HTMLElement,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
const pointer = new runtime.three.Vector2(
|
||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-(((clientY - rect.top) / rect.height) * 2 - 1),
|
||||
);
|
||||
runtime.raycaster.setFromCamera(pointer, runtime.camera);
|
||||
const meshes = [...runtime.entries.values()]
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.item.clickable &&
|
||||
isItemState(entry.item.state, 'in_board') &&
|
||||
entry.mesh.visible,
|
||||
)
|
||||
.map((entry) => entry.mesh);
|
||||
const hit = runtime.raycaster.intersectObjects(meshes, true)[0];
|
||||
return typeof hit?.object.userData.itemInstanceId === 'string'
|
||||
? hit.object.userData.itemInstanceId
|
||||
: null;
|
||||
}
|
||||
|
||||
function createPhysicsEntryFromPendingSpawn(
|
||||
runtime: PhysicsRuntime,
|
||||
pendingSpawn: PendingPhysicsSpawn,
|
||||
@@ -1261,7 +1301,10 @@ function createPhysicsEntryFromPendingSpawn(
|
||||
const colliderBounds = resolveMatch3DColliderBounds(asset, visual.radius);
|
||||
const boundaryRadius = resolveMatch3DBoundaryRadius(asset, visual.radius);
|
||||
const position = visual.position;
|
||||
const maxDistance = Math.max(0, MATCH3D_ITEM_ACTIVITY_RADIUS - boundaryRadius);
|
||||
const maxDistance = Math.max(
|
||||
0,
|
||||
MATCH3D_ITEM_ACTIVITY_RADIUS - boundaryRadius,
|
||||
);
|
||||
const horizontalDistance = Math.hypot(position.x, position.z);
|
||||
if (horizontalDistance > maxDistance && horizontalDistance > 0) {
|
||||
const ratio = maxDistance / horizontalDistance;
|
||||
@@ -1269,8 +1312,7 @@ function createPhysicsEntryFromPendingSpawn(
|
||||
position.z *= ratio;
|
||||
}
|
||||
const spawnLayerIndex = Math.floor(
|
||||
Math.max(0, pendingSpawn.activeLayerRank) /
|
||||
pendingSpawn.layerCapacity,
|
||||
Math.max(0, pendingSpawn.activeLayerRank) / pendingSpawn.layerCapacity,
|
||||
);
|
||||
const plannedSpawnY =
|
||||
pendingSpawn.targetY +
|
||||
@@ -1297,11 +1339,7 @@ function createPhysicsEntryFromPendingSpawn(
|
||||
shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
|
||||
sleepSpeedLimit: 0.12,
|
||||
sleepTimeLimit: 0.18,
|
||||
position: new runtime.cannon.Vec3(
|
||||
position.x,
|
||||
spawnY,
|
||||
position.z,
|
||||
),
|
||||
position: new runtime.cannon.Vec3(position.x, spawnY, position.z),
|
||||
});
|
||||
body.velocity.set(
|
||||
((pendingSpawn.item.layer % 5) - 2) * 0.06,
|
||||
@@ -1358,15 +1396,17 @@ function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
|
||||
return left[0].localeCompare(right[0]);
|
||||
});
|
||||
const spawnBudget = runtime.spawnTimingPlan.frameSpawnLimit;
|
||||
readySpawns.slice(0, spawnBudget).forEach(([itemInstanceId, pendingSpawn]) => {
|
||||
runtime.pendingSpawns.delete(itemInstanceId);
|
||||
createPhysicsEntryFromPendingSpawn(
|
||||
runtime,
|
||||
pendingSpawn,
|
||||
now,
|
||||
runtime.generatedModelTemplates,
|
||||
);
|
||||
});
|
||||
readySpawns
|
||||
.slice(0, spawnBudget)
|
||||
.forEach(([itemInstanceId, pendingSpawn]) => {
|
||||
runtime.pendingSpawns.delete(itemInstanceId);
|
||||
createPhysicsEntryFromPendingSpawn(
|
||||
runtime,
|
||||
pendingSpawn,
|
||||
now,
|
||||
runtime.generatedModelTemplates,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function disposeRuntime(runtime: PhysicsRuntime | null) {
|
||||
@@ -1455,8 +1495,7 @@ export function resolveMatch3DTrayPreviewReferenceDimension(
|
||||
|
||||
export function resolveMatch3DTrayPreviewRotation(visualKey: string) {
|
||||
const asset = resolveGeometryAsset(visualKey);
|
||||
const yaw =
|
||||
asset.studsX >= asset.studsY ? Math.PI / 4 : Math.PI / 5;
|
||||
const yaw = asset.studsX >= asset.studsY ? Math.PI / 4 : Math.PI / 5;
|
||||
|
||||
// 中文注释:托盘里用轻微俯视 3/4 姿态展示体积,固定朝向只影响 UI 预览,不反写场内物理姿态。
|
||||
switch (asset.shape) {
|
||||
@@ -1495,14 +1534,12 @@ export function resolveMatch3DTrayPreviewScale(
|
||||
itemDimension: number,
|
||||
referenceMaxDimension: number,
|
||||
) {
|
||||
const maxScale = MATCH3D_TRAY_MODEL_TARGET_SIZE / Math.max(referenceMaxDimension, 0.001);
|
||||
const maxScale =
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE / Math.max(referenceMaxDimension, 0.001);
|
||||
const readableScale =
|
||||
(MATCH3D_TRAY_MODEL_TARGET_SIZE * MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE) /
|
||||
Math.max(itemDimension, 0.001);
|
||||
return Math.max(
|
||||
maxScale,
|
||||
readableScale,
|
||||
);
|
||||
return Math.max(maxScale, readableScale);
|
||||
}
|
||||
|
||||
function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
|
||||
@@ -1524,9 +1561,7 @@ function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
|
||||
runtime.renderer.domElement.remove();
|
||||
}
|
||||
|
||||
export function applyMatch3DRendererCanvasLayout(
|
||||
canvas: HTMLCanvasElement,
|
||||
) {
|
||||
export function applyMatch3DRendererCanvasLayout(canvas: HTMLCanvasElement) {
|
||||
// 中文注释:WebGL 绘图缓冲区会乘设备 DPR,CSS 尺寸必须单独锁住,否则手机端画布会放大溢出。
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.height = '100%';
|
||||
@@ -1794,11 +1829,7 @@ export function Match3DTrayPreviewBoard({
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
warnMatch3DGeneratedModelLoadFailure(
|
||||
itemTypeId,
|
||||
source,
|
||||
caughtError,
|
||||
);
|
||||
warnMatch3DGeneratedModelLoadFailure(itemTypeId, source, caughtError);
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
runtime.failedGeneratedModelTypeIds.add(itemTypeId);
|
||||
setTrayModelRevision((current) => current + 1);
|
||||
@@ -1841,8 +1872,8 @@ export function Match3DTrayPreviewBoard({
|
||||
runtime.three,
|
||||
referenceItems.length > 0
|
||||
? referenceItems
|
||||
: slotItems.filter(
|
||||
(item): item is Match3DItemSnapshot => Boolean(item),
|
||||
: slotItems.filter((item): item is Match3DItemSnapshot =>
|
||||
Boolean(item),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1885,10 +1916,7 @@ export function Match3DTrayPreviewBoard({
|
||||
0.001,
|
||||
);
|
||||
model.scale.multiplyScalar(
|
||||
resolveMatch3DTrayPreviewScale(
|
||||
itemDimension,
|
||||
referenceMaxDimension,
|
||||
),
|
||||
resolveMatch3DTrayPreviewScale(itemDimension, referenceMaxDimension),
|
||||
);
|
||||
const centeredBounds = new runtime.three.Box3().setFromObject(model);
|
||||
const center = centeredBounds.getCenter(new runtime.three.Vector3());
|
||||
@@ -1935,6 +1963,9 @@ export function Match3DPhysicsBoard({
|
||||
const disabledRef = useRef(disabled);
|
||||
const fallbackRef = useRef(onFallback);
|
||||
const runRef = useRef(run);
|
||||
const pointerSelectionRef = useRef<Match3DPhysicsPointerSelection | null>(
|
||||
null,
|
||||
);
|
||||
const generatedModelByType = useMemo(
|
||||
() => buildMatch3DGeneratedAssetTypeMap(run, generatedItemAssets),
|
||||
[generatedItemAssets, run],
|
||||
@@ -2033,7 +2064,11 @@ export function Match3DPhysicsBoard({
|
||||
scene.add(floor);
|
||||
|
||||
const basinShade = new three.Mesh(
|
||||
new three.RingGeometry(MATCH3D_POT_INNER_RADIUS * 0.72, MATCH3D_POT_FLOOR_RADIUS, 112),
|
||||
new three.RingGeometry(
|
||||
MATCH3D_POT_INNER_RADIUS * 0.72,
|
||||
MATCH3D_POT_FLOOR_RADIUS,
|
||||
112,
|
||||
),
|
||||
new three.MeshBasicMaterial({
|
||||
color: '#8a4f1f',
|
||||
opacity: 0.2,
|
||||
@@ -2123,7 +2158,9 @@ export function Match3DPhysicsBoard({
|
||||
const z = Math.sin(angle) * (MATCH3D_POT_INNER_RADIUS + 0.18);
|
||||
const wall = new cannon.Body({
|
||||
mass: 0,
|
||||
shape: new cannon.Box(new cannon.Vec3(0.22, MATCH3D_POT_WALL_HEIGHT, 0.34)),
|
||||
shape: new cannon.Box(
|
||||
new cannon.Vec3(0.22, MATCH3D_POT_WALL_HEIGHT, 0.34),
|
||||
),
|
||||
position: new cannon.Vec3(x, MATCH3D_POT_WALL_HEIGHT, z),
|
||||
});
|
||||
wall.quaternion.setFromEuler(0, -angle, 0);
|
||||
@@ -2170,7 +2207,10 @@ export function Match3DPhysicsBoard({
|
||||
if (!activeRuntime) {
|
||||
return;
|
||||
}
|
||||
const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
|
||||
const delta = Math.min(
|
||||
0.04,
|
||||
Math.max(0.001, (now - lastTime) / 1000),
|
||||
);
|
||||
lastTime = now;
|
||||
flushPendingPhysicsSpawns(activeRuntime, now);
|
||||
activeRuntime.entries.forEach((entry) => {
|
||||
@@ -2188,7 +2228,14 @@ export function Match3DPhysicsBoard({
|
||||
constrainBodyInsidePot(entry);
|
||||
const spawnProgress = resolveSpawnAnimationProgress(entry, now);
|
||||
const spawnScale = resolveMatch3DSpawnVisualScale(spawnProgress);
|
||||
entry.mesh.scale.copy(entry.baseVisualScale).multiplyScalar(spawnScale);
|
||||
const selectedScale =
|
||||
pointerSelectionRef.current?.itemInstanceId ===
|
||||
entry.item.itemInstanceId
|
||||
? MATCH3D_SELECTED_MODEL_SCALE
|
||||
: 1;
|
||||
entry.mesh.scale
|
||||
.copy(entry.baseVisualScale)
|
||||
.multiplyScalar(spawnScale * selectedScale);
|
||||
entry.mesh.position.set(
|
||||
entry.body.position.x,
|
||||
entry.body.position.y -
|
||||
@@ -2206,7 +2253,10 @@ export function Match3DPhysicsBoard({
|
||||
}
|
||||
});
|
||||
|
||||
activeRuntime.renderer.render(activeRuntime.scene, activeRuntime.camera);
|
||||
activeRuntime.renderer.render(
|
||||
activeRuntime.scene,
|
||||
activeRuntime.camera,
|
||||
);
|
||||
activeRuntime.animationId = window.requestAnimationFrame(animate);
|
||||
};
|
||||
runtime.animationId = window.requestAnimationFrame(animate);
|
||||
@@ -2283,11 +2333,7 @@ export function Match3DPhysicsBoard({
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
warnMatch3DGeneratedModelLoadFailure(
|
||||
itemTypeId,
|
||||
source,
|
||||
caughtError,
|
||||
);
|
||||
warnMatch3DGeneratedModelLoadFailure(itemTypeId, source, caughtError);
|
||||
runtime.generatedModelTemplates.delete(itemTypeId);
|
||||
runtime.failedGeneratedModelTypeIds.add(itemTypeId);
|
||||
setGeneratedModelRevision((current) => current + 1);
|
||||
@@ -2409,39 +2455,63 @@ export function Match3DPhysicsBoard({
|
||||
run.snapshotVersion,
|
||||
]);
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
const resolvePointerHitItemId = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const runtime = runtimeRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!runtime || !container || disabledRef.current) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
return resolveMatch3DPhysicsHitItemId(
|
||||
runtime,
|
||||
container,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
);
|
||||
};
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const pointer = new runtime.three.Vector2(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-(((event.clientY - rect.top) / rect.height) * 2 - 1),
|
||||
);
|
||||
runtime.raycaster.setFromCamera(pointer, runtime.camera);
|
||||
const meshes = [...runtime.entries.values()]
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.item.clickable &&
|
||||
isItemState(entry.item.state, 'in_board') &&
|
||||
entry.mesh.visible,
|
||||
)
|
||||
.map((entry) => entry.mesh);
|
||||
const hit = runtime.raycaster.intersectObjects(meshes, true)[0];
|
||||
const itemInstanceId =
|
||||
typeof hit?.object.userData.itemInstanceId === 'string'
|
||||
? hit.object.userData.itemInstanceId
|
||||
: null;
|
||||
if (!itemInstanceId) {
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
if (!runtimeRef.current || !containerRef.current || disabledRef.current) {
|
||||
return;
|
||||
}
|
||||
const item = runRef.current.items.find(
|
||||
(entry) => entry.itemInstanceId === itemInstanceId,
|
||||
);
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
pointerSelectionRef.current = {
|
||||
itemInstanceId: resolvePointerHitItemId(event),
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (pointerSelectionRef.current?.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
pointerSelectionRef.current = {
|
||||
itemInstanceId: resolvePointerHitItemId(event),
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerCancel = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (pointerSelectionRef.current?.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
pointerSelectionRef.current = null;
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
if (pointerSelectionRef.current?.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
const itemInstanceId = resolvePointerHitItemId(event);
|
||||
pointerSelectionRef.current = null;
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
const item = itemInstanceId
|
||||
? runRef.current.items.find(
|
||||
(entry) => entry.itemInstanceId === itemInstanceId,
|
||||
)
|
||||
: null;
|
||||
if (item?.clickable && isItemState(item.state, 'in_board')) {
|
||||
onClickItem(item);
|
||||
}
|
||||
@@ -2453,6 +2523,9 @@ export function Match3DPhysicsBoard({
|
||||
className="absolute inset-0 z-10 overflow-visible"
|
||||
data-testid="match3d-physics-board"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{!ready ? (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.18),transparent_28%)]" />
|
||||
|
||||
@@ -1,55 +1,52 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useEffect } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Match3DGeneratedItemAsset,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import {
|
||||
confirmLocalMatch3DClick,
|
||||
resolveLocalMatch3DItemTypeCount,
|
||||
startLocalMatch3DRun,
|
||||
} from '../../services/match3d-runtime';
|
||||
import {
|
||||
MATCH3D_RENDER_ITEM_SCALE,
|
||||
resolveRenderableItemFrame,
|
||||
} from './match3dRuntimePresentation';
|
||||
import {
|
||||
MATCH3D_EXTRUDED_READABLE_SHAPES,
|
||||
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE,
|
||||
applyMatch3DRendererCanvasLayout,
|
||||
buildMatch3DGeneratedAssetTypeMap,
|
||||
buildMatch3DPhysicsEntrySignature,
|
||||
buildMatch3DTrayModelSourceMap,
|
||||
createMatch3DCannonShape,
|
||||
createMatch3DThreeGeometry,
|
||||
MATCH3D_EXTRUDED_READABLE_SHAPES,
|
||||
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE,
|
||||
measureMatch3DItemPreviewDimension,
|
||||
resolveMatch3DColliderBounds,
|
||||
resolveMatch3DBoardDepthPlan,
|
||||
resolveMatch3DBoundaryRadius,
|
||||
resolveMatch3DColliderBounds,
|
||||
resolveMatch3DPhysicsStabilityPlan,
|
||||
resolveMatch3DSpawnTimingPlan,
|
||||
resolveMatch3DStackTargetY,
|
||||
resolveMatch3DSpawnDelay,
|
||||
resolveMatch3DSpawnTimingPlan,
|
||||
resolveMatch3DSpawnVisualScale,
|
||||
resolveMatch3DSpawnY,
|
||||
resolveMatch3DTrayPreviewRotation,
|
||||
resolveMatch3DStackTargetY,
|
||||
resolveMatch3DTrayPreviewReferenceDimension,
|
||||
resolveMatch3DTrayPreviewRotation,
|
||||
resolveMatch3DTrayPreviewScale,
|
||||
} from './Match3DPhysicsBoard';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
import {
|
||||
MATCH3D_RENDER_ITEM_SCALE,
|
||||
resolveRenderableItemFrame,
|
||||
} from './match3dRuntimePresentation';
|
||||
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
|
||||
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
||||
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
||||
return {
|
||||
...actual,
|
||||
Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => {
|
||||
@@ -81,6 +78,65 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockMatch3DBoardRect() {
|
||||
Object.defineProperty(
|
||||
screen.getByTestId('match3d-board'),
|
||||
'getBoundingClientRect',
|
||||
{
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 420,
|
||||
height: 320,
|
||||
left: 40,
|
||||
right: 360,
|
||||
top: 100,
|
||||
width: 320,
|
||||
x: 40,
|
||||
y: 100,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mockMatch3DPointerCapture(element: HTMLElement) {
|
||||
Object.defineProperty(element, 'setPointerCapture', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(element, 'releasePointerCapture', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
function toMatch3DBoardClientPoint(item: Match3DRunSnapshot['items'][number]) {
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
return {
|
||||
clientX: 40 + frame.x * 320,
|
||||
clientY: 100 + frame.y * 320,
|
||||
};
|
||||
}
|
||||
|
||||
function fireMatch3DBoardPointer(
|
||||
element: HTMLElement,
|
||||
type: 'pointerdown' | 'pointermove' | 'pointerup',
|
||||
point: { clientX: number; clientY: number },
|
||||
pointerId: number,
|
||||
) {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: point.clientX,
|
||||
clientY: point.clientY,
|
||||
});
|
||||
Object.defineProperty(event, 'pointerId', {
|
||||
configurable: true,
|
||||
value: pointerId,
|
||||
});
|
||||
fireEvent(element, event);
|
||||
}
|
||||
|
||||
function renderRuntime(
|
||||
run: Match3DRunSnapshot,
|
||||
generatedItemAssets: Match3DGeneratedItemAsset[] = [],
|
||||
@@ -128,11 +184,49 @@ test('展示圆形空间和 7 格备选栏', () => {
|
||||
expect(screen.getAllByTestId('match3d-tray-slot')).toHaveLength(7);
|
||||
});
|
||||
|
||||
test('顶部 HUD 对齐拼图样式展示关卡名和倒计时', () => {
|
||||
const run = startLocalMatch3DRun(4);
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
levelName="水果抓大鹅"
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('第 1 关')).toBeTruthy();
|
||||
expect(screen.getByText('水果抓大鹅')).toBeTruthy();
|
||||
expect(screen.getByText('10:00')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('推荐页抓大鹅运行态隐藏返回按钮和结算返回入口', () => {
|
||||
const run: Match3DRunSnapshot = {
|
||||
...startLocalMatch3DRun(4),
|
||||
status: 'Won',
|
||||
};
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
hideBackButton
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '再来一局' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('显示层把可消除物整体半径放大 2 倍且保留相对比例', () => {
|
||||
const run = startLocalMatch3DRun(21);
|
||||
const firstItemByType = [...new Map(
|
||||
run.items.map((item) => [item.itemTypeId, item]),
|
||||
).values()];
|
||||
const firstItemByType = [
|
||||
...new Map(run.items.map((item) => [item.itemTypeId, item])).values(),
|
||||
];
|
||||
const smallItem = firstItemByType.reduce((smallest, item) =>
|
||||
item.radius < smallest.radius ? item : smallest,
|
||||
);
|
||||
@@ -151,18 +245,139 @@ test('显示层把可消除物整体半径放大 2 倍且保留相对比例', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('点击可见物品后先乐观入槽再等待确认', async () => {
|
||||
test('松手命中可见物品后只提交一次乐观入槽', async () => {
|
||||
const run = startLocalMatch3DRun(4);
|
||||
const clickableItem = run.items.find((item) => item.clickable);
|
||||
const clickableItem = run.items
|
||||
.filter((item) => item.clickable)
|
||||
.sort((left, right) => right.layer - left.layer)[0];
|
||||
expect(clickableItem).toBeTruthy();
|
||||
const { onClickItem, onOptimisticRunChange } = renderRuntime(run);
|
||||
const board = screen.getByTestId('match3d-board');
|
||||
mockMatch3DBoardRect();
|
||||
mockMatch3DPointerCapture(board);
|
||||
const point = toMatch3DBoardClientPoint(clickableItem!);
|
||||
|
||||
fireMatch3DBoardPointer(board, 'pointerdown', point, 7);
|
||||
|
||||
expect(onClickItem).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`)
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
fireMatch3DBoardPointer(board, 'pointerup', point, 7);
|
||||
|
||||
expect(onOptimisticRunChange).toHaveBeenCalled();
|
||||
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
test('松手后的浏览器 click 事件不会重复提交同一个物品', async () => {
|
||||
const run = startLocalMatch3DRun(4);
|
||||
const clickableItem = run.items
|
||||
.filter((item) => item.clickable)
|
||||
.sort((left, right) => right.layer - left.layer)[0];
|
||||
expect(clickableItem).toBeTruthy();
|
||||
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => {
|
||||
return confirmLocalMatch3DClick(run, payload);
|
||||
});
|
||||
const onOptimisticRunChange = vi.fn();
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
const board = screen.getByTestId('match3d-board');
|
||||
mockMatch3DBoardRect();
|
||||
mockMatch3DPointerCapture(board);
|
||||
const point = toMatch3DBoardClientPoint(clickableItem!);
|
||||
|
||||
fireMatch3DBoardPointer(board, 'pointerdown', point, 7);
|
||||
fireMatch3DBoardPointer(board, 'pointerup', point, 7);
|
||||
fireEvent.click(
|
||||
screen.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`),
|
||||
);
|
||||
|
||||
expect(onOptimisticRunChange).toHaveBeenCalled();
|
||||
expect(onClickItem).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
test('拖动后按松手位置选择单个物品', async () => {
|
||||
const baseRun = startLocalMatch3DRun(2);
|
||||
const [firstItem, secondItem] = baseRun.items.slice(0, 2);
|
||||
expect(firstItem && secondItem).toBeTruthy();
|
||||
const run: Match3DRunSnapshot = {
|
||||
...baseRun,
|
||||
items: [
|
||||
{
|
||||
...firstItem!,
|
||||
clickable: true,
|
||||
itemInstanceId: 'press-start-item',
|
||||
x: 0.35,
|
||||
y: 0.5,
|
||||
layer: 1,
|
||||
radius: 0.06,
|
||||
},
|
||||
{
|
||||
...secondItem!,
|
||||
clickable: true,
|
||||
itemInstanceId: 'release-target-item',
|
||||
x: 0.68,
|
||||
y: 0.5,
|
||||
layer: 2,
|
||||
radius: 0.06,
|
||||
},
|
||||
...baseRun.items.slice(2).map((item) => ({
|
||||
...item,
|
||||
clickable: false,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
layer: 0,
|
||||
})),
|
||||
],
|
||||
};
|
||||
const { onClickItem, onOptimisticRunChange } = renderRuntime(run);
|
||||
const board = screen.getByTestId('match3d-board');
|
||||
mockMatch3DBoardRect();
|
||||
mockMatch3DPointerCapture(board);
|
||||
|
||||
fireMatch3DBoardPointer(
|
||||
board,
|
||||
'pointerdown',
|
||||
toMatch3DBoardClientPoint(run.items[0]!),
|
||||
9,
|
||||
);
|
||||
fireMatch3DBoardPointer(
|
||||
board,
|
||||
'pointermove',
|
||||
toMatch3DBoardClientPoint(run.items[1]!),
|
||||
9,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-item-release-target-item')
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
});
|
||||
fireMatch3DBoardPointer(
|
||||
board,
|
||||
'pointerup',
|
||||
toMatch3DBoardClientPoint(run.items[1]!),
|
||||
9,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
|
||||
expect(onClickItem.mock.calls[0]?.[0].itemInstanceId).toBe(
|
||||
'release-target-item',
|
||||
);
|
||||
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
@@ -189,7 +404,7 @@ test('运行态按物品实例稳定使用生成 2D 视角素材', () => {
|
||||
itemTypeId: selectedItem.itemTypeId,
|
||||
visualKey: selectedItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
|
||||
@@ -212,8 +427,161 @@ test('运行态按物品实例稳定使用生成 2D 视角素材', () => {
|
||||
|
||||
renderRuntime(nextRun, generatedItemAssets);
|
||||
|
||||
const trayImage = screen.getByTestId('match3d-tray-image') as HTMLImageElement;
|
||||
expect(trayImage.src).toContain('/match3d/strawberry-view-');
|
||||
const trayImage = screen.getByTestId(
|
||||
'match3d-tray-image',
|
||||
) as HTMLImageElement;
|
||||
expect(trayImage.src).toContain('/match3d/strawberry-view-1.png');
|
||||
});
|
||||
|
||||
test('运行态按生成素材的相对尺寸缩放场内和托盘图片', () => {
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const selectedItem = run.items[0]!;
|
||||
const nextRun: Match3DRunSnapshot = {
|
||||
...run,
|
||||
items: run.items.map((item, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...item,
|
||||
state: 'InTray' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: 0,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === 0
|
||||
? {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: selectedItem.itemInstanceId,
|
||||
itemTypeId: selectedItem.itemTypeId,
|
||||
visualKey: selectedItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '糖果',
|
||||
itemSize: '小',
|
||||
imageSrc: '/match3d/candy.png',
|
||||
imageObjectKey: null,
|
||||
imageViews: [],
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
];
|
||||
|
||||
renderRuntime(nextRun, generatedItemAssets);
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByTestId('match3d-token-image')
|
||||
.every((image) => image.style.transform === 'scale(0.58)'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByTestId('match3d-tray-image') as HTMLElement).style.transform,
|
||||
).toBe('scale(0.58)');
|
||||
});
|
||||
|
||||
test('点击物品时播放飞入底部栏位动画并使用第一张物品视图', async () => {
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const clickableItem = run.items.find((item) => item.clickable)!;
|
||||
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
|
||||
viewId: `view-${viewIndex}`,
|
||||
viewIndex,
|
||||
imageSrc: `/match3d/strawberry-view-${viewIndex}.png`,
|
||||
imageObjectKey: null,
|
||||
})),
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
];
|
||||
|
||||
const { onClickItem, onOptimisticRunChange } = renderRuntime(
|
||||
run,
|
||||
generatedItemAssets,
|
||||
);
|
||||
const board = screen.getByTestId('match3d-board');
|
||||
mockMatch3DBoardRect();
|
||||
mockMatch3DPointerCapture(board);
|
||||
Object.defineProperty(
|
||||
screen.getAllByTestId('match3d-tray-slot')[0]!,
|
||||
'getBoundingClientRect',
|
||||
{
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 530,
|
||||
height: 56,
|
||||
left: 52,
|
||||
right: 108,
|
||||
top: 474,
|
||||
width: 56,
|
||||
x: 52,
|
||||
y: 474,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'),
|
||||
).toContain('/match3d/strawberry-view-');
|
||||
});
|
||||
|
||||
const point = toMatch3DBoardClientPoint(clickableItem);
|
||||
fireMatch3DBoardPointer(board, 'pointerdown', point, 12);
|
||||
fireMatch3DBoardPointer(board, 'pointerup', point, 12);
|
||||
|
||||
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
|
||||
const flyingImage = screen.getByTestId('match3d-flying-token-image');
|
||||
expect(flyingImage.getAttribute('src')).toContain(
|
||||
'/match3d/strawberry-view-1.png',
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-flying-token')
|
||||
.style.getPropertyValue('--match3d-fly-dy'),
|
||||
).not.toBe('0px');
|
||||
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
test('直接点击物品按钮不会绕过松手位置判定', () => {
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const clickableItem = run.items.find((item) => item.clickable)!;
|
||||
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: '/match3d/strawberry-view-1.png',
|
||||
imageObjectKey: null,
|
||||
imageViews: [],
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
];
|
||||
|
||||
const { onClickItem, onOptimisticRunChange } = renderRuntime(
|
||||
run,
|
||||
generatedItemAssets,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByTestId(`match3d-item-${clickableItem.itemInstanceId}`),
|
||||
);
|
||||
|
||||
expect(onClickItem).not.toHaveBeenCalled();
|
||||
expect(onOptimisticRunChange).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId('match3d-flying-token-image')).toBeNull();
|
||||
});
|
||||
|
||||
test('3D WebGL 画布锁定 CSS 尺寸,避免高 DPR 手机上溢出中心棋盘', () => {
|
||||
@@ -234,9 +602,7 @@ test('3D 物理条目签名随 run 和视觉资源变化,避免旧模型复用
|
||||
const sameIdDifferentVisual = {
|
||||
...item,
|
||||
visualKey:
|
||||
item.visualKey === 'block-red-2x4'
|
||||
? 'block-blue-1x2'
|
||||
: 'block-red-2x4',
|
||||
item.visualKey === 'block-red-2x4' ? 'block-blue-1x2' : 'block-red-2x4',
|
||||
};
|
||||
|
||||
expect(buildMatch3DPhysicsEntrySignature(run.runId, item)).not.toBe(
|
||||
@@ -279,10 +645,7 @@ test('生成模型按运行态类型编号映射,并兼容仅 modelObjectKey
|
||||
},
|
||||
];
|
||||
|
||||
const boardMap = buildMatch3DGeneratedAssetTypeMap(
|
||||
run,
|
||||
generatedItemAssets,
|
||||
);
|
||||
const boardMap = buildMatch3DGeneratedAssetTypeMap(run, generatedItemAssets);
|
||||
const trayMap = buildMatch3DTrayModelSourceMap(
|
||||
run.items,
|
||||
[],
|
||||
@@ -298,9 +661,7 @@ test('生成模型按运行态类型编号映射,并兼容仅 modelObjectKey
|
||||
expect(trayMap.get('match3d-type-01')).toBe(
|
||||
generatedItemAssets[0]!.modelObjectKey,
|
||||
);
|
||||
expect(trayMap.get('match3d-type-02')).toBe(
|
||||
generatedItemAssets[1]!.modelSrc,
|
||||
);
|
||||
expect(trayMap.get('match3d-type-02')).toBe(generatedItemAssets[1]!.modelSrc);
|
||||
});
|
||||
|
||||
test('运行态会先换签 generated 图片素材再渲染局内物品', async () => {
|
||||
@@ -315,8 +676,7 @@ test('运行态会先换签 generated 图片素材再渲染局内物品', async
|
||||
viewId: `view-${viewIndex}`,
|
||||
viewIndex,
|
||||
imageSrc: null,
|
||||
imageObjectKey:
|
||||
`generated-match3d-assets/session/profile/items/match3d-item-1/views/view-${viewIndex}.png`,
|
||||
imageObjectKey: `generated-match3d-assets/session/profile/items/match3d-item-1/views/view-${viewIndex}.png`,
|
||||
})),
|
||||
status: 'image_ready',
|
||||
modelSrc: null,
|
||||
@@ -352,23 +712,176 @@ test('运行态会先换签 generated 图片素材再渲染局内物品', async
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('match3d-token-image').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId('match3d-token-image').length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
expect(screen.getAllByTestId('match3d-token-image')[0]!.getAttribute('src')).toBe(
|
||||
'https://oss.example.com/match3d-view.png',
|
||||
expect(
|
||||
screen.getAllByTestId('match3d-token-image')[0]!.getAttribute('src'),
|
||||
).toBe('https://oss.example.com/match3d-view.png');
|
||||
});
|
||||
|
||||
test('generated 图片素材换签未完成前不显示默认积木', async () => {
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [
|
||||
{
|
||||
viewId: 'view-01',
|
||||
viewIndex: 1,
|
||||
imageSrc: null,
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1/views/view-01.png',
|
||||
},
|
||||
],
|
||||
status: 'image_ready',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
},
|
||||
];
|
||||
let resolveFetch: (response: Response) => void = (_response: Response) => {
|
||||
throw new Error('read-url fetch was not started');
|
||||
};
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
generatedItemAssets={generatedItemAssets}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('match3d-token-image')).toBeNull();
|
||||
expect(screen.queryAllByTestId(/^match3d-visual-/u)).toHaveLength(0);
|
||||
|
||||
resolveFetch(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
signedUrl: 'https://oss.example.com/match3d-view.png',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'),
|
||||
).toBe('https://oss.example.com/match3d-view.png');
|
||||
});
|
||||
});
|
||||
|
||||
test('同一批 generated 图片素材在重启 run 时保留已解析地址', async () => {
|
||||
const firstRun = startLocalMatch3DRun(1);
|
||||
const secondRun: Match3DRunSnapshot = {
|
||||
...startLocalMatch3DRun(1),
|
||||
profileId: firstRun.profileId,
|
||||
items: firstRun.items.map((item) => ({
|
||||
...item,
|
||||
itemInstanceId: `${item.itemInstanceId}-restart`,
|
||||
})),
|
||||
};
|
||||
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [
|
||||
{
|
||||
viewId: 'view-01',
|
||||
viewIndex: 1,
|
||||
imageSrc: null,
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1/views/view-01.png',
|
||||
},
|
||||
],
|
||||
status: 'image_ready',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
},
|
||||
];
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
signedUrl: 'https://oss.example.com/match3d-view.png',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<Match3DRuntimeShell
|
||||
run={firstRun}
|
||||
generatedItemAssets={generatedItemAssets}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'),
|
||||
).toBe('https://oss.example.com/match3d-view.png');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Match3DRuntimeShell
|
||||
run={secondRun}
|
||||
generatedItemAssets={generatedItemAssets}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByTestId('match3d-token-image')[0]?.getAttribute('src'),
|
||||
).toBe('https://oss.example.com/match3d-view.png');
|
||||
expect(screen.queryAllByTestId(/^match3d-visual-/u)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('运行态按 generated itemId 编号映射到后端 match3d-type 类型', async () => {
|
||||
const baseRun = startLocalMatch3DRun(3);
|
||||
const baseTypeIds = [...new Set(baseRun.items.map((item) => item.itemTypeId))];
|
||||
const baseTypeIds = [
|
||||
...new Set(baseRun.items.map((item) => item.itemTypeId)),
|
||||
];
|
||||
const run: Match3DRunSnapshot = {
|
||||
...baseRun,
|
||||
items: baseRun.items.map((item) =>
|
||||
item.itemTypeId === baseTypeIds[0]
|
||||
? {...item, itemTypeId: 'match3d-type-01'}
|
||||
? { ...item, itemTypeId: 'match3d-type-01' }
|
||||
: item.itemTypeId === baseTypeIds[1]
|
||||
? {...item, itemTypeId: 'match3d-type-02'}
|
||||
? { ...item, itemTypeId: 'match3d-type-02' }
|
||||
: item,
|
||||
),
|
||||
};
|
||||
@@ -475,6 +988,13 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
|
||||
).toBe('https://oss.example.com/match3d-container.png');
|
||||
});
|
||||
fireEvent.load(screen.getByTestId('match3d-container-image'));
|
||||
const containerImage = screen.getByTestId(
|
||||
'match3d-container-image',
|
||||
) as HTMLImageElement;
|
||||
expect(containerImage.className).toContain('w-[min(96vw,28rem)]');
|
||||
expect(containerImage.className).toContain('h-auto');
|
||||
expect(containerImage.className).toContain('left-1/2');
|
||||
expect(containerImage.className).toContain('-translate-x-1/2');
|
||||
expect(screen.getByTestId('match3d-board').className).toContain(
|
||||
'bg-transparent',
|
||||
);
|
||||
@@ -706,12 +1226,10 @@ test('硬核档位生成不重复积木视觉签名', () => {
|
||||
[...firstItemByType.values()].map((item) => item.visualKey),
|
||||
);
|
||||
const signatures = new Set(
|
||||
[...firstItemByType.values()].map(
|
||||
(item) => {
|
||||
const asset = resolveGeometryAsset(item.visualKey);
|
||||
return `${asset.shape}-${asset.fill}-${asset.studsX}x${asset.studsY}-${asset.heightScale}`;
|
||||
},
|
||||
),
|
||||
[...firstItemByType.values()].map((item) => {
|
||||
const asset = resolveGeometryAsset(item.visualKey);
|
||||
return `${asset.shape}-${asset.fill}-${asset.studsX}x${asset.studsY}-${asset.heightScale}`;
|
||||
}),
|
||||
);
|
||||
|
||||
expect(firstItemByType.size).toBe(21);
|
||||
@@ -721,8 +1239,8 @@ test('硬核档位生成不重复积木视觉签名', () => {
|
||||
|
||||
test('积木池覆盖参考图里的特殊件', () => {
|
||||
const shapes = new Set(
|
||||
startLocalMatch3DRun(21).items.map((item) =>
|
||||
resolveGeometryAsset(item.visualKey).shape,
|
||||
startLocalMatch3DRun(21).items.map(
|
||||
(item) => resolveGeometryAsset(item.visualKey).shape,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -755,17 +1273,21 @@ test('进阶档位保持 15 种视觉模型并按三消组复用', () => {
|
||||
item.visualKey,
|
||||
(countByVisualKey.get(item.visualKey) ?? 0) + 1,
|
||||
);
|
||||
typeByVisualKey.set(item.visualKey, typeByVisualKey.get(item.visualKey) ?? new Set());
|
||||
typeByVisualKey.set(
|
||||
item.visualKey,
|
||||
typeByVisualKey.get(item.visualKey) ?? new Set(),
|
||||
);
|
||||
typeByVisualKey.get(item.visualKey)!.add(item.itemTypeId);
|
||||
}
|
||||
|
||||
expect(countByVisualKey.size).toBe(15);
|
||||
expect([...countByVisualKey.values()].sort((left, right) => left - right)).toEqual([
|
||||
...Array(14).fill(3),
|
||||
6,
|
||||
]);
|
||||
expect(
|
||||
[...typeByVisualKey.values()].every((itemTypeIds) => itemTypeIds.size === 1),
|
||||
[...countByVisualKey.values()].sort((left, right) => left - right),
|
||||
).toEqual([...Array(14).fill(3), 6]);
|
||||
expect(
|
||||
[...typeByVisualKey.values()].every(
|
||||
(itemTypeIds) => itemTypeIds.size === 1,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -824,15 +1346,17 @@ test('同一视觉模型在复用时保持唯一尺寸', () => {
|
||||
}
|
||||
|
||||
expect(radiiByVisualKey.size).toBe(21);
|
||||
expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true);
|
||||
expect(
|
||||
[...radiiByVisualKey.values()].every((radii) => radii.size === 1),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('托盘 3D 预览保留场内模型的相对尺寸比例', async () => {
|
||||
const three = await import('three');
|
||||
const run = startLocalMatch3DRun(21);
|
||||
const firstItemByType = [...new Map(
|
||||
run.items.map((item) => [item.itemTypeId, item]),
|
||||
).values()];
|
||||
const firstItemByType = [
|
||||
...new Map(run.items.map((item) => [item.itemTypeId, item])).values(),
|
||||
];
|
||||
const referenceDimension = resolveMatch3DTrayPreviewReferenceDimension(
|
||||
three,
|
||||
firstItemByType,
|
||||
@@ -840,8 +1364,7 @@ test('托盘 3D 预览保留场内模型的相对尺寸比例', async () => {
|
||||
const previewRatios = new Set(
|
||||
firstItemByType.map((item) =>
|
||||
Math.round(
|
||||
(measureMatch3DItemPreviewDimension(three, item) /
|
||||
referenceDimension) *
|
||||
(measureMatch3DItemPreviewDimension(three, item) / referenceDimension) *
|
||||
1_000,
|
||||
),
|
||||
),
|
||||
@@ -880,8 +1403,7 @@ test('托盘 3D 预览为小模型保留最低可读显示尺寸', () => {
|
||||
);
|
||||
|
||||
expect(scale * smallDimension).toBeGreaterThanOrEqual(
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE *
|
||||
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE * MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
|
||||
);
|
||||
expect(scale).toBeGreaterThan(
|
||||
MATCH3D_TRAY_MODEL_TARGET_SIZE / referenceDimension,
|
||||
@@ -914,12 +1436,12 @@ test('3D 物体碰撞体按同款视觉尺寸生成', async () => {
|
||||
const longBrickBounds = resolveMatch3DColliderBounds(longBrick, radius);
|
||||
const longBrickShape = createMatch3DCannonShape(cannon, longBrick, radius);
|
||||
expect(longBrickShape.type).toBe(cannon.Shape.types.BOX);
|
||||
expect((longBrickShape as import('cannon-es').Box).halfExtents.x * 2).toBeCloseTo(
|
||||
longBrickBounds.width,
|
||||
);
|
||||
expect((longBrickShape as import('cannon-es').Box).halfExtents.z * 2).toBeCloseTo(
|
||||
longBrickBounds.depth,
|
||||
);
|
||||
expect(
|
||||
(longBrickShape as import('cannon-es').Box).halfExtents.x * 2,
|
||||
).toBeCloseTo(longBrickBounds.width);
|
||||
expect(
|
||||
(longBrickShape as import('cannon-es').Box).halfExtents.z * 2,
|
||||
).toBeCloseTo(longBrickBounds.depth);
|
||||
|
||||
const tileBounds = resolveMatch3DColliderBounds(tile, radius);
|
||||
const tileShape = createMatch3DCannonShape(cannon, tile, radius);
|
||||
@@ -930,9 +1452,9 @@ test('3D 物体碰撞体按同款视觉尺寸生成', async () => {
|
||||
const cylinderBounds = resolveMatch3DColliderBounds(cylinder, radius);
|
||||
const cylinderShape = createMatch3DCannonShape(cannon, cylinder, radius);
|
||||
expect(cylinderShape.type).toBe(cannon.Shape.types.CYLINDER);
|
||||
expect(
|
||||
(cylinderShape as import('cannon-es').Cylinder).height,
|
||||
).toBeCloseTo(cylinderBounds.height);
|
||||
expect((cylinderShape as import('cannon-es').Cylinder).height).toBeCloseTo(
|
||||
cylinderBounds.height,
|
||||
);
|
||||
});
|
||||
|
||||
test('中心场地 3D 纵深随物体总量增加并随消除进度回补', () => {
|
||||
@@ -947,9 +1469,7 @@ test('中心场地 3D 纵深随物体总量增加并随消除进度回补', () =
|
||||
expect(largeDepthPlan.layerCapacity).toBeLessThan(
|
||||
smallDepthPlan.layerCapacity,
|
||||
);
|
||||
expect(largeDepthPlan.layerCount).toBeGreaterThan(
|
||||
smallDepthPlan.layerCount,
|
||||
);
|
||||
expect(largeDepthPlan.layerCount).toBeGreaterThan(smallDepthPlan.layerCount);
|
||||
expect(largeDepthPlan.surfaceY).toBeGreaterThan(largeDepthPlan.baseY);
|
||||
expect(lateBottomY).toBeGreaterThan(earlyBottomY);
|
||||
expect(lateBottomY).toBeLessThanOrEqual(largeDepthPlan.surfaceY);
|
||||
@@ -959,9 +1479,7 @@ test('高数量 3D 局面使用更稳定的物理参数', () => {
|
||||
const smallPlan = resolveMatch3DPhysicsStabilityPlan(30);
|
||||
const largePlan = resolveMatch3DPhysicsStabilityPlan(300);
|
||||
|
||||
expect(largePlan.contactFriction).toBeGreaterThan(
|
||||
smallPlan.contactFriction,
|
||||
);
|
||||
expect(largePlan.contactFriction).toBeGreaterThan(smallPlan.contactFriction);
|
||||
expect(largePlan.contactRestitution).toBeLessThan(
|
||||
smallPlan.contactRestitution,
|
||||
);
|
||||
@@ -995,7 +1513,10 @@ test('100 次局面的新物体会按层级延迟生成并逐层回落', () => {
|
||||
const smallTimingPlan = resolveMatch3DSpawnTimingPlan(30);
|
||||
const largeTimingPlan = resolveMatch3DSpawnTimingPlan(300);
|
||||
const bottomDelay = resolveMatch3DSpawnDelay(0, largeDepthPlan.layerCapacity);
|
||||
const middleDelay = resolveMatch3DSpawnDelay(30, largeDepthPlan.layerCapacity);
|
||||
const middleDelay = resolveMatch3DSpawnDelay(
|
||||
30,
|
||||
largeDepthPlan.layerCapacity,
|
||||
);
|
||||
const topDelay = resolveMatch3DSpawnDelay(120, largeDepthPlan.layerCapacity);
|
||||
const dynamicCapacityDelay = resolveMatch3DSpawnDelay(
|
||||
120,
|
||||
@@ -1031,7 +1552,11 @@ test('100 次局面的新物体会按层级延迟生成并逐层回落', () => {
|
||||
smallTimingPlan.layerDelayMs,
|
||||
);
|
||||
expect(
|
||||
resolveMatch3DSpawnDelay(299, largeDepthPlan.layerCapacity, largeTimingPlan),
|
||||
resolveMatch3DSpawnDelay(
|
||||
299,
|
||||
largeDepthPlan.layerCapacity,
|
||||
largeTimingPlan,
|
||||
),
|
||||
).toBeGreaterThan(5000);
|
||||
});
|
||||
|
||||
@@ -1125,7 +1650,9 @@ test('积木视觉键渲染为无文字纯色图标', () => {
|
||||
|
||||
expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByTestId('match3d-visual-block-clear-ring').getAttribute('data-shape'),
|
||||
screen
|
||||
.getByTestId('match3d-visual-block-clear-ring')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('ring');
|
||||
expect(
|
||||
screen
|
||||
@@ -1156,7 +1683,9 @@ test('运行态支持长条、斜坡和圆柱等差异化积木造型', () => {
|
||||
renderRuntime(run);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('match3d-visual-block-black-1x8').getAttribute('data-shape'),
|
||||
screen
|
||||
.getByTestId('match3d-visual-block-black-1x8')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('brick');
|
||||
expect(
|
||||
screen
|
||||
@@ -1192,3 +1721,27 @@ test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', ()
|
||||
expect(parseFloat(token.style.left)).toBeGreaterThanOrEqual(0);
|
||||
expect(parseFloat(token.style.left)).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
test('本地运行态物品围绕容器口中心生成,不贴边挤在局部', () => {
|
||||
const run = startLocalMatch3DRun(21);
|
||||
const boardItems = run.items.filter((item) => item.state === 'InBoard');
|
||||
const meanX =
|
||||
boardItems.reduce((sum, item) => sum + item.x, 0) / boardItems.length;
|
||||
const meanY =
|
||||
boardItems.reduce((sum, item) => sum + item.y, 0) / boardItems.length;
|
||||
const distances = boardItems.map((item) =>
|
||||
Math.hypot(item.x - 0.5, item.y - 0.5),
|
||||
);
|
||||
const farItems = distances.filter((distance) => distance > 0.26);
|
||||
const quadrants = new Set(
|
||||
boardItems.map(
|
||||
(item) => `${item.x >= 0.5 ? 'r' : 'l'}-${item.y >= 0.5 ? 'b' : 't'}`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(Math.abs(meanX - 0.5)).toBeLessThan(0.035);
|
||||
expect(Math.abs(meanY - 0.5)).toBeLessThan(0.035);
|
||||
expect(Math.max(...distances)).toBeLessThan(0.4);
|
||||
expect(farItems.length).toBeGreaterThan(0);
|
||||
expect(quadrants.size).toBe(4);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type PointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -47,16 +48,16 @@ import {
|
||||
isRunState,
|
||||
resolveRenderableItemFrame,
|
||||
} from './match3dRuntimePresentation';
|
||||
import {
|
||||
Match3DVisualIcon,
|
||||
resolveVisualSeed,
|
||||
} from './match3dVisualAssets';
|
||||
import {
|
||||
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
|
||||
MATCH3D_RUNTIME_GLASS_TIMER_CLASS,
|
||||
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
|
||||
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
|
||||
MATCH3D_RUNTIME_HEADER_CARD_CLASS,
|
||||
MATCH3D_RUNTIME_LEVEL_BADGE_CLASS,
|
||||
MATCH3D_RUNTIME_TIMER_CLASS,
|
||||
MATCH3D_RUNTIME_TIMER_URGENT_CLASS,
|
||||
} from './match3dRuntimeUiStyles';
|
||||
import { Match3DVisualIcon, resolveVisualSeed } from './match3dVisualAssets';
|
||||
|
||||
type Match3DRuntimeShellProps = {
|
||||
run: Match3DRunSnapshot | null;
|
||||
@@ -66,6 +67,8 @@ type Match3DRuntimeShellProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
embedded?: boolean;
|
||||
hideBackButton?: boolean;
|
||||
levelName?: string | null;
|
||||
onBack: () => void;
|
||||
onRestart: () => void;
|
||||
onOptimisticRunChange: (run: Match3DRunSnapshot) => void;
|
||||
@@ -87,6 +90,26 @@ type Match3DFeedbackEvent = {
|
||||
itemIds: string[];
|
||||
};
|
||||
|
||||
type Match3DBoardPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type Match3DFlyingTrayAnimation = {
|
||||
id: string;
|
||||
item: Match3DItemSnapshot;
|
||||
imageSrc: string;
|
||||
itemSize: Match3DGeneratedItemRelativeSize;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
fromSize: number;
|
||||
toSize: number;
|
||||
};
|
||||
|
||||
type Match3DGeneratedItemRelativeSize = '大' | '中' | '小';
|
||||
|
||||
function resolveTrayPreviewItem(
|
||||
run: Match3DRunSnapshot,
|
||||
slot: Match3DTraySlot,
|
||||
@@ -108,6 +131,7 @@ function resolveTrayPreviewItem(
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
@@ -156,6 +180,20 @@ function findHitItem(run: Match3DRunSnapshot, pointX: number, pointY: number) {
|
||||
.sort((left, right) => right.layer - left.layer)[0];
|
||||
}
|
||||
|
||||
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 = Number.parseInt(left.match(/(\d+)$/u)?.[1] ?? '', 10);
|
||||
const rightIndex = Number.parseInt(right.match(/(\d+)$/u)?.[1] ?? '', 10);
|
||||
@@ -203,22 +241,19 @@ function buildMatch3DImageSourcesByType(
|
||||
const asset =
|
||||
readyAssets.find(
|
||||
(entry) => directIndex !== null && entry.itemIndex === directIndex,
|
||||
) ??
|
||||
readyAssets.find((entry) => entry.fallbackIndex === index);
|
||||
) ?? readyAssets.find((entry) => entry.fallbackIndex === index);
|
||||
return asset ? [[typeId, asset.sources] as const] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function buildMatch3DImageSourceSignature(
|
||||
function resolveMatch3DImageReadUrlCacheKey(
|
||||
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
||||
) {
|
||||
return [...imageSourcesByType.entries()]
|
||||
.map(([typeId, sources]) => `${typeId}:${sources.join(',')}`)
|
||||
.join('|');
|
||||
return resolveMatch3DImageReadUrlSources(imageSourcesByType).join('|');
|
||||
}
|
||||
|
||||
function resolveMatch3DImageReadUrlCacheKey(
|
||||
function resolveMatch3DImageReadUrlSources(
|
||||
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
||||
) {
|
||||
return [
|
||||
@@ -230,7 +265,15 @@ function resolveMatch3DImageReadUrlCacheKey(
|
||||
),
|
||||
]
|
||||
.sort()
|
||||
.join('|');
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveStaticMatch3DReadUrlMap(sources: readonly string[]) {
|
||||
return new Map(
|
||||
sources.flatMap((source) =>
|
||||
isGeneratedLegacyPath(source) ? [] : [[source, source] as const],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildResolvedMatch3DImageSourcesByType(
|
||||
@@ -253,6 +296,56 @@ function buildResolvedMatch3DImageSourcesByType(
|
||||
);
|
||||
}
|
||||
|
||||
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 resolveMatch3DItemSizeScale(
|
||||
itemSize: Match3DGeneratedItemRelativeSize | undefined,
|
||||
) {
|
||||
if (itemSize === '小') {
|
||||
return 0.58;
|
||||
}
|
||||
if (itemSize === '中') {
|
||||
return 0.78;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function hashMatch3DString(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
@@ -272,6 +365,38 @@ function resolveMatch3DImageForItem(
|
||||
return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? '';
|
||||
}
|
||||
|
||||
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 resolveMatch3DFirstImageForItem(
|
||||
item: Match3DItemSnapshot,
|
||||
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
||||
) {
|
||||
return imageSourcesByType.get(item.itemTypeId)?.[0] ?? '';
|
||||
}
|
||||
|
||||
function resolveMatch3DItemSizeForType(
|
||||
item: Pick<Match3DItemSnapshot, 'itemTypeId'>,
|
||||
itemSizeByType: ReadonlyMap<string, Match3DGeneratedItemRelativeSize>,
|
||||
) {
|
||||
return itemSizeByType.get(item.itemTypeId) ?? '大';
|
||||
}
|
||||
|
||||
function buildOptimisticRun(
|
||||
run: Match3DRunSnapshot,
|
||||
item: Match3DItemSnapshot,
|
||||
@@ -307,13 +432,15 @@ function buildOptimisticRun(
|
||||
function Match3DToken({
|
||||
item,
|
||||
imageSrc,
|
||||
itemSize,
|
||||
disabled,
|
||||
onClick,
|
||||
selected,
|
||||
}: {
|
||||
item: Match3DItemSnapshot;
|
||||
imageSrc?: string;
|
||||
itemSize?: Match3DGeneratedItemRelativeSize;
|
||||
disabled: boolean;
|
||||
onClick: (item: Match3DItemSnapshot) => void;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const visualSeed = resolveVisualSeed(item.visualKey);
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
@@ -321,8 +448,11 @@ function Match3DToken({
|
||||
const itemStateClass = isItemState(item.state, 'flying')
|
||||
? 'scale-75 opacity-0'
|
||||
: item.clickable
|
||||
? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95'
|
||||
? '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') &&
|
||||
@@ -334,7 +464,7 @@ function Match3DToken({
|
||||
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-300 ${itemStateClass}`}
|
||||
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}%`,
|
||||
@@ -343,11 +473,11 @@ function Match3DToken({
|
||||
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')
|
||||
}
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
@@ -356,10 +486,16 @@ function Match3DToken({
|
||||
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" />
|
||||
<Match3DVisualIcon
|
||||
visualKey={item.visualKey}
|
||||
className="relative z-10"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -368,9 +504,13 @@ function Match3DToken({
|
||||
function Match3DTrayToken({
|
||||
slot,
|
||||
imageSrc,
|
||||
itemSize,
|
||||
isArriving = false,
|
||||
}: {
|
||||
slot: Match3DTraySlot;
|
||||
imageSrc?: string;
|
||||
itemSize?: Match3DGeneratedItemRelativeSize;
|
||||
isArriving?: boolean;
|
||||
}) {
|
||||
if (!slot.visualKey) {
|
||||
return (
|
||||
@@ -380,7 +520,9 @@ function Match3DTrayToken({
|
||||
const visualSeed = resolveVisualSeed(slot.visualKey);
|
||||
return (
|
||||
<span
|
||||
className="flex h-full w-full items-center justify-center p-1"
|
||||
className={`flex h-full w-full items-center justify-center p-1 transition-opacity duration-150 ${
|
||||
isArriving ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
aria-label={visualSeed.label}
|
||||
>
|
||||
{imageSrc ? (
|
||||
@@ -390,6 +532,9 @@ function Match3DTrayToken({
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
@@ -399,12 +544,65 @@ function Match3DTrayToken({
|
||||
);
|
||||
}
|
||||
|
||||
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 Match3DSettlement({
|
||||
run,
|
||||
hideBackButton,
|
||||
onBack,
|
||||
onRestart,
|
||||
}: {
|
||||
run: Match3DRunSnapshot;
|
||||
hideBackButton?: boolean;
|
||||
onBack: () => void;
|
||||
onRestart: () => void;
|
||||
}) {
|
||||
@@ -441,14 +639,16 @@ function Match3DSettlement({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-black text-slate-700"
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<div className={`grid gap-2 ${hideBackButton ? '' : 'grid-cols-2'}`}>
|
||||
{!hideBackButton ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-black text-slate-700"
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white"
|
||||
@@ -464,12 +664,14 @@ function Match3DSettlement({
|
||||
|
||||
export function Match3DRuntimeShell({
|
||||
run,
|
||||
generatedItemAssets = [],
|
||||
generatedItemAssets = EMPTY_MATCH3D_GENERATED_ITEM_ASSETS,
|
||||
generatedBackgroundAsset = null,
|
||||
backgroundImageSrc = null,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
embedded = false,
|
||||
hideBackButton = false,
|
||||
levelName = null,
|
||||
onBack,
|
||||
onRestart,
|
||||
onOptimisticRunChange,
|
||||
@@ -478,12 +680,20 @@ export function Match3DRuntimeShell({
|
||||
}: 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 [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 [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
|
||||
const [resolvedBackgroundImageSrc, setResolvedBackgroundImageSrc] =
|
||||
useState('');
|
||||
@@ -522,6 +732,18 @@ export function Match3DRuntimeShell({
|
||||
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 (!run) {
|
||||
clearSoundKeyRef.current = null;
|
||||
@@ -592,20 +814,28 @@ export function Match3DRuntimeShell({
|
||||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
'',
|
||||
)
|
||||
.find(Boolean) || '';
|
||||
.find(Boolean) ||
|
||||
'';
|
||||
const containerAssetSrc =
|
||||
generatedContainerAssetSrc || MATCH3D_CONTAINER_REFERENCE_SRC;
|
||||
const imageSourcesByType = useMemo(
|
||||
() => buildMatch3DImageSourcesByType(run, runtimeGeneratedItemAssets),
|
||||
[runtimeGeneratedItemAssets, run],
|
||||
);
|
||||
const imageSourceSignature = useMemo(
|
||||
() => buildMatch3DImageSourceSignature(imageSourcesByType),
|
||||
const itemSizeByType = useMemo(
|
||||
() => buildMatch3DItemSizeByType(run, runtimeGeneratedItemAssets),
|
||||
[runtimeGeneratedItemAssets, run],
|
||||
);
|
||||
const imageReadUrlCacheKey = useMemo(
|
||||
() => resolveMatch3DImageReadUrlCacheKey(imageSourcesByType),
|
||||
[imageSourcesByType],
|
||||
);
|
||||
const [resolvedImageSources, setResolvedImageSources] = useState<
|
||||
Map<string, string>
|
||||
>(() => new Map());
|
||||
const [failedImageSources, setFailedImageSources] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const resolvedImageSourcesByType = useMemo(
|
||||
() =>
|
||||
buildResolvedMatch3DImageSourcesByType(
|
||||
@@ -617,8 +847,10 @@ export function Match3DRuntimeShell({
|
||||
const backgroundMusicSrc =
|
||||
runtimeGeneratedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
|
||||
?.backgroundMusic?.audioSrc ?? null;
|
||||
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] = useState('');
|
||||
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] = useState('');
|
||||
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] =
|
||||
useState('');
|
||||
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] =
|
||||
useState('');
|
||||
const [isContainerImageLoaded, setIsContainerImageLoaded] = useState(false);
|
||||
const hasRenderedContainerAsset = Boolean(
|
||||
resolvedContainerImageSrc && isContainerImageLoaded,
|
||||
@@ -643,7 +875,12 @@ export function Match3DRuntimeShell({
|
||||
|
||||
const tryPlayBackgroundMusic = useCallback(() => {
|
||||
const audio = backgroundAudioRef.current;
|
||||
if (!audio || !resolvedBackgroundMusicSrc || !run || !isRunState(run.status, 'running')) {
|
||||
if (
|
||||
!audio ||
|
||||
!resolvedBackgroundMusicSrc ||
|
||||
!run ||
|
||||
!isRunState(run.status, 'running')
|
||||
) {
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
}
|
||||
@@ -705,6 +942,10 @@ export function Match3DRuntimeShell({
|
||||
setResolvedBackgroundImageSrc('');
|
||||
return undefined;
|
||||
}
|
||||
if (!isGeneratedLegacyPath(backgroundAssetSrc)) {
|
||||
setResolvedBackgroundImageSrc(backgroundAssetSrc);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
@@ -734,6 +975,10 @@ export function Match3DRuntimeShell({
|
||||
const controller = new AbortController();
|
||||
setResolvedContainerImageSrc('');
|
||||
setIsContainerImageLoaded(false);
|
||||
if (!isGeneratedLegacyPath(containerAssetSrc)) {
|
||||
setResolvedContainerImageSrc(containerAssetSrc);
|
||||
return undefined;
|
||||
}
|
||||
void resolveAssetReadUrl(containerAssetSrc, {
|
||||
signal: controller.signal,
|
||||
expireSeconds: 300,
|
||||
@@ -762,44 +1007,67 @@ export function Match3DRuntimeShell({
|
||||
}, [containerAssetSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
const rawSources = [
|
||||
...new Set(
|
||||
[...imageSourcesByType.values()]
|
||||
.flatMap((sources) => sources)
|
||||
.map((source) => source.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
const rawSources = resolveMatch3DImageReadUrlSources(imageSourcesByType);
|
||||
if (rawSources.length <= 0) {
|
||||
setResolvedImageSources(new Map());
|
||||
setResolvedImageSources((current) =>
|
||||
current.size > 0 ? new Map() : current,
|
||||
);
|
||||
setFailedImageSources((current) =>
|
||||
current.size > 0 ? new Set() : current,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (rawSources.every((source) => !isGeneratedLegacyPath(source))) {
|
||||
setResolvedImageSources(resolveStaticMatch3DReadUrlMap(rawSources));
|
||||
setFailedImageSources((current) =>
|
||||
current.size > 0 ? new Set() : current,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const sourceSet = new Set(rawSources);
|
||||
const nextSources = new Map<string, string>();
|
||||
setResolvedImageSources(() => new Map());
|
||||
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());
|
||||
void Promise.all(
|
||||
rawSources.map(async (source) => {
|
||||
if (nextSources.has(source)) {
|
||||
return;
|
||||
}
|
||||
if (!isGeneratedLegacyPath(source)) {
|
||||
nextSources.set(source, source);
|
||||
return;
|
||||
}
|
||||
const resolvedSource = await resolveAssetReadUrl(source, {
|
||||
signal: controller.signal,
|
||||
expireSeconds: 300,
|
||||
});
|
||||
nextSources.set(source, resolvedSource || source);
|
||||
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) {
|
||||
setResolvedImageSources(new Map());
|
||||
setFailedImageSources(new Set(rawSources));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -807,7 +1075,7 @@ export function Match3DRuntimeShell({
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [imageSourceSignature, imageSourcesByType]);
|
||||
}, [imageReadUrlCacheKey, imageSourcesByType]);
|
||||
|
||||
const trayPreviewItems = useMemo(() => {
|
||||
if (!run) {
|
||||
@@ -816,15 +1084,68 @@ export function Match3DRuntimeShell({
|
||||
return run.traySlots.map((slot) => resolveTrayPreviewItem(run, slot));
|
||||
}, [run]);
|
||||
|
||||
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: resolveMatch3DFirstImageForItem(
|
||||
item,
|
||||
resolvedImageSourcesByType,
|
||||
),
|
||||
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, resolvedImageSourcesByType],
|
||||
);
|
||||
|
||||
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
||||
if (!run || !isRunState(run.status, 'running') || pendingClick) {
|
||||
if (
|
||||
!run ||
|
||||
!isRunState(run.status, 'running') ||
|
||||
pendingClick ||
|
||||
pendingClickLockRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pendingClickLockRef.current = true;
|
||||
const optimisticRun = buildOptimisticRun(run, item);
|
||||
const clientEventId = buildClientEventId(item.itemInstanceId);
|
||||
const targetSlot = run.traySlots.find((slot) => !slot.itemInstanceId);
|
||||
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
|
||||
tryPlayBackgroundMusic();
|
||||
playClickSound(item);
|
||||
if (targetSlot) {
|
||||
startFlyingTrayAnimation(item, targetSlot.slotIndex, clientEventId);
|
||||
}
|
||||
setPendingClick({
|
||||
clientEventId,
|
||||
itemInstanceId: item.itemInstanceId,
|
||||
@@ -832,44 +1153,83 @@ export function Match3DRuntimeShell({
|
||||
});
|
||||
onOptimisticRunChange(optimisticRun);
|
||||
|
||||
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) {
|
||||
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) {
|
||||
setFeedbackEvent({
|
||||
id: clientEventId,
|
||||
kind: 'cleared',
|
||||
itemIds: result.clearedItemInstanceIds,
|
||||
});
|
||||
}
|
||||
onOptimisticRunChange(result.run);
|
||||
} else {
|
||||
setFeedbackEvent({
|
||||
id: clientEventId,
|
||||
kind: 'cleared',
|
||||
itemIds: result.clearedItemInstanceIds,
|
||||
kind: 'rejected',
|
||||
itemIds: [item.itemInstanceId],
|
||||
});
|
||||
onOptimisticRunChange(result.run ?? run);
|
||||
}
|
||||
onOptimisticRunChange(result.run);
|
||||
} else {
|
||||
setFeedbackEvent({
|
||||
id: clientEventId,
|
||||
kind: 'rejected',
|
||||
itemIds: [item.itemInstanceId],
|
||||
});
|
||||
onOptimisticRunChange(result.run ?? run);
|
||||
} finally {
|
||||
pendingClickLockRef.current = false;
|
||||
setPendingClick(null);
|
||||
}
|
||||
setPendingClick(null);
|
||||
};
|
||||
|
||||
const resolvePointerCandidate = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!run || !isRunState(run.status, 'running') || pendingClick) {
|
||||
return null;
|
||||
}
|
||||
const point = resolveBoardPointFromPointerEvent(event, stageRef.current);
|
||||
return point ? (findHitItem(run, point.x, point.y) ?? null) : null;
|
||||
};
|
||||
|
||||
const handleBoardPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!run || !isRunState(run.status, 'running') || pendingClick) {
|
||||
setPressedItemInstanceId(null);
|
||||
return;
|
||||
}
|
||||
const rect = stageRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
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;
|
||||
}
|
||||
const pointX = (event.clientX - rect.left) / rect.width;
|
||||
const pointY = (event.clientY - rect.top) / rect.height;
|
||||
const item = findHitItem(run, pointX, pointY);
|
||||
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);
|
||||
}
|
||||
@@ -885,6 +1245,13 @@ export function Match3DRuntimeShell({
|
||||
);
|
||||
}
|
||||
|
||||
const displayLevelName = levelName?.trim() || '抓大鹅';
|
||||
const timerClassName =
|
||||
timeLeftMs <= levelAudioConfig.countdownWarningThresholdMs &&
|
||||
isRunState(run.status, 'running')
|
||||
? MATCH3D_RUNTIME_TIMER_URGENT_CLASS
|
||||
: MATCH3D_RUNTIME_TIMER_CLASS;
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#16221f] text-white`}
|
||||
@@ -916,18 +1283,30 @@ export function Match3DRuntimeShell({
|
||||
width: 'min(100vw, 28rem)',
|
||||
}}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>
|
||||
<Clock3 size={16} />
|
||||
<span>{formatTimer(timeLeftMs)}</span>
|
||||
<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={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
<div className={`${MATCH3D_RUNTIME_HEADER_CARD_CLASS} mx-auto`}>
|
||||
<div className="flex max-w-full items-center justify-center gap-1.5">
|
||||
<span className={MATCH3D_RUNTIME_LEVEL_BADGE_CLASS}>第 1 关</span>
|
||||
<span className="min-w-0 truncate text-sm font-black sm:text-base">
|
||||
{displayLevelName}
|
||||
</span>
|
||||
</div>
|
||||
<div className={timerClassName}>
|
||||
<Clock3 className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
{formatTimer(timeLeftMs)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -951,6 +1330,9 @@ export function Match3DRuntimeShell({
|
||||
width: 'min(96vw, 60dvh, 100%)',
|
||||
}}
|
||||
onPointerDown={handleBoardPointerDown}
|
||||
onPointerMove={handleBoardPointerMove}
|
||||
onPointerCancel={handleBoardPointerCancel}
|
||||
onPointerUp={handleBoardPointerUp}
|
||||
data-testid="match3d-board"
|
||||
>
|
||||
{resolvedContainerImageSrc ? (
|
||||
@@ -958,7 +1340,7 @@ export function Match3DRuntimeShell({
|
||||
src={resolvedContainerImageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-[-10%] z-0 h-[120%] w-[120%] object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)] ${
|
||||
className={`pointer-events-none absolute left-1/2 top-1/2 z-0 h-auto w-[min(96vw,28rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)] ${
|
||||
isContainerImageLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
data-testid="match3d-container-image"
|
||||
@@ -975,18 +1357,26 @@ export function Match3DRuntimeShell({
|
||||
) : (
|
||||
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
|
||||
)}
|
||||
{run.items.map((item) => (
|
||||
<Match3DToken
|
||||
key={item.itemInstanceId}
|
||||
item={item}
|
||||
imageSrc={resolveMatch3DImageForItem(
|
||||
item,
|
||||
resolvedImageSourcesByType,
|
||||
)}
|
||||
disabled={Boolean(pendingClick)}
|
||||
onClick={handleItemClick}
|
||||
/>
|
||||
))}
|
||||
{run.items.map((item) =>
|
||||
hasPendingMatch3DGeneratedImageForItem(
|
||||
item,
|
||||
imageSourcesByType,
|
||||
resolvedImageSources,
|
||||
failedImageSources,
|
||||
) ? null : (
|
||||
<Match3DToken
|
||||
key={item.itemInstanceId}
|
||||
item={item}
|
||||
imageSrc={resolveMatch3DImageForItem(
|
||||
item,
|
||||
resolvedImageSourcesByType,
|
||||
)}
|
||||
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="flex h-24 w-24 items-center justify-center rounded-full bg-white/24 text-amber-100 shadow-[0_0_42px_rgba(255,255,255,0.72)] backdrop-blur-sm">
|
||||
@@ -1015,17 +1405,32 @@ export function Match3DRuntimeShell({
|
||||
key={slot.slotIndex}
|
||||
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
|
||||
data-testid="match3d-tray-slot"
|
||||
ref={(element) => {
|
||||
traySlotRefs.current[slot.slotIndex] = element;
|
||||
}}
|
||||
>
|
||||
<Match3DTrayToken
|
||||
slot={slot}
|
||||
isArriving={
|
||||
flyingTrayAnimation?.item.itemInstanceId ===
|
||||
slot.itemInstanceId
|
||||
}
|
||||
imageSrc={
|
||||
trayItem
|
||||
? resolveMatch3DImageForItem(
|
||||
? resolveMatch3DFirstImageForItem(
|
||||
trayItem,
|
||||
resolvedImageSourcesByType,
|
||||
)
|
||||
: ''
|
||||
}
|
||||
itemSize={
|
||||
trayItem
|
||||
? resolveMatch3DItemSizeForType(
|
||||
trayItem,
|
||||
itemSizeByType,
|
||||
)
|
||||
: '大'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1040,7 +1445,23 @@ export function Match3DRuntimeShell({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Match3DSettlement run={run} onBack={onBack} onRestart={onRestart} />
|
||||
{flyingTrayAnimation ? (
|
||||
<Match3DFlyingTrayToken
|
||||
animation={flyingTrayAnimation}
|
||||
onDone={(id) =>
|
||||
setFlyingTrayAnimation((current) =>
|
||||
current?.id === id ? null : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Match3DSettlement
|
||||
run={run}
|
||||
hideBackButton={hideBackButton}
|
||||
onBack={onBack}
|
||||
onRestart={onRestart}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,18 @@ export const MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS =
|
||||
export const MATCH3D_RUNTIME_GLASS_TIMER_CLASS =
|
||||
'flex min-w-[4.25rem] items-center justify-center gap-1.5 rounded-full border border-white/65 bg-white/72 px-3 py-2 text-sm font-black text-slate-800 shadow-[0_8px_22px_rgba(15,23,42,0.14)] backdrop-blur-md';
|
||||
|
||||
export const MATCH3D_RUNTIME_HEADER_CARD_CLASS =
|
||||
'flex max-w-[min(15rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center gap-1.5 rounded-[1.1rem] border border-white/65 bg-white/72 px-3 py-2 text-center text-slate-800 shadow-[0_8px_22px_rgba(15,23,42,0.14)] backdrop-blur-md sm:max-w-[18rem] sm:px-4';
|
||||
|
||||
export const MATCH3D_RUNTIME_LEVEL_BADGE_CLASS =
|
||||
'shrink-0 rounded-full border border-white/55 bg-white/58 px-2 py-0.5 text-[10px] font-bold text-emerald-700 sm:text-[11px]';
|
||||
|
||||
export const MATCH3D_RUNTIME_TIMER_CLASS =
|
||||
'inline-flex items-center gap-1.5 rounded-full border border-white/55 bg-white/58 px-3 py-1.5 font-mono text-lg font-black leading-none text-slate-900 shadow-[0_10px_28px_rgba(15,23,42,0.16)] sm:text-xl';
|
||||
|
||||
export const MATCH3D_RUNTIME_TIMER_URGENT_CLASS =
|
||||
'inline-flex items-center gap-1.5 rounded-full border border-rose-100/72 bg-rose-500/88 px-3 py-1.5 font-mono text-lg font-black leading-none text-white shadow-[0_10px_28px_rgba(190,18,60,0.26)] sm:text-xl';
|
||||
|
||||
export const MATCH3D_RUNTIME_GLASS_SPINNER_CLASS =
|
||||
'h-4 w-4 rounded-full border-2 border-slate-700/76 border-l-transparent';
|
||||
|
||||
|
||||
@@ -709,6 +709,32 @@ function hasMatch3DRuntimeAsset(
|
||||
return hasMatch3DGeneratedImageAsset(assets);
|
||||
}
|
||||
|
||||
function hasMatch3DRuntimeBackgroundAsset(
|
||||
profile: Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'backgroundImageSrc'
|
||||
| 'backgroundImageObjectKey'
|
||||
| 'generatedBackgroundAsset'
|
||||
| 'generatedItemAssets'
|
||||
>,
|
||||
) {
|
||||
return Boolean(
|
||||
profile.backgroundImageSrc?.trim() ||
|
||||
profile.backgroundImageObjectKey?.trim() ||
|
||||
profile.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
profile.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
|
||||
profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
profile.generatedItemAssets?.some(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.imageSrc?.trim() ||
|
||||
asset.backgroundAsset?.imageObjectKey?.trim() ||
|
||||
asset.backgroundAsset?.containerImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
@@ -3773,6 +3799,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
await preloadMatch3DGeneratedRuntimeAssets(
|
||||
runtimeProfile.generatedItemAssets,
|
||||
runtimeProfile.generatedBackgroundAsset,
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
const { run } = await match3dRuntimeAdapter.startRun(
|
||||
@@ -4880,6 +4907,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
await preloadMatch3DGeneratedRuntimeAssets(
|
||||
runtimeProfile.generatedItemAssets,
|
||||
runtimeProfile.generatedBackgroundAsset,
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
const { run } = await match3dRuntimeAdapter.startRun(
|
||||
@@ -6557,7 +6585,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
try {
|
||||
let runtimeProfile = profile;
|
||||
if (!hasMatch3DRuntimeAsset(profile.generatedItemAssets)) {
|
||||
if (
|
||||
!hasMatch3DRuntimeAsset(profile.generatedItemAssets) ||
|
||||
!hasMatch3DRuntimeBackgroundAsset(profile)
|
||||
) {
|
||||
try {
|
||||
const { item } = await getMatch3DWorkDetail(profile.profileId);
|
||||
runtimeProfile = {
|
||||
@@ -6579,6 +6610,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
};
|
||||
await preloadMatch3DGeneratedRuntimeAssets(
|
||||
runtimeProfile.generatedItemAssets,
|
||||
runtimeProfile.generatedBackgroundAsset,
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
const runtimeOptions = {
|
||||
@@ -9314,6 +9346,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
isBusy={isMatch3DBusy}
|
||||
error={match3dError}
|
||||
embedded
|
||||
hideBackButton
|
||||
levelName={
|
||||
activeMatch3DRuntimeProfile?.gameName?.trim() ||
|
||||
activeEntry.worldName
|
||||
}
|
||||
generatedItemAssets={resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
match3dRun,
|
||||
activeMatch3DRuntimeProfile,
|
||||
|
||||
@@ -929,7 +929,7 @@ test('拼图运行态主体使用主题语义类承接明暗主题', () => {
|
||||
expect(container.querySelector('.puzzle-runtime-pill')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('合并块按实际拼块外轮廓描边', () => {
|
||||
test('合并块不叠加可见轮廓和单块阴影', () => {
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
@@ -976,22 +976,16 @@ test('合并块按实际拼块外轮廓描边', () => {
|
||||
expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-merged-group-outline="true"]'),
|
||||
).toBeTruthy();
|
||||
).toBeNull();
|
||||
const outlineStroke = container.querySelector(
|
||||
'[data-merged-group-outline-stroke="true"]',
|
||||
);
|
||||
expect(outlineStroke).toBeTruthy();
|
||||
expect(outlineStroke?.getAttribute('d')).toContain('Q 2 1 1.84 1');
|
||||
expect(outlineStroke?.getAttribute('d')).toContain('Q 1 1 1 1.16');
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-merged-group-outline="true"]')
|
||||
?.getAttribute('fill'),
|
||||
).toBe('transparent');
|
||||
expect(outlineStroke).toBeNull();
|
||||
expect((outlinedPieces[0] as HTMLElement).style.clipPath).toBe('');
|
||||
for (const outlinedPiece of outlinedPieces) {
|
||||
const outlinedPieceElement = outlinedPiece as HTMLElement;
|
||||
expect(outlinedPieceElement.className).not.toContain('bg-emerald-300/10');
|
||||
expect(outlinedPieceElement.className).not.toContain('shadow-[');
|
||||
expect(
|
||||
outlinedPieceElement.querySelector('.absolute.inset-0.bg-black\\/8'),
|
||||
).toBeNull();
|
||||
@@ -999,7 +993,7 @@ test('合并块按实际拼块外轮廓描边', () => {
|
||||
const clippedLayer = container.querySelector(
|
||||
'[style*="clip-path"]',
|
||||
) as HTMLElement | null;
|
||||
expect(clippedLayer?.style.clipPath).toContain('url(#');
|
||||
expect(clippedLayer).toBeNull();
|
||||
});
|
||||
|
||||
test('合并块轮廓路径为内凹角生成圆角曲线', () => {
|
||||
@@ -1026,7 +1020,7 @@ test('合并块轮廓路径为内凹角生成圆角曲线', () => {
|
||||
expect(outlinePath).toContain('Q 1 1 1 1.16');
|
||||
});
|
||||
|
||||
test('基础单块使用圆角裁剪图片', () => {
|
||||
test('基础单块不叠加边框圆角或图片蒙版', () => {
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
@@ -1055,7 +1049,9 @@ test('基础单块使用圆角裁剪图片', () => {
|
||||
'[data-piece-id="piece-0"]',
|
||||
) as HTMLElement | null;
|
||||
expect(basePiece?.className).toContain('overflow-hidden');
|
||||
expect(basePiece?.className).toContain('rounded-[0.85rem]');
|
||||
expect(basePiece?.className).toContain('border-0');
|
||||
expect(basePiece?.className).not.toContain('rounded-[0.85rem]');
|
||||
expect(basePiece?.className).not.toContain('border-2');
|
||||
expect(basePiece?.querySelector('.puzzle-runtime-piece-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Sparkles,
|
||||
Trophy,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
@@ -23,7 +23,6 @@ import type {
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import {
|
||||
createRuntimeDragInputController,
|
||||
createRuntimeInputPointFromClient,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
type RuntimeDragInputSession,
|
||||
type RuntimeInputPoint,
|
||||
} from '../../services/input-devices';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import {
|
||||
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
|
||||
playRuntimeClickSound,
|
||||
@@ -44,12 +44,9 @@ import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
buildMergedGroupClipPath,
|
||||
buildMergedGroupOutlinePath,
|
||||
resolveDraggedMergedGroupLayer,
|
||||
resolveDraggedPieceCellLayer,
|
||||
resolveDraggedPieceLayer,
|
||||
sanitizeSvgId,
|
||||
} from './puzzleRuntimeShape';
|
||||
|
||||
type PuzzleRuntimeShellProps = {
|
||||
@@ -344,7 +341,6 @@ export function PuzzleRuntimeShell({
|
||||
onUseProp,
|
||||
onTimeExpired,
|
||||
}: PuzzleRuntimeShellProps) {
|
||||
const mergedGroupSvgIdPrefix = sanitizeSvgId(useId());
|
||||
const authUi = useAuthUi();
|
||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||||
const selectedPieceIdRef = useRef<string | null>(null);
|
||||
@@ -1376,7 +1372,7 @@ export function PuzzleRuntimeShell({
|
||||
pieceElementRefMap.current.delete(piece.pieceId);
|
||||
}}
|
||||
data-piece-id={piece?.pieceId ?? undefined}
|
||||
className={`puzzle-runtime-piece relative flex h-full items-center justify-center overflow-hidden rounded-[0.85rem] border-2 text-sm font-black transition ${
|
||||
className={`puzzle-runtime-piece relative flex h-full items-center justify-center overflow-hidden border-0 text-sm font-black transition ${
|
||||
occupied
|
||||
? isSelected
|
||||
? 'puzzle-runtime-piece--selected'
|
||||
@@ -1386,8 +1382,8 @@ export function PuzzleRuntimeShell({
|
||||
: 'puzzle-runtime-piece--empty'
|
||||
} ${
|
||||
isMerged
|
||||
? 'transition-colors'
|
||||
: 'transition-[background-color,border-color,box-shadow,opacity]'
|
||||
? 'transition-opacity'
|
||||
: 'transition-[opacity,transform]'
|
||||
}`}
|
||||
style={{
|
||||
zIndex: resolveDraggedPieceLayer(
|
||||
@@ -1451,9 +1447,6 @@ export function PuzzleRuntimeShell({
|
||||
);
|
||||
})}
|
||||
{mergedGroups.map((group) => {
|
||||
const outlinePath = buildMergedGroupOutlinePath(group);
|
||||
const clipPath = buildMergedGroupClipPath(group);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.groupId}
|
||||
@@ -1487,57 +1480,9 @@ export function PuzzleRuntimeShell({
|
||||
height: `${(group.rowSpan / board.rows) * 100}%`,
|
||||
}}
|
||||
>
|
||||
{outlinePath ? (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-20 h-full w-full overflow-visible"
|
||||
preserveAspectRatio="none"
|
||||
viewBox={`0 0 ${group.colSpan} ${group.rowSpan}`}
|
||||
>
|
||||
<defs>
|
||||
<clipPath
|
||||
clipPathUnits="objectBoundingBox"
|
||||
id={`${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
|
||||
group.groupId,
|
||||
)}`}
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d={clipPath}
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path
|
||||
d={outlinePath}
|
||||
data-merged-group-outline="true"
|
||||
fill="transparent"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d={outlinePath}
|
||||
data-merged-group-outline-stroke="true"
|
||||
fill="none"
|
||||
stroke="rgba(255, 255, 255, 0.22)"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
<div
|
||||
className="pointer-events-none relative z-10 grid h-full w-full touch-none overflow-hidden active:scale-[0.992]"
|
||||
style={{
|
||||
WebkitClipPath: outlinePath
|
||||
? `url(#${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
|
||||
group.groupId,
|
||||
)})`
|
||||
: undefined,
|
||||
clipPath: outlinePath
|
||||
? `url(#${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
|
||||
group.groupId,
|
||||
)})`
|
||||
: undefined,
|
||||
gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
|
||||
}}
|
||||
@@ -1545,7 +1490,7 @@ export function PuzzleRuntimeShell({
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className="pointer-events-auto relative touch-none overflow-hidden shadow-[0_12px_30px_rgba(15,23,42,0.16)]"
|
||||
className="pointer-events-auto relative touch-none overflow-hidden"
|
||||
data-merged-piece-outline="true"
|
||||
style={{
|
||||
gridColumn: piece.localCol + 1,
|
||||
|
||||
@@ -2755,6 +2755,48 @@ test('mobile game category list orders works by composite public metric', async
|
||||
expect(gameItems).toEqual(['热门高分拼图', '奇幻拼图']);
|
||||
});
|
||||
|
||||
test('mobile game category filter dialog filters by play type', async () => {
|
||||
const user = userEvent.setup();
|
||||
const match3dEntry = {
|
||||
...puzzlePublicEntry,
|
||||
sourceType: 'match3d',
|
||||
workId: 'match3d-work-category-filter',
|
||||
profileId: 'match3d-profile-category-filter',
|
||||
publicWorkCode: 'M3D-FILTER',
|
||||
worldName: '奇幻抓鹅',
|
||||
subtitle: '抓大鹅关卡',
|
||||
summaryText: '一组用于筛选的抓大鹅作品。',
|
||||
playCount: 6,
|
||||
remixCount: 0,
|
||||
likeCount: 1,
|
||||
publishedAt: '2026-04-26T10:00:00.000Z',
|
||||
updatedAt: '2026-04-26T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [puzzlePublicEntry, match3dEntry],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
await user.click(screen.getByRole('button', { name: '奇幻' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /奇幻拼图,试玩/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /奇幻抓鹅,进入/u })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /筛选/u }));
|
||||
const filterDialog = await screen.findByRole('dialog', {
|
||||
name: '分类筛选',
|
||||
});
|
||||
|
||||
await user.click(within(filterDialog).getByRole('button', { name: '抓鹅' }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /奇幻拼图,试玩/u }),
|
||||
).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /奇幻抓鹅,进入/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('bottom category tab becomes ranking and switches ranking metrics', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Camera,
|
||||
CheckCircle2,
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
Heart,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
Palette,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
@@ -47,22 +47,22 @@ import communityQqQrImage from '../../../media/social-media-group/qq.png';
|
||||
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
|
||||
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
|
||||
import type {
|
||||
ConfirmWechatProfileRechargeOrderResponse,
|
||||
CustomWorldLibraryEntry,
|
||||
PlatformBrowseHistoryEntry,
|
||||
ConfirmWechatProfileRechargeOrderResponse,
|
||||
ProfileDashboardCardKey,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeProduct,
|
||||
WechatMiniProgramPayParams,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileSaveArchiveSummary,
|
||||
ProfileTaskCenterResponse,
|
||||
ProfileTaskItem,
|
||||
ProfileWalletLedgerResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
WechatMiniProgramPayParams,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||
@@ -77,8 +77,8 @@ import {
|
||||
claimRpgProfileTaskReward,
|
||||
confirmWechatRpgProfileRechargeOrder,
|
||||
createRpgProfileRechargeOrder,
|
||||
getRpgProfileReferralInviteCenter,
|
||||
getRpgProfileRechargeCenter,
|
||||
getRpgProfileReferralInviteCenter,
|
||||
getRpgProfileTasks,
|
||||
getRpgProfileWalletLedger,
|
||||
redeemRpgProfileReferralInviteCode,
|
||||
@@ -242,6 +242,15 @@ type DiscoverChannel =
|
||||
| 'ranking'
|
||||
| 'edutainment';
|
||||
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
|
||||
type PlatformCategoryKindFilter =
|
||||
| 'all'
|
||||
| 'puzzle'
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'visual-novel'
|
||||
| 'big-fish'
|
||||
| 'custom-world';
|
||||
type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like';
|
||||
|
||||
const COMMUNITY_QR_CODES = [
|
||||
{
|
||||
@@ -269,6 +278,27 @@ const EDUTAINMENT_DISCOVER_CHANNEL = {
|
||||
id: 'edutainment',
|
||||
label: EDUTAINMENT_WORK_TAG,
|
||||
} as const;
|
||||
const PLATFORM_CATEGORY_KIND_FILTERS: Array<{
|
||||
id: PlatformCategoryKindFilter;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'puzzle', label: '拼图' },
|
||||
{ id: 'match3d', label: '抓鹅' },
|
||||
{ id: 'square-hole', label: '方洞' },
|
||||
{ id: 'visual-novel', label: '视觉' },
|
||||
{ id: 'big-fish', label: '大鱼' },
|
||||
{ id: 'custom-world', label: 'RPG' },
|
||||
];
|
||||
const PLATFORM_CATEGORY_SORT_OPTIONS: Array<{
|
||||
id: PlatformCategorySortMode;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'composite', label: '综合' },
|
||||
{ id: 'latest', label: '最新' },
|
||||
{ id: 'play', label: '游玩' },
|
||||
{ id: 'like', label: '点赞' },
|
||||
];
|
||||
const BABY_LOVE_DRAWING_DEFAULT_CARD = {
|
||||
title: '宝贝爱画',
|
||||
subtitle: '空白画板',
|
||||
@@ -1391,6 +1421,123 @@ function PlatformCategoryGameItem({
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformCategoryFilterDialog({
|
||||
kindFilter,
|
||||
sortMode,
|
||||
resultCount,
|
||||
onKindFilterChange,
|
||||
onSortModeChange,
|
||||
onReset,
|
||||
onClose,
|
||||
}: {
|
||||
kindFilter: PlatformCategoryKindFilter;
|
||||
sortMode: PlatformCategorySortMode;
|
||||
resultCount: number;
|
||||
onKindFilterChange: (filter: PlatformCategoryKindFilter) => void;
|
||||
onSortModeChange: (mode: PlatformCategorySortMode) => void;
|
||||
onReset: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[90] flex items-end justify-center px-3 py-4 sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭分类筛选"
|
||||
className="absolute inset-0"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="分类筛选"
|
||||
className="platform-modal-shell platform-category-filter-dialog relative w-full max-w-md overflow-hidden rounded-[1.35rem]"
|
||||
>
|
||||
<div className="flex min-w-0 items-center justify-between gap-3 px-4 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-black text-[var(--platform-text-strong)]">
|
||||
分类筛选
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
{resultCount} 个作品
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<XCircle className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 px-4 pb-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="text-xs font-black text-[var(--platform-text-soft)]">
|
||||
玩法
|
||||
</div>
|
||||
<div className="platform-category-filter-dialog__options">
|
||||
{PLATFORM_CATEGORY_KIND_FILTERS.map((option) => {
|
||||
const active = option.id === kindFilter;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onKindFilterChange(option.id)}
|
||||
className={`platform-category-filter-dialog__option ${active ? 'platform-category-filter-dialog__option--active' : ''}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="text-xs font-black text-[var(--platform-text-soft)]">
|
||||
排序
|
||||
</div>
|
||||
<div className="platform-category-filter-dialog__options">
|
||||
{PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => {
|
||||
const active = option.id === sortMode;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onSortModeChange(option.id)}
|
||||
className={`platform-category-filter-dialog__option ${active ? 'platform-category-filter-dialog__option--active' : ''}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="platform-category-filter-dialog__actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="platform-category-filter-dialog__action platform-category-filter-dialog__action--secondary"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-category-filter-dialog__action platform-category-filter-dialog__action--primary"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPublicCategoryGroups(
|
||||
featuredEntries: PlatformPublicGalleryCard[],
|
||||
latestEntries: PlatformPublicGalleryCard[],
|
||||
@@ -1830,6 +1977,64 @@ function getPlatformCategoryCompositeScore(entry: PlatformPublicGalleryCard) {
|
||||
);
|
||||
}
|
||||
|
||||
function getPlatformCategoryKindFilter(entry: PlatformPublicGalleryCard) {
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return 'puzzle';
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return 'match3d';
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return 'square-hole';
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return 'visual-novel';
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return 'big-fish';
|
||||
}
|
||||
|
||||
return 'custom-world';
|
||||
}
|
||||
|
||||
function matchesPlatformCategoryKindFilter(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
kindFilter: PlatformCategoryKindFilter,
|
||||
) {
|
||||
return (
|
||||
kindFilter === 'all' || getPlatformCategoryKindFilter(entry) === kindFilter
|
||||
);
|
||||
}
|
||||
|
||||
function sortPlatformCategoryEntries(
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
sortMode: PlatformCategorySortMode,
|
||||
) {
|
||||
return [...entries].sort((left, right) => {
|
||||
if (sortMode === 'latest') {
|
||||
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
|
||||
}
|
||||
|
||||
const metricDiff =
|
||||
sortMode === 'play'
|
||||
? getPlatformWorldPlayCount(right) - getPlatformWorldPlayCount(left)
|
||||
: sortMode === 'like'
|
||||
? getPlatformWorldLikeCount(right) - getPlatformWorldLikeCount(left)
|
||||
: getPlatformCategoryCompositeScore(right) -
|
||||
getPlatformCategoryCompositeScore(left);
|
||||
|
||||
if (metricDiff !== 0) {
|
||||
return metricDiff;
|
||||
}
|
||||
|
||||
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
|
||||
});
|
||||
}
|
||||
|
||||
function getPlatformCategoryPrimaryMetric(entry: PlatformPublicGalleryCard) {
|
||||
const likeCount = getPlatformWorldLikeCount(entry);
|
||||
if (likeCount > 0) {
|
||||
@@ -3463,6 +3668,12 @@ export function RpgEntryHomeView({
|
||||
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [categoryKindFilter, setCategoryKindFilter] =
|
||||
useState<PlatformCategoryKindFilter>('all');
|
||||
const [categorySortMode, setCategorySortMode] =
|
||||
useState<PlatformCategorySortMode>('composite');
|
||||
const [isCategoryFilterPanelOpen, setIsCategoryFilterPanelOpen] =
|
||||
useState(false);
|
||||
const [discoverChannel, setDiscoverChannel] =
|
||||
useState<DiscoverChannel>('recommend');
|
||||
const mobileDiscoverFeedRef = useRef<HTMLElement | null>(null);
|
||||
@@ -3586,17 +3797,24 @@ export function RpgEntryHomeView({
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...activeCategoryGroup.entries].sort((left, right) => {
|
||||
const scoreDiff =
|
||||
getPlatformCategoryCompositeScore(right) -
|
||||
getPlatformCategoryCompositeScore(left);
|
||||
if (scoreDiff !== 0) {
|
||||
return scoreDiff;
|
||||
}
|
||||
|
||||
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
|
||||
});
|
||||
}, [activeCategoryGroup]);
|
||||
return sortPlatformCategoryEntries(
|
||||
activeCategoryGroup.entries.filter((entry) =>
|
||||
matchesPlatformCategoryKindFilter(entry, categoryKindFilter),
|
||||
),
|
||||
categorySortMode,
|
||||
);
|
||||
}, [activeCategoryGroup, categoryKindFilter, categorySortMode]);
|
||||
const activeCategoryRawCount = activeCategoryGroup?.entries.length ?? 0;
|
||||
const activeCategoryFilterLabel =
|
||||
PLATFORM_CATEGORY_KIND_FILTERS.find(
|
||||
(option) => option.id === categoryKindFilter,
|
||||
)?.label ?? '全部';
|
||||
const activeCategorySortLabel =
|
||||
PLATFORM_CATEGORY_SORT_OPTIONS.find(
|
||||
(option) => option.id === categorySortMode,
|
||||
)?.label ?? '综合';
|
||||
const activeCategoryFilterCount = activeCategoryEntries.length;
|
||||
const categoryFilterApplied = categoryKindFilter !== 'all';
|
||||
const visibleTabs = useMemo<PlatformHomeTab[]>(
|
||||
() =>
|
||||
isAuthenticated
|
||||
@@ -3928,7 +4146,7 @@ export function RpgEntryHomeView({
|
||||
setIsWalletLedgerOpen(true);
|
||||
loadWalletLedger();
|
||||
};
|
||||
const loadRechargeCenter = () => {
|
||||
const loadRechargeCenter = useCallback(() => {
|
||||
setRechargeError(null);
|
||||
setIsLoadingRechargeCenter(true);
|
||||
void getRpgProfileRechargeCenter()
|
||||
@@ -3940,7 +4158,7 @@ export function RpgEntryHomeView({
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingRechargeCenter(false));
|
||||
};
|
||||
}, []);
|
||||
const refreshRechargeState = useCallback(
|
||||
() => {
|
||||
loadRechargeCenter();
|
||||
@@ -4301,6 +4519,18 @@ export function RpgEntryHomeView({
|
||||
const submitMobileSearch = () => {
|
||||
submitWorkSearch(mobileSearchKeyword);
|
||||
};
|
||||
const cycleCategorySortMode = () => {
|
||||
const currentIndex = PLATFORM_CATEGORY_SORT_OPTIONS.findIndex(
|
||||
(option) => option.id === categorySortMode,
|
||||
);
|
||||
const nextIndex =
|
||||
currentIndex >= 0
|
||||
? (currentIndex + 1) % PLATFORM_CATEGORY_SORT_OPTIONS.length
|
||||
: 0;
|
||||
setCategorySortMode(
|
||||
PLATFORM_CATEGORY_SORT_OPTIONS[nextIndex]?.id ?? 'composite',
|
||||
);
|
||||
};
|
||||
const desktopHeroEntry =
|
||||
featuredShelf[0] ?? generalLatestEntries[0] ?? myEntries[0] ?? null;
|
||||
const desktopHeroCover = desktopHeroEntry
|
||||
@@ -4904,12 +5134,18 @@ export function RpgEntryHomeView({
|
||||
<div className="platform-category-filter-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCategoryFilterPanelOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
className="platform-category-filter-button"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span>筛选</span>
|
||||
<span>
|
||||
{categoryFilterApplied
|
||||
? activeCategoryFilterLabel
|
||||
: '筛选'}
|
||||
</span>
|
||||
<span className="platform-category-filter-button__count">
|
||||
{activeCategoryGroup.entries.length}
|
||||
{activeCategoryFilterCount}
|
||||
</span>
|
||||
</button>
|
||||
<span className="platform-category-filter-divider" />
|
||||
@@ -4933,22 +5169,27 @@ export function RpgEntryHomeView({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycleCategorySortMode}
|
||||
className="platform-category-sort-button"
|
||||
>
|
||||
<span>按综合排序</span>
|
||||
<span>按{activeCategorySortLabel}排序</span>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="platform-category-game-list">
|
||||
{activeCategoryEntries.map((entry) => (
|
||||
<PlatformCategoryGameItem
|
||||
key={`${buildPublicGalleryCardKey(entry)}:mobile-category:${activeCategoryGroup.tag}`}
|
||||
entry={entry}
|
||||
categoryTag={activeCategoryGroup.tag}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{activeCategoryEntries.length > 0 ? (
|
||||
<div className="platform-category-game-list">
|
||||
{activeCategoryEntries.map((entry) => (
|
||||
<PlatformCategoryGameItem
|
||||
key={`${buildPublicGalleryCardKey(entry)}:mobile-category:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
|
||||
entry={entry}
|
||||
categoryTag={activeCategoryGroup.tag}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="当前筛选下没有作品。" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有可分类的作品。" />
|
||||
@@ -5069,35 +5310,63 @@ export function RpgEntryHomeView({
|
||||
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取作品分类..." />
|
||||
) : activeCategoryGroup && desktopCategoryGrid.length > 0 ? (
|
||||
) : activeCategoryGroup && activeCategoryRawCount > 0 ? (
|
||||
<>
|
||||
<div className="mb-4 flex min-w-0 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{categoryGroups.map((group) => {
|
||||
const active = group.tag === activeCategoryGroup.tag;
|
||||
<div className="mb-4 flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCategoryFilterPanelOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
className="platform-category-filter-button"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span>
|
||||
{categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}
|
||||
</span>
|
||||
<span className="platform-category-filter-button__count">
|
||||
{activeCategoryFilterCount}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{categoryGroups.map((group) => {
|
||||
const active = group.tag === activeCategoryGroup.tag;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${group.tag}:desktop-discover-category`}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
{group.tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{desktopCategoryGrid.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-discover-category:${activeCategoryGroup.tag}`}
|
||||
entry={entry}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
))}
|
||||
return (
|
||||
<button
|
||||
key={`${group.tag}:desktop-discover-category`}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
{group.tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycleCategorySortMode}
|
||||
className="platform-category-sort-button shrink-0"
|
||||
>
|
||||
<span>{activeCategorySortLabel}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{desktopCategoryGrid.length > 0 ? (
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{desktopCategoryGrid.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-discover-category:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
|
||||
entry={entry}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="当前筛选下没有作品。" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyShelf text="暂时还没有可分类的作品。" />
|
||||
@@ -5747,35 +6016,65 @@ export function RpgEntryHomeView({
|
||||
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取作品分类..." />
|
||||
) : activeCategoryGroup && desktopCategoryGrid.length > 0 ? (
|
||||
) : activeCategoryGroup && activeCategoryRawCount > 0 ? (
|
||||
<>
|
||||
<div className="mb-4 flex min-w-0 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{categoryGroups.map((group) => {
|
||||
const active = group.tag === activeCategoryGroup.tag;
|
||||
<div className="mb-4 flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCategoryFilterPanelOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
className="platform-category-filter-button"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span>
|
||||
{categoryFilterApplied
|
||||
? activeCategoryFilterLabel
|
||||
: '筛选'}
|
||||
</span>
|
||||
<span className="platform-category-filter-button__count">
|
||||
{activeCategoryFilterCount}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{categoryGroups.map((group) => {
|
||||
const active = group.tag === activeCategoryGroup.tag;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${group.tag}:desktop-category`}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
{group.tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{desktopCategoryGrid.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}`}
|
||||
entry={entry}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
))}
|
||||
return (
|
||||
<button
|
||||
key={`${group.tag}:desktop-category`}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||||
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
{group.tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycleCategorySortMode}
|
||||
className="platform-category-sort-button shrink-0"
|
||||
>
|
||||
<span>{activeCategorySortLabel}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{desktopCategoryGrid.length > 0 ? (
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{desktopCategoryGrid.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
|
||||
entry={entry}
|
||||
onClick={() => openRecommendGalleryDetail(entry)}
|
||||
className="w-full min-w-0"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="当前筛选下没有作品。" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyShelf text="暂时还没有可分类的作品。" />
|
||||
@@ -5885,6 +6184,20 @@ export function RpgEntryHomeView({
|
||||
onClose={() => setRechargePaymentResult(null)}
|
||||
/>
|
||||
) : null;
|
||||
const categoryFilterDialog: ReactNode = isCategoryFilterPanelOpen ? (
|
||||
<PlatformCategoryFilterDialog
|
||||
kindFilter={categoryKindFilter}
|
||||
sortMode={categorySortMode}
|
||||
resultCount={activeCategoryFilterCount}
|
||||
onKindFilterChange={setCategoryKindFilter}
|
||||
onSortModeChange={setCategorySortMode}
|
||||
onReset={() => {
|
||||
setCategoryKindFilter('all');
|
||||
setCategorySortMode('composite');
|
||||
}}
|
||||
onClose={() => setIsCategoryFilterPanelOpen(false)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (!isDesktopLayout) {
|
||||
const isMobileRecommendTab = activeTab === 'home';
|
||||
@@ -5969,6 +6282,7 @@ export function RpgEntryHomeView({
|
||||
{rewardCodeModal}
|
||||
{rechargeModal}
|
||||
{rechargePaymentResultModal}
|
||||
{categoryFilterDialog}
|
||||
{isTaskCenterOpen ? (
|
||||
<ProfileTaskCenterModal
|
||||
center={taskCenter}
|
||||
@@ -6101,6 +6415,7 @@ export function RpgEntryHomeView({
|
||||
{rewardCodeModal}
|
||||
{rechargeModal}
|
||||
{rechargePaymentResultModal}
|
||||
{categoryFilterDialog}
|
||||
{isTaskCenterOpen ? (
|
||||
<ProfileTaskCenterModal
|
||||
center={taskCenter}
|
||||
|
||||
Reference in New Issue
Block a user