Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts
T
suzmii b41b26cab8 修复资源画本进栏目只有少数卡片有位移:没有 First 帧的卡片改从那一摞起飞
- apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts:begin() 顺带按 (栏目, 类型摞列号) 记下同一摞最深那张卡的 First 矩形当"堆锚点",play() 时没有 First 帧的卡片认领它当合成 First(仍是同一套 FLIP,不新增动画系统、不为动画多渲染节点);堆锚点只在本次转场有效(maybeFinish / settle / invalidate 清掉),转场中重基(已在跑动画)不认领锚点,起点仍是"此刻像素"。
- apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts:展开态(allOpen)的卡片补上真实类型摞列号,不再一律 0——否则第 2 摞之后的卡片会全从第 1 摞飞出来。
- apps/ai-game-creator-shell/src/view/project-development/index.tsx:卡片宿主补 data-resource-book-stack-column / data-resource-book-stack-index,把"哪一摞"的摞身份交给转场层。
- apps/ai-game-creator-shell/tests/resourceBookController.test.ts:新增 3 条用例——每一张卡都从自己那一摞位移出来(含第 0/1 摞分别认领各自锚点、总览已渲染的仍用自己 First)、转场中重基从此刻像素而不是被拉回摞上、转场收尾后挂载的卡片不从过期锚点飞出。
- apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts:新增"展开态每张卡带自己那一摞的列号"用例(同栏目两摞 ⇒ [0,0,1])。
- apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts:在既有总览/进栏目用例里钉住宿主上的摞身份属性(总览 3 张 = 下标 0,1,2;进栏目后 5 张 = 下标 0..4、同类型列号 0)。
- docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md:把资源画本转场一节更新为当前状态(堆锚点来源、锚点生命周期、展开态列号)。
- docs/project-memory/shared-memory/pitfalls.md:记录本次排障——现象、已用代码核实的原因(总览每摞只铺 3 张 ⇒ 其余卡片没有节点、没有 First 帧)、"钉住的标题栏移出 world"那笔改动不是原因的证据、处理、变异验证与真机判据。
2026-09-12 18:56:43 +08:00

586 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ResourceBookCardPresentation,
ResourceBookCategory,
ResourceBookState,
ResourceBookTarget,
} from './resourceBookModel';
import {
isResourceBookAllTarget,
resourceBookCategoryCardPresentation,
resourceBookShowsAllCategories,
resourceBookShowsExitingCards,
} from './resourceBookModel';
import {
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
type ResourceCanvasCardSize,
resourceCanvasSectionExtent,
} 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;
/**
* 「所有资源」卡那一摞的类型标签占位值。
*
* 这一摞跨类型,没有真实类型名可用;它只在总览摞的分组里当 key,
* 不参与任何持久化或跨端口径。
*/
const RESOURCE_BOOK_ALL_STACK_LABEL = 'all';
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,
);
}
/**
* 「所有资源」展开态里一个栏目的分带。
*
* `originX / originY` 是**卡片空间原点**:栏目内局部坐标加它才得到世界坐标
* (`resourceBookChildCardLayout` 的产物仍是局部坐标)。展开态只铺卡片、不出栏目标题栏
* (见 `ResourceBookSceneCategoryPlan.titlebar`),所以这里没有任何标题栏几何。
*/
export type ResourceBookAllBand = {
category: ResourceBookCategory;
originX: number;
originY: number;
width: number;
height: number;
};
export type ResourceBookAllLayout = {
bands: readonly ResourceBookAllBand[];
bandByCategory: ReadonlyMap<ResourceBookCategory, ResourceBookAllBand>;
/** 全部分带(含标题栏横带)在世界坐标里的并集,供"适应画布"拟合。 */
bounds: { x: number; y: number; width: number; height: number };
};
/** 相邻两条分带之间的竖直留白(上一带最后一张卡到下一条带第一张卡)。 */
export const RESOURCE_BOOK_ALL_BAND_GAP = 48;
/**
* 「所有资源」展开态的分带几何:沿用总览的栏目顺序,每个**有可见资源**的栏目纵向占一带。
*
* 卡片坐标是栏目内局部坐标(见 `resourceCanvasSectionExtent`),铺进同一张画布前必须各给
* 一个带原点,否则各栏目全叠在原点。带宽高取可见资源的 extent:被筛掉的栏目不占屏,
* 与总览"只保留有资源的栏目"同口径。
*
* 带内只有卡片,没有标题栏横带:展开态是"一张平铺画布",唯一的标题栏是钉在视口上的
* `all` 那一条(见 `buildResourceBookScenePlan`)。因此带高就是卡片 extent,带间距就是
* `RESOURCE_BOOK_ALL_BAND_GAP`。没有可见资源的栏目不产生带 —— 这一点是硬约束:
* 曾经"每条带都配一条标题栏"时,空栏目拿不到带矩形,标题栏会全部回落到世界原点叠在一起。
*
* 这里的产物只描述几何,不参与任何持久化:落盘仍只有栏目内局部坐标。
*/
export function buildResourceBookAllLayout({
categoryOrder,
resourcesByCategory,
positions,
cardSizes,
}: {
categoryOrder: readonly ResourceBookCategory[];
resourcesByCategory: ReadonlyMap<ResourceBookCategory, ProjectResource[]>;
positions: ReadonlyMap<string, { x: number; y: number }>;
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
}): ResourceBookAllLayout {
const bands: ResourceBookAllBand[] = [];
const bandByCategory = new Map<ResourceBookCategory, ResourceBookAllBand>();
let cursorTop = 0;
let minX = 0;
let minY = 0;
let maxX = 0;
let maxY = 0;
for (const category of categoryOrder) {
const resources = resourcesByCategory.get(category) ?? [];
if (resources.length === 0) {
continue;
}
const extent = resourceCanvasSectionExtent(
resources.map((resource) => {
const position = positions.get(resource.id);
return {
resourceId: resource.id,
section: category,
x: position?.x ?? 0,
y: position?.y ?? 0,
manuallyPlaced: false,
};
}),
cardSizes,
);
const originY = cursorTop;
const band: ResourceBookAllBand = {
category,
originX: 0,
originY,
width: extent.width,
height: extent.height,
};
bands.push(band);
bandByCategory.set(category, band);
minX = Math.min(minX, extent.x);
maxX = Math.max(maxX, extent.x + extent.width);
minY = Math.min(minY, originY);
maxY = Math.max(maxY, originY + extent.height);
cursorTop = originY + extent.height + RESOURCE_BOOK_ALL_BAND_GAP;
}
return {
bands,
bandByCategory,
bounds: {
x: minX,
y: minY,
width: Math.max(1, Math.ceil(maxX - minX)),
height: Math.max(1, Math.ceil(maxY - minY)),
},
};
}
/**
* 展开态的世界坐标 → 栏目内局部坐标(**落盘前必须走的唯一换算**)。
*
* 展开态按分带铺卡片,卡片画的是"局部坐标 + 带原点";而布局 sidecar 只认栏目内局部坐标
* (`resource-layouts/{dependency,type}.json` 的 `x / y`,`section` 是资源当前分类)。
* 因此拖动提交前减掉带原点:写回的是**这张卡在它自己栏目里的同一个槽位**,栏目页立刻看到
* 同一位置,不存在第二份空间。带不存在(栏目页、或该栏目此刻没有分带)时减 0,
* 与栏目页本来就有的口径完全一致。
*/
export function resourceBookAllBandLocalPoint({
layout,
category,
x,
y,
}: {
layout: ResourceBookAllLayout | null;
category: ResourceBookCategory;
x: number;
y: number;
}) {
const band = layout?.bandByCategory.get(category) ?? null;
return {
x: x - (band?.originX ?? 0),
y: y - (band?.originY ?? 0),
};
}
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: ResourceBookTarget) {
return `title:${category}`;
}
export type ResourceBookSceneCardPlan = {
key: string;
category: ResourceBookTarget;
resource: ProjectResource;
presentation: ResourceBookCardPresentation;
stackIndex: number;
stackColumn: number;
/** 同一类型摞里的卡片总数,用于总览态 z-index。 */
stackCount: number;
layout: ResourceBookCardLayout;
};
export type ResourceBookSceneCategoryPlan = {
category: ResourceBookTarget;
resourceCount: number;
/**
* 这一组要不要渲染栏目标题栏节点。
*
* 展开态只有 `all` 那一条(钉在视口上、带「资源总览」收起入口):展开态是**一张平铺
* 画布**,真实栏目只铺卡片。总览态与栏目态每一组都要(总览态靠它承载计数徽标,
* 栏目态靠它承载当前栏目与返回入口)。
*/
titlebar: boolean;
titlebarActive: boolean;
titlebarOpacity: number;
cards: ResourceBookSceneCardPlan[];
};
/**
* 画本场景渲染计划。渲染、转场签名和淡出集合共用这一份计划,避免"渲染用一套、
* 测量用另一套"的错位:
* - 总览/返回态全部用总览摞布局;
* - 进入栏目时当前栏目换成子画布布局,其他栏目留在 DOM 里按总览布局淡出;
* - 返回总览时源栏目未回到摞上的卡片按子画布布局淡出;
* - 搜索/排序导致的卸载由 `exitingCards` 保留一帧,用同一套淡出撤下。
* - 「所有资源」组在总览态只出栏目标题栏(计数即全量资源数),卡片本体仍由各栏目摞承载;
* 在**展开态**则反过来:各真实栏目把自己分带里的全部卡片平铺在画布上(**不出标题栏**),
* `all` 只留钉住的标题栏与计数。
*/
export function buildResourceBookScenePlan({
state,
visibleCategoryOrder,
resourcesByCategory,
overviewRects,
positions,
cardSizes,
visibleResourceIds,
categoryViewCenters,
cardsReady,
exitingCategory = null,
exitingCards = [],
allLayout = null,
}: {
state: ResourceBookState;
visibleCategoryOrder: readonly ResourceBookTarget[];
resourcesByCategory: ReadonlyMap<ResourceBookTarget, ProjectResource[]>;
overviewRects: ReadonlyMap<ResourceBookTarget, ResourceBookOverviewRect>;
positions: ReadonlyMap<string, { x: number; y: number }>;
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
visibleResourceIds: ReadonlySet<string>;
categoryViewCenters: ReadonlyMap<
ResourceBookTarget,
{ x: number; y: number }
>;
cardsReady: boolean;
exitingCategory?: ResourceBookTarget | null;
exitingCards?: readonly ResourceBookSceneCardPlan[];
/**
* 展开态的分带几何(见 `buildResourceBookAllLayout`)。总览态与栏目态不需要它;
* 只有展开态本身、以及"从展开态返回总览"的淡出会用到带偏移。
*/
allLayout?: ResourceBookAllLayout | null;
}): ResourceBookSceneCategoryPlan[] {
/**
* 展开态(子画布里的 `all`)与"从展开态返回总览"共用一套带偏移规则:
* - `allOpen`:每个真实栏目铺满自己分带里的全部卡片,`all` 只出标题栏与计数;
* - `exitsAllCategories`:刚离开展开态,没回到总览摞的卡片必须**按带位置**淡出,
* 否则它们会贴到未偏移的局部坐标上,返回动画会跳位置。
*/
const allOpen = resourceBookShowsAllCategories(state);
const exitsAllCategories = isResourceBookAllTarget(exitingCategory ?? null);
const bandsActive = allOpen || exitsAllCategories;
const bandFor = (category: ResourceBookTarget) =>
bandsActive && !isResourceBookAllTarget(category)
? (allLayout?.bandByCategory.get(category) ?? null)
: null;
/**
* 子画布布局 + 可选带偏移。偏移同时写进 `x / y` 与 `dragX / dragY`:后者是拖动起点,
* 必须与画出来的世界坐标同系;提交时再减回带原点(`resourceBookAllBandLocalPoint`)。
*/
const childLayoutFor = (
category: ResourceBookTarget,
resource: ProjectResource,
): ResourceBookCardLayout => {
const layout = resourceBookChildCardLayout({
position: positions.get(resource.id),
size: cardSizes.get(resource.id) ?? RESOURCE_BOOK_CARD_FALLBACK_SIZE,
});
const band = bandFor(category);
if (!band) {
return layout;
}
const x = layout.x + band.originX;
const y = layout.y + band.originY;
return { ...layout, x, y, dragX: x, dragY: y };
};
return visibleCategoryOrder.map((category) => {
const presentation = resourceBookCategoryCardPresentation(state, category);
const rect = overviewRects.get(category);
const cards: ResourceBookSceneCardPlan[] = [];
const categoryResources = resourcesByCategory.get(category) ?? [];
/**
* 展开态:`all` 只出标题栏与全量计数(`titlebarActive` 让它钉在视口上,并带上
* 「资源总览」收起入口);真实栏目把全部卡片平铺在自己那条分带里,**不渲染标题栏**。
*
* 真实栏目这一支仍然按 `presentation === 'child'` 取卡,而不是"展开态就一定出卡":
* "能出卡的判定"只有 `resourceBookCategoryCardPresentation` 一处,避免两边口径分叉。
*/
if (allOpen) {
if (isResourceBookAllTarget(category)) {
return {
category,
resourceCount: categoryResources.length,
titlebar: true,
titlebarActive: true,
titlebarOpacity: 1,
cards,
};
}
if (cardsReady && presentation === 'child') {
/**
* 展开态的每张卡仍带"它在总览里属于哪一摞"的列号:总览每摞只铺
* `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,进展开态时的其余卡片在总览侧没有节点、
* 拿不到 First 帧,转场层要按 (栏目, 列号) 认领那一摞的起飞点(见
* `resourceBookController` 的堆锚点)。
*
* 列号只服务这个身份,不影响几何:展开态用分带布局,`stackIndex / stackCount`
* 只服务 z-index,而展开态不设 z-index(见 `ResourceBookScene`)。
*/
const stackColumnByResourceId = new Map<string, number>();
groupResourceBookStacks(categoryResources).forEach(([, items], column) => {
items.forEach((item) => stackColumnByResourceId.set(item.id, column));
});
categoryResources.forEach((resource) => {
cards.push({
key: resourceBookSceneCardKey(resource.id),
category,
resource,
presentation: 'child',
stackIndex: 0,
stackColumn: stackColumnByResourceId.get(resource.id) ?? 0,
stackCount: 1,
layout: childLayoutFor(category, resource),
});
});
}
return {
category,
resourceCount: categoryResources.length,
titlebar: false,
titlebarActive: false,
titlebarOpacity: 0,
cards,
};
}
/**
* 「所有资源」卡的总览摞只铺一列。
*
* 它拿的是全量投影(与计数徽标同一份),按类型分列会一口气铺出十几列、在总览里
* 立刻失控;这里把它当成一摞,交给与栏目卡同一个选取函数:没有视图中心时该函数
* 直接按投影顺序取前 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,和栏目卡每摞的上限同口径。
*/
const stacks = isResourceBookAllTarget(category)
? [
[
RESOURCE_BOOK_ALL_STACK_LABEL,
categoryResources,
] as ResourceBookStack,
]
: 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
? childLayoutFor(category, resource)
: resourceBookOverviewCardLayout({
rect,
stackIndex,
stackColumn,
}),
});
});
});
}
if (
// 「所有资源」组永远不承载 child / exiting 卡:展开态它只出标题栏,总览态只出预览摞
// (预览摞是刻意不进转场层的第二份宿主)。真实卡片一律由各真实栏目组承载。
!isResourceBookAllTarget(category) &&
resourceBookShowsExitingCards(state) &&
(exitsAllCategories || 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: childLayoutFor(category, resource),
});
});
}
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,
titlebar: true,
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}`;
}