Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts
T
suzmii 4fb0de2c94 C3 资源画本转场改为真实元素 FLIP
- resourceBookController 重写为真实元素 FLIP:begin 在提交前记录 First,play 测 Last 后给同一节点加反向 transform,sync 按布局签名做可中断重基
- 转场进行中重基用剩余时长(下限 80ms),空闲布局变化用完整 420ms,预览图尺寸晚到的二次布局变化不再瞬移
- 宿主节点几何基准改存 world 局部坐标,平移和缩放不会让上一次的干净矩形过期
- 不可见节点钉在 First 淡出、挂载节点淡入;搜索/排序导致的卸载保留一帧淡出;返回总览时源栏目未回到摞上的卡片按子画布布局淡出
- 新增 buildResourceBookScenePlan 渲染计划,渲染、转场签名与淡出集合共用同一份数据
- 删除 ResourceBookTransitionLayer 与克隆快照逻辑,清理 styles.css 的转场层和 data-book-motion 隐藏规则
- 重写 resourceBookController.test.ts,补 resourceBookLayout/resourceBookModel 用例与 appSurface 同节点断言
- 同步技术方案转场段与 pitfalls 经验条目
2026-09-09 20:24:10 +08:00

331 lines
10 KiB
TypeScript

import type {
ResourceBookCardPresentation,
ResourceBookCategory,
ResourceBookState,
} from './resourceBookModel';
import {
resourceBookCategoryCardPresentation,
resourceBookShowsExitingCards,
} from './resourceBookModel';
import {
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
type ResourceCanvasCardSize,
} from './resourceCanvasLayoutModel';
import type { ProjectResource } from './resourceProjectionModel';
import { projectResourceTypeLabel } from './resourceProjectionModel';
export type ResourceBookOverviewRect = {
left: number;
top: number;
width: number;
height: number;
};
export type ResourceBookStack = [type: string, items: ProjectResource[]];
export type ResourceBookCardLayout = {
x: number;
y: number;
width: number;
height: number;
dragX: number;
dragY: number;
rotation: number;
};
// 总览每个资源类型最多铺这么多张卡片;其余数量由标题栏的“N 项”表达。
// 叠放角度同样只取这几个值,避免素材变多后旋转角随下标无限增大。
const overviewStackRotations = [0.5, -1.5, 2.5] as const;
export const RESOURCE_BOOK_OVERVIEW_STACK_LIMIT = overviewStackRotations.length;
export function groupResourceBookStacks(
resources: readonly ProjectResource[],
): ResourceBookStack[] {
const groups = new Map<string, ProjectResource[]>();
for (const resource of resources) {
const items = groups.get(projectResourceTypeLabel(resource)) ?? [];
items.push(resource);
groups.set(projectResourceTypeLabel(resource), items);
}
return Array.from(
groups,
([type, items]) => [type, items] as ResourceBookStack,
);
}
export function groupResourceBookResourcesByCategory(
resources: readonly ProjectResource[],
) {
const result = new Map<ResourceBookCategory, ProjectResource[]>();
for (const resource of resources) {
const items = result.get(resource.category) ?? [];
items.push(resource);
result.set(resource.category, items);
}
return result;
}
/**
* 总览摞只铺有限张卡。挑哪几张决定了进入子画布时的观感:若按数组顺序取,
* 用户看到的卡和入场视口里最近的卡不是同一批,FLIP 找不到对应终点,动画会
* 退化成淡入淡出、返回时还会跳成另一张图。这里改为取离当前视图中心最近的卡。
*/
export function selectResourceBookOverviewCards({
resources,
visibleResourceIds,
positions,
cardSizes,
viewCenter,
limit = RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
}: {
resources: readonly ProjectResource[];
visibleResourceIds: ReadonlySet<string>;
positions: ReadonlyMap<string, { x: number; y: number }>;
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
viewCenter: { x: number; y: number } | undefined;
limit?: number;
}): ProjectResource[] {
const visible = resources.filter((resource) =>
visibleResourceIds.has(resource.id),
);
if (!viewCenter || visible.length <= limit) {
return visible.slice(0, limit);
}
const distance = (resource: ProjectResource) => {
const position = positions.get(resource.id);
if (!position) return Number.POSITIVE_INFINITY;
const size = cardSizes.get(resource.id) ?? {
width: RESOURCE_CANVAS_CARD_WIDTH,
height: RESOURCE_CANVAS_CARD_HEIGHT,
};
const centerX = position.x + size.width / 2;
const centerY = position.y + size.height / 2;
return (centerX - viewCenter.x) ** 2 + (centerY - viewCenter.y) ** 2;
};
return visible
.map((resource, index) => ({
resource,
index,
distance: distance(resource),
}))
.sort(
(left, right) =>
left.distance - right.distance || left.index - right.index,
)
.slice(0, limit)
.map((entry) => entry.resource);
}
export function resourceBookOverviewCardLayout({
rect,
stackIndex,
stackColumn,
}: {
rect: ResourceBookOverviewRect | undefined;
stackIndex: number;
stackColumn: number;
}): ResourceBookCardLayout {
const rotationIndex = Math.min(
Math.max(stackIndex, 0),
overviewStackRotations.length - 1,
);
return {
x: (rect?.left ?? 0) + 18 + (stackColumn % 3) * 102,
y: (rect?.top ?? 0) + 58 + Math.min(stackIndex, 2) * 5,
width: 92,
height: 64,
dragX: 0,
dragY: 0,
rotation: overviewStackRotations[rotationIndex] ?? 0,
};
}
export function resourceBookChildCardLayout({
position,
size,
}: {
position: { x: number; y: number } | undefined;
size: ResourceCanvasCardSize;
}): ResourceBookCardLayout {
const x = position?.x ?? 0;
const y = position?.y ?? 0;
// The child scene applies the viewport transform on its card-world wrapper.
// Keep card geometry logical here so persistence, drag callbacks, and the
// public ResourceCard style contract are not polluted by screen space.
return {
x,
y,
width: size.width,
height: size.height,
dragX: x,
dragY: y,
rotation: 0,
};
}
const RESOURCE_BOOK_CARD_FALLBACK_SIZE: ResourceCanvasCardSize = {
width: 180,
height: 128,
};
export function resourceBookSceneCardKey(resourceId: string) {
return `card:${resourceId}`;
}
export function resourceBookSceneTitleKey(category: ResourceBookCategory) {
return `title:${category}`;
}
export type ResourceBookSceneCardPlan = {
key: string;
category: ResourceBookCategory;
resource: ProjectResource;
presentation: ResourceBookCardPresentation;
stackIndex: number;
stackColumn: number;
/** 同一类型摞里的卡片总数,用于总览态 z-index。 */
stackCount: number;
layout: ResourceBookCardLayout;
};
export type ResourceBookSceneCategoryPlan = {
category: ResourceBookCategory;
resourceCount: number;
titlebarActive: boolean;
titlebarOpacity: number;
cards: ResourceBookSceneCardPlan[];
};
/**
* 画本场景渲染计划。渲染、转场签名和淡出集合共用这一份计划,避免"渲染用一套、
* 测量用另一套"的错位:
* - 总览/返回态全部用总览摞布局;
* - 进入栏目时当前栏目换成子画布布局,其他栏目留在 DOM 里按总览布局淡出;
* - 返回总览时源栏目未回到摞上的卡片按子画布布局淡出;
* - 搜索/排序导致的卸载由 `exitingCards` 保留一帧,用同一套淡出撤下。
*/
export function buildResourceBookScenePlan({
state,
visibleCategoryOrder,
resourcesByCategory,
overviewRects,
positions,
cardSizes,
visibleResourceIds,
categoryViewCenters,
cardsReady,
exitingCategory = null,
exitingCards = [],
}: {
state: ResourceBookState;
visibleCategoryOrder: readonly ResourceBookCategory[];
resourcesByCategory: ReadonlyMap<ResourceBookCategory, ProjectResource[]>;
overviewRects: ReadonlyMap<ResourceBookCategory, ResourceBookOverviewRect>;
positions: ReadonlyMap<string, { x: number; y: number }>;
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
visibleResourceIds: ReadonlySet<string>;
categoryViewCenters: ReadonlyMap<
ResourceBookCategory,
{ x: number; y: number }
>;
cardsReady: boolean;
exitingCategory?: ResourceBookCategory | null;
exitingCards?: readonly ResourceBookSceneCardPlan[];
}): ResourceBookSceneCategoryPlan[] {
return visibleCategoryOrder.map((category) => {
const presentation = resourceBookCategoryCardPresentation(state, category);
const rect = overviewRects.get(category);
const cards: ResourceBookSceneCardPlan[] = [];
const categoryResources = resourcesByCategory.get(category) ?? [];
const stacks = groupResourceBookStacks(categoryResources);
const childLayout = presentation === 'child';
if (cardsReady && presentation) {
stacks.forEach(([, items], stackColumn) => {
// 总览只铺有限张,数量由栏目标题栏的“N 项”表达;子画布渲染全部资源。
const stackItems = childLayout
? items
: selectResourceBookOverviewCards({
resources: items,
visibleResourceIds,
positions,
cardSizes,
viewCenter: categoryViewCenters.get(category),
});
stackItems.forEach((resource, stackIndex) => {
cards.push({
key: resourceBookSceneCardKey(resource.id),
category,
resource,
presentation: childLayout ? 'child' : 'overview',
stackIndex,
stackColumn,
stackCount: stackItems.length,
layout: childLayout
? resourceBookChildCardLayout({
position: positions.get(resource.id),
size:
cardSizes.get(resource.id) ??
RESOURCE_BOOK_CARD_FALLBACK_SIZE,
})
: resourceBookOverviewCardLayout({
rect,
stackIndex,
stackColumn,
}),
});
});
});
}
if (
resourceBookShowsExitingCards(state) &&
exitingCategory === category &&
cardsReady
) {
const onStack = new Set(
cards
.filter((card) => card.presentation === 'overview')
.map((card) => card.key),
);
categoryResources.forEach((resource) => {
const key = resourceBookSceneCardKey(resource.id);
if (onStack.has(key)) return;
if (!visibleResourceIds.has(resource.id)) return;
cards.push({
key,
category,
resource,
presentation: 'exiting',
stackIndex: 0,
stackColumn: 0,
stackCount: 1,
layout: resourceBookChildCardLayout({
position: positions.get(resource.id),
size:
cardSizes.get(resource.id) ?? RESOURCE_BOOK_CARD_FALLBACK_SIZE,
}),
});
});
}
exitingCards.forEach((card) => {
if (card.category !== category) return;
if (cards.some((entry) => entry.key === card.key)) return;
cards.push({ ...card, presentation: 'exiting' });
});
return {
category,
resourceCount: categoryResources.length,
titlebarActive: presentation === 'child',
titlebarOpacity: childLayout || state.view === 'main' ? 1 : 0,
cards,
};
});
}
export function resourceBookSceneCardLayoutSignature(
card: ResourceBookSceneCardPlan,
) {
return `${card.presentation}:${card.layout.x},${card.layout.y}:${card.layout.width},${card.layout.height}:${card.layout.rotation}`;
}