Files
Genarrative/src/components/puzzle-runtime/puzzleRuntimeShape.ts
高物 bb60ca91ef Match3D & Puzzle: runtime UI, assets, drag fix
Backend: stop treating background music as a required draft asset and remove auto-submit/plan for background music; load persisted generated UI/assets into Match3D agent session responses (added helpers to resolve profile id and fetch existing generated assets). Frontend: make Match3D result preview reuse runtime UI styles, unify runtime settings entry, update PuzzleRuntime to apply immediate pointermove transforms (disable drag transition), use SVG clipPath for merged piece rounding, ensure PuzzleRuntimeShell supplies platform theme classes, and adjust related tests. Docs & logs: update decision log, pitfalls and product docs to reflect these changes.
2026-05-15 08:49:59 +08:00

347 lines
9.1 KiB
TypeScript

type PuzzleMergedGroupShape = {
colSpan: number;
rowSpan: number;
pieces: Array<{
localRow: number;
localCol: number;
}>;
};
type GridPoint = {
x: number;
y: number;
};
type GridEdge = {
start: GridPoint;
end: GridPoint;
};
const MERGED_GROUP_OUTLINE_CORNER_RADIUS = 0.16;
const MERGED_GROUP_CONCAVE_CORNER_RADIUS_FACTOR = 1.2;
function buildLocalCellKey(row: number, col: number) {
return `${row}:${col}`;
}
function formatSvgNumber(value: number) {
const normalizedValue = Object.is(value, -0) ? 0 : value;
return Number(normalizedValue.toFixed(4)).toString();
}
function formatSvgPoint(point: GridPoint) {
return `${formatSvgNumber(point.x)} ${formatSvgNumber(point.y)}`;
}
function gridPointKey(point: GridPoint) {
return `${formatSvgNumber(point.x)}:${formatSvgNumber(point.y)}`;
}
function distanceBetweenGridPoints(first: GridPoint, second: GridPoint) {
return Math.hypot(second.x - first.x, second.y - first.y);
}
function moveGridPointToward(
from: GridPoint,
target: GridPoint,
distance: number,
) {
const fullDistance = distanceBetweenGridPoints(from, target);
if (fullDistance <= 0) {
return from;
}
const ratio = Math.min(1, distance / fullDistance);
return {
x: from.x + (target.x - from.x) * ratio,
y: from.y + (target.y - from.y) * ratio,
};
}
function isCollinearGridCorner(
previous: GridPoint,
current: GridPoint,
next: GridPoint,
) {
return (
(previous.x === current.x && current.x === next.x) ||
(previous.y === current.y && current.y === next.y)
);
}
function removeCollinearGridPoints(points: GridPoint[]) {
if (points.length <= 3) {
return points;
}
return points.filter((point, index) => {
const previous = points[(index - 1 + points.length) % points.length];
const next = points[(index + 1) % points.length];
return previous && next && !isCollinearGridCorner(previous, point, next);
});
}
function computePolygonSignedArea(points: GridPoint[]) {
let area = 0;
for (let index = 0; index < points.length; index += 1) {
const current = points[index];
const next = points[(index + 1) % points.length];
if (!current || !next) {
continue;
}
area += current.x * next.y - next.x * current.y;
}
return area / 2;
}
type CornerRadiusResolver = (corner: {
point: GridPoint;
previous: GridPoint;
next: GridPoint;
isConvex: boolean;
radius: number;
}) => number;
function buildRoundedGridCyclePath(
points: GridPoint[],
radius: number,
transformPoint: (point: GridPoint) => GridPoint = (point) => point,
resolveCornerRadius?: CornerRadiusResolver,
) {
const cyclePoints = removeCollinearGridPoints(points);
if (cyclePoints.length < 3) {
return '';
}
const polygonOrientation = computePolygonSignedArea(cyclePoints) >= 0 ? 1 : -1;
const resolveCorner = (index: number) => {
const point = cyclePoints[index];
const previous = cyclePoints[
(index - 1 + cyclePoints.length) % cyclePoints.length
];
const next = cyclePoints[(index + 1) % cyclePoints.length];
if (!point || !previous || !next) {
return null;
}
const previousVectorX = point.x - previous.x;
const previousVectorY = point.y - previous.y;
const nextVectorX = next.x - point.x;
const nextVectorY = next.y - point.y;
const turnCross =
previousVectorX * nextVectorY - previousVectorY * nextVectorX;
const isConvex = turnCross * polygonOrientation > 0;
const resolvedRadius = Math.max(
0,
resolveCornerRadius?.({
point,
previous,
next,
isConvex,
radius,
}) ?? radius,
);
const safeRadius = Math.min(
resolvedRadius,
distanceBetweenGridPoints(point, previous) / 2,
distanceBetweenGridPoints(point, next) / 2,
);
return {
point,
entry: moveGridPointToward(point, previous, safeRadius),
exit: moveGridPointToward(point, next, safeRadius),
};
};
const firstCorner = resolveCorner(0);
if (!firstCorner) {
return '';
}
const commands = [`M ${formatSvgPoint(transformPoint(firstCorner.exit))}`];
for (let index = 1; index <= cyclePoints.length; index += 1) {
const corner = resolveCorner(index % cyclePoints.length);
if (!corner) {
continue;
}
commands.push(`L ${formatSvgPoint(transformPoint(corner.entry))}`);
commands.push(
`Q ${formatSvgPoint(transformPoint(corner.point))} ${formatSvgPoint(
transformPoint(corner.exit),
)}`,
);
}
commands.push('Z');
return commands.join(' ');
}
function buildMergedGroupBoundaryCycles(group: PuzzleMergedGroupShape) {
const groupCellKeys = new Set(
group.pieces.map((piece) =>
buildLocalCellKey(piece.localRow, piece.localCol),
),
);
const hasCell = (row: number, col: number) =>
groupCellKeys.has(buildLocalCellKey(row, col));
const edges: GridEdge[] = [];
for (const piece of group.pieces) {
const { localRow: row, localCol: col } = piece;
if (!hasCell(row - 1, col)) {
edges.push({ start: { x: col, y: row }, end: { x: col + 1, y: row } });
}
if (!hasCell(row, col + 1)) {
edges.push({
start: { x: col + 1, y: row },
end: { x: col + 1, y: row + 1 },
});
}
if (!hasCell(row + 1, col)) {
edges.push({
start: { x: col + 1, y: row + 1 },
end: { x: col, y: row + 1 },
});
}
if (!hasCell(row, col - 1)) {
edges.push({ start: { x: col, y: row + 1 }, end: { x: col, y: row } });
}
}
const edgeIndexesByStart = new Map<string, number[]>();
edges.forEach((edge, index) => {
const key = gridPointKey(edge.start);
const indexes = edgeIndexesByStart.get(key) ?? [];
indexes.push(index);
edgeIndexesByStart.set(key, indexes);
});
const unusedEdgeIndexes = new Set(edges.map((_, index) => index));
const cycles: GridPoint[][] = [];
while (unusedEdgeIndexes.size > 0) {
const firstEdgeIndex = unusedEdgeIndexes.values().next().value as
| number
| undefined;
if (firstEdgeIndex === undefined) {
break;
}
const firstEdge = edges[firstEdgeIndex];
if (!firstEdge) {
unusedEdgeIndexes.delete(firstEdgeIndex);
continue;
}
const cycle: GridPoint[] = [firstEdge.start];
let currentEdge = firstEdge;
unusedEdgeIndexes.delete(firstEdgeIndex);
for (let guard = 0; guard < edges.length + 1; guard += 1) {
const currentEnd = currentEdge.end;
const cycleStart = cycle[0];
if (!cycleStart || gridPointKey(currentEnd) === gridPointKey(cycleStart)) {
break;
}
cycle.push(currentEnd);
const nextEdgeIndex = (
edgeIndexesByStart.get(gridPointKey(currentEnd)) ?? []
).find((index) => unusedEdgeIndexes.has(index));
if (nextEdgeIndex === undefined) {
break;
}
const nextEdge = edges[nextEdgeIndex];
if (!nextEdge) {
unusedEdgeIndexes.delete(nextEdgeIndex);
break;
}
currentEdge = nextEdge;
unusedEdgeIndexes.delete(nextEdgeIndex);
}
if (cycle.length >= 3) {
cycles.push(cycle);
}
}
return cycles;
}
export function buildRoundedGridCellClipPath(
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
) {
return buildRoundedGridCyclePath([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: 1 },
], radius);
}
export function buildMergedGroupOutlinePath(
group: PuzzleMergedGroupShape,
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
) {
// 合并块的凹入角不能靠单格 border-radius 稳定拼出来,必须先生成整体外轮廓。
return buildMergedGroupBoundaryCycles(group)
.map((cycle) =>
buildRoundedGridCyclePath(
cycle,
radius,
(corner) => corner,
({ isConvex }) =>
isConvex
? radius
: radius * MERGED_GROUP_CONCAVE_CORNER_RADIUS_FACTOR,
),
)
.filter(Boolean)
.join(' ');
}
export function buildMergedGroupClipPath(
group: PuzzleMergedGroupShape,
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
) {
return buildMergedGroupBoundaryCycles(group)
.map((cycle) =>
buildRoundedGridCyclePath(
cycle,
radius,
(point) => ({
x: point.x / group.colSpan,
y: point.y / group.rowSpan,
}),
({ isConvex }) =>
isConvex
? radius
: radius * MERGED_GROUP_CONCAVE_CORNER_RADIUS_FACTOR,
),
)
.filter(Boolean)
.join(' ');
}
export function sanitizeSvgId(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, '-');
}
export function resolveDraggedPieceCellLayer(
pieceId: string | null | undefined,
draggingPieceId: string | null,
isMerged: boolean,
) {
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
return undefined;
}
return 80;
}
export function resolveDraggedPieceLayer(
pieceId: string | null | undefined,
draggingPieceId: string | null,
isMerged: boolean,
) {
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
return undefined;
}
return 81;
}
export function resolveDraggedMergedGroupLayer(
groupId: string,
draggingGroupId: string | null,
) {
return groupId === draggingGroupId ? 90 : undefined;
}