From b41b26cab87b06451e75b55a2b9dae37172edbce Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 18:56:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90=E7=94=BB?= =?UTF-8?q?=E6=9C=AC=E8=BF=9B=E6=A0=8F=E7=9B=AE=E5=8F=AA=E6=9C=89=E5=B0=91?= =?UTF-8?q?=E6=95=B0=E5=8D=A1=E7=89=87=E6=9C=89=E4=BD=8D=E7=A7=BB=EF=BC=9A?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=20First=20=E5=B8=A7=E7=9A=84=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=94=B9=E4=BB=8E=E9=82=A3=E4=B8=80=E6=91=9E=E8=B5=B7?= =?UTF-8?q?=E9=A3=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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"那笔改动不是原因的证据、处理、变异验证与真机判据。 --- .../src/view/project-development/index.tsx | 8 + .../resourceBookController.ts | 120 +++++++- .../project-development/resourceBookLayout.ts | 15 +- .../appSurface/project-development.suite.ts | 26 ++ .../tests/resourceBookController.test.ts | 291 ++++++++++++++++++ .../tests/resourceBookLayout.test.ts | 58 ++++ docs/project-memory/shared-memory/pitfalls.md | 10 + ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 3 +- 8 files changed, 517 insertions(+), 14 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index fe4827914..a63058a09 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -1293,6 +1293,14 @@ function ResourceBookScene({
; + stackAnchors: Map; +}; + +/** + * 节点在总览里的"那一摞"身份:栏目 + 类型摞的列号(与 + * `resourceBookOverviewCardLayout` 的 `stackColumn` 同一口径,见 + * `buildResourceBookScenePlan`)。渲染层把这两个数写在卡片宿主上,转场层不认布局函数 + * 也能把卡片认回它自己那一摞。 + */ +type MotionStackIdentity = { + category: string; + column: number; + index: number; +}; + +function readStackIdentity(host: HTMLElement): MotionStackIdentity | null { + const category = host.dataset.resourceBookCategory; + const column = Number.parseInt( + host.dataset.resourceBookStackColumn ?? '', + 10, + ); + if (!category || !Number.isFinite(column)) return null; + const index = Number.parseInt(host.dataset.resourceBookStackIndex ?? '', 10); + return { category, column, index: Number.isFinite(index) ? index : 0 }; +} + +function stackAnchorKey(category: string, column: number) { + return `${category}\n${column}`; +} + +/** 没有 First 帧的卡片:用它自己那一摞的起飞点当合成 First(没有锚点就返回 null)。 */ +function readStackAnchor( + host: HTMLElement, + stackAnchors: ReadonlyMap | null, +): ResourceBookMotionRect | null { + if (!stackAnchors?.size) return null; + const stack = readStackIdentity(host); + if (!stack) return null; + const rect = stackAnchors.get(stackAnchorKey(stack.category, stack.column)); + return resourceBookValidMotionRect(rect) ? rect : null; +} + type MotionNode = { key: string; host: HTMLElement; @@ -284,7 +340,14 @@ export type ResourceBookTransitionController = ReturnType< export function createResourceBookTransitionController() { let token = 0; const records = new Map(); - let snapshot: Map | null = null; + let snapshot: MotionSnapshot | null = null; + /** + * 本次转场的堆锚点,只在这次转场期间有效。 + * + * 转场一结束(`maybeFinish`)就清掉:否则转场之后才挂载的卡片会从一段过期的"摞" + * 位置飞出来(那时它和新位置毫无关系)。 + */ + let stackAnchors: ReadonlyMap | null = null; let completion: (() => void) | null = null; let phasePending = false; let phaseStartedAt = 0; @@ -311,6 +374,7 @@ export function createResourceBookTransitionController() { completion = null; phasePending = false; phaseStartedAt = 0; + stackAnchors = null; done?.(); }; @@ -320,6 +384,7 @@ export function createResourceBookTransitionController() { completion = null; phasePending = false; phaseStartedAt = 0; + stackAnchors = null; done?.(); }; @@ -327,6 +392,7 @@ export function createResourceBookTransitionController() { token += 1; records.forEach(cancelRecord); snapshot = null; + stackAnchors = null; completion = null; phasePending = false; phaseStartedAt = 0; @@ -469,6 +535,17 @@ export function createResourceBookTransitionController() { const previousSignature = record.signature; const previousTargetOpacity = record.targetOpacity; const hadAnimation = record.animation !== null; + /** + * 合成 First:没有 First 帧、当前又没有动画在跑的卡片(总览每摞只铺 3 张,进栏目 + * 时其余卡片在总览侧没有节点),从它自己那一摞的起飞点开始。 + * + * 已经在跑动画的节点(转场中重基)不认领锚点:它的起点必须是"此刻像素",否则会被 + * 拉回那一摞。 + */ + const stackAnchor = + snapshotEntry === null && !hadAnimation && targetOpacity > 0 + ? readStackAnchor(node.host, stackAnchors) + : null; const signatureChanged = previousSignature !== '' && previousSignature !== signature; const opacityChanged = previousTargetOpacity !== targetOpacity; @@ -478,6 +555,7 @@ export function createResourceBookTransitionController() { (remounted && record.cleanRect !== null); const wantMotion = Boolean(snapshotEntry) || + Boolean(stackAnchor) || signatureChanged || opacityChanged || (mountFade && targetOpacity > 0 && !hadAnimation); @@ -495,17 +573,21 @@ export function createResourceBookTransitionController() { const previousCleanRect = record.cleanRect; const startOpacity = hadAnimation ? readVisualOpacity(node.host, previousTargetOpacity) - : mountFade - ? 0 - : previousTargetOpacity; + : stackAnchor + ? targetOpacity + : mountFade + ? 0 + : previousTargetOpacity; cancelRecord(record); record.signature = signature; record.targetOpacity = targetOpacity; record.cleanRect = toWorldRect(readRect(node.box), nodeWorld); if (suppressed) continue; - // 转场用 begin 记录的 First;动画中途重基用"此刻像素";空闲布局变化用上一次的干净矩形。 + // 转场用 begin 记录的 First(没有 First 的卡片用那一摞的堆锚点);动画中途重基用 + // "此刻像素";空闲布局变化用上一次的干净矩形。 const first = snapshotEntry?.rect ?? + stackAnchor ?? (hadAnimation ? visual : previousCleanRect @@ -543,9 +625,15 @@ export function createResourceBookTransitionController() { * 非活动栏目的标题栏在子画布态就属于这种情况——它的布局坐标还是总览矩形,但被 * 子画布 world 变换推到了屏幕外。若把它当 First,返回总览时标题栏会从屏幕外飞 * 进来。没有 First 的节点只走淡入,位置保持不动。 + * + * 顺带记录每摞的起飞点:同一栏目、同一摞里**最深那张卡**(`stackIndex` 最大)的矩形。 + * 总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,进栏目时的其余卡片在总览侧 + * 没有节点、拿不到 First,它们就从这个矩形起飞(见 `stackAnchors`)。 */ begin(manager: HTMLElement | null) { - const captured = new Map(); + const entries = new Map(); + const anchors = new Map(); + const anchorDepths = new Map(); if (manager) { for (const node of collectNodes(manager)) { const opacity = readVisualOpacity( @@ -555,17 +643,23 @@ export function createResourceBookTransitionController() { if (opacity <= 0.001) continue; const rect = readRect(node.box); if (!resourceBookValidMotionRect(rect)) continue; - captured.set(node.key, { rect, opacity }); + entries.set(node.key, { rect, opacity }); + const stack = readStackIdentity(node.host); + if (!stack) continue; + const anchorKey = stackAnchorKey(stack.category, stack.column); + if ((anchorDepths.get(anchorKey) ?? -1) >= stack.index) continue; + anchorDepths.set(anchorKey, stack.index); + anchors.set(anchorKey, rect); } } invalidate(); - snapshot = captured; + snapshot = { entries, stackAnchors: anchors }; return token; }, invalidate, settle, isCurrent: (expected: number) => token === expected, - /** 目标 DOM 提交后播放转场:用 begin 记录的 First 反算反向 transform。 */ + /** 目标 DOM 提交后播放转场:用 begin 记录的 First(或那一摞的堆锚点)反算反向 transform。 */ play( manager: HTMLElement | null, expected: number, @@ -574,22 +668,24 @@ export function createResourceBookTransitionController() { frozenKeys?: ReadonlySet, ) { if (token !== expected) return; - const first = snapshot; + const captured = snapshot; snapshot = null; completion = done; phasePending = true; phaseStartedAt = performance.now(); if ( !manager || - !first?.size || + !captured?.entries.size || prefersReducedMotion() || !supportsMotion(manager) ) { settle(); return; } + // 只有真正在铺卡片的转场才带堆锚点:没有任何 First 帧时整段转场本来就是跳过的。 + stackAnchors = captured.stackAnchors; syncNodes(manager, signatures, { - snapshot: first, + snapshot: captured.entries, frozenKeys, forceMountFadeIn: true, duration: RESOURCE_BOOK_MOTION_DURATION, diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts index 191a6f08e..71ee3ccdb 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts @@ -450,6 +450,19 @@ export function buildResourceBookScenePlan({ }; } if (cardsReady && presentation === 'child') { + /** + * 展开态的每张卡仍带"它在总览里属于哪一摞"的列号:总览每摞只铺 + * `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,进展开态时的其余卡片在总览侧没有节点、 + * 拿不到 First 帧,转场层要按 (栏目, 列号) 认领那一摞的起飞点(见 + * `resourceBookController` 的堆锚点)。 + * + * 列号只服务这个身份,不影响几何:展开态用分带布局,`stackIndex / stackCount` + * 只服务 z-index,而展开态不设 z-index(见 `ResourceBookScene`)。 + */ + const stackColumnByResourceId = new Map(); + groupResourceBookStacks(categoryResources).forEach(([, items], column) => { + items.forEach((item) => stackColumnByResourceId.set(item.id, column)); + }); categoryResources.forEach((resource) => { cards.push({ key: resourceBookSceneCardKey(resource.id), @@ -457,7 +470,7 @@ export function buildResourceBookScenePlan({ resource, presentation: 'child', stackIndex: 0, - stackColumn: 0, + stackColumn: stackColumnByResourceId.get(resource.id) ?? 0, stackCount: 1, layout: childLayoutFor(category, resource), }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 258e5acb4..ca7b8389c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -1178,6 +1178,16 @@ export function registerProjectWorkbenchFoundationTests() { ), ).map((card) => Number(card.style.zIndex)); expect(overviewCardLayers[0]).toBeGreaterThan(overviewCardLayers.at(-1)!); + // 摞身份(栏目 × 列号 × 摞内下标)写在卡片宿主上:总览每摞只铺 N 张,进栏目时其余卡片在 + // 总览侧没有节点、拿不到 First 帧,转场层靠这两个数把它们认回自己那一摞的起飞点 + // (见 `resourceBookController` 的堆锚点)。 + expect( + Array.from( + document.querySelectorAll( + '.game-resource-book-scene-card[data-resource-book-category="character"]', + ), + ).map((card) => card.dataset.resourceBookStackIndex), + ).toEqual(['0', '1', '2']); await openResourceBookCategory('角色与对象'); await waitFor(() => @@ -1187,6 +1197,22 @@ export function registerProjectWorkbenchFoundationTests() { ), ).toHaveLength(5), ); + // 进栏目后铺满全部卡片,每张卡都还带着自己那一摞的身份:没有 First 帧的那几张正是靠它 + // 从摞里飞出来(只带下标、不带列号,或者干脆不带,都会退化成原地淡入)。 + expect( + Array.from( + document.querySelectorAll( + '.game-resource-book-scene-card.is-expanded[data-resource-book-category="character"]', + ), + ).map((card) => card.dataset.resourceBookStackIndex), + ).toEqual(['0', '1', '2', '3', '4']); + expect( + Array.from( + document.querySelectorAll( + '.game-resource-book-scene-card.is-expanded[data-resource-book-category="character"]', + ), + ).map((card) => card.dataset.resourceBookStackColumn), + ).toEqual(['0', '0', '0', '0', '0']); fireEvent.change(openResourceSearch(), { target: { value: 'overview-art-4' }, diff --git a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts index 37b6c5571..adc8d6366 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts @@ -160,6 +160,297 @@ afterEach(() => { document.body.replaceChildren(); }); +type StackCardSpec = { + id: string; + /** 总览里的类型摞列号(与 `resourceBookOverviewCardLayout` 的 `stackColumn` 同口径)。 */ + column: number; + /** 摞内下标:越大越深、越靠后。 */ + index: number; + rect: MotionRect; +}; + +/** + * 多卡宿主:把场景世界换成"一根栏目摞"。 + * + * 宿主是 0x0 的定位包装,它的原点就是卡片自己的落点(经典 FLIP 起点);真假两态都复用它, + * 差别只在传入的卡片清单与矩形——总览一摞只铺 3 张,进栏目后这一摞铺满。 + */ +function mountStackCards(world: HTMLElement, cards: StackCardSpec[]) { + world.replaceChildren(); + const hosts = new Map(); + const boxes = new Map(); + for (const card of cards) { + const host = document.createElement('div'); + host.className = 'game-resource-book-scene-card'; + host.dataset.resourceBookCategory = 'unclassified'; + host.dataset.resourceBookStackColumn = String(card.column); + host.dataset.resourceBookStackIndex = String(card.index); + const box = document.createElement('div'); + box.className = 'game-resource-card'; + box.dataset.resourceCardId = card.id; + host.append(box); + world.append(host); + vi.spyOn(host, 'getBoundingClientRect').mockImplementation(() => + toDomRect({ + left: card.rect.left, + top: card.rect.top, + width: 0, + height: 0, + }), + ); + vi.spyOn(box, 'getBoundingClientRect').mockImplementation(() => + toDomRect(card.rect), + ); + hosts.set(card.id, host); + boxes.set(card.id, box); + } + return { hosts, boxes }; +} + +/** 卡片宿主上必须正好有一段动画;返回它,避免逐处 `flights.find(...)!`。 */ +function flightFor(flights: Flight[], host: Element | undefined) { + const matches = flights.filter((flight) => flight.host === host); + expect(matches).toHaveLength(1); + return matches[0]!; +} + +describe('resource book pile anchors', () => { + /** + * 用户口径:进栏目时**每一张卡**都要从那摞里位移出来,而不是只有总览里已经渲染的那几张。 + * + * 总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张(其余数量写在"N 项"里),所以 + * 大部分卡片在总览侧没有节点、拿不到 First 帧;它们必须从自己那一摞起飞(合成 First), + * 而不是原地淡入。 + */ + it('flies every card of the opened category out of its own overview pile', () => { + const flights = installAnimateMock(); + const { manager, world, signatures } = fixture(); + // 总览:第 0 摞铺满 3 张(下标 2 最深,就是"那一摞"的落点),第 1 摞只有 1 张。 + mountStackCards(world, [ + { + id: 'art-0', + column: 0, + index: 0, + rect: { left: 100, top: 100, width: 92, height: 64 }, + }, + { + id: 'art-1', + column: 0, + index: 1, + rect: { left: 100, top: 105, width: 92, height: 64 }, + }, + { + id: 'art-2', + column: 0, + index: 2, + rect: { left: 100, top: 110, width: 92, height: 64 }, + }, + { + id: 'art-3', + column: 1, + index: 0, + rect: { left: 202, top: 110, width: 92, height: 64 }, + }, + ]); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + + // 提交后:这一摞铺满全部卡片,art-4 / art-5 / art-6 在总览侧从来没有节点。 + const { hosts } = mountStackCards(world, [ + { + id: 'art-0', + column: 0, + index: 0, + rect: { left: 400, top: 500, width: 180, height: 128 }, + }, + { + id: 'art-1', + column: 0, + index: 1, + rect: { left: 400, top: 700, width: 180, height: 128 }, + }, + { + id: 'art-2', + column: 0, + index: 2, + rect: { left: 400, top: 900, width: 180, height: 128 }, + }, + { + id: 'art-4', + column: 0, + index: 3, + rect: { left: 700, top: 500, width: 180, height: 128 }, + }, + { + id: 'art-5', + column: 0, + index: 4, + rect: { left: 700, top: 700, width: 180, height: 128 }, + }, + { + id: 'art-3', + column: 1, + index: 0, + rect: { left: 1_000, top: 500, width: 180, height: 128 }, + }, + { + id: 'art-6', + column: 1, + index: 1, + rect: { left: 1_000, top: 700, width: 180, height: 128 }, + }, + ]); + controller.play(manager, token, () => undefined, signatures); + + // 名单:这一栏目进的每一张卡都要有位移(起点 ≠ 终点),不是只有总览那几张。 + for (const [id, host] of hosts) { + const flight = flightFor(flights, host); + expect(flight.frames[0]!.transform, id).not.toBe( + flight.frames[1]!.transform, + ); + expect(flight.frames[1]!.transform, id).toBe('none'); + } + + // 起点是"自己那一摞"最深那张卡的矩形。 + expect(flightFor(flights, hosts.get('art-4')).frames[0]!.transform).toBe( + 'translate(-600px, -390px) scale(0.511, 0.5)', + ); + // 第 1 摞的卡片从第 1 摞起飞,不会被贴到第 0 摞的落点上。 + expect(flightFor(flights, hosts.get('art-6')).frames[0]!.transform).toBe( + 'translate(-798px, -590px) scale(0.511, 0.5)', + ); + // 总览里已经渲染的那几张仍从它自己的 First 帧起飞。 + expect(flightFor(flights, hosts.get('art-0')).frames[0]!.transform).toBe( + 'translate(-300px, -400px) scale(0.511, 0.5)', + ); + // 从摞里出来是"看得见地飞",不叠一层原地淡入。 + expect(flightFor(flights, hosts.get('art-4')).frames[0]!.opacity).toBe(1); + expect(flightFor(flights, hosts.get('art-4')).frames[1]!.opacity).toBe(1); + controller.dispose(); + }); + + /** + * 卡片已经飞出那一摞、正在动的时候,预览图尺寸才到(布局再变一次)。 + * + * 重基必须从"此刻像素"续上:堆锚点在整段转场里都还活着,一旦重基也去认领它, + * 卡片会被拉回摞上再飞一次——用户看到的是"飞一半又跳回卡片堆"。 + */ + it('rebases a flying card from the current pixels instead of the pile anchor', () => { + const flights = installAnimateMock(); + const now = vi.spyOn(performance, 'now'); + now.mockReturnValue(1_000); + const { manager, world, signatures } = fixture(); + mountStackCards(world, [ + { + id: 'art-0', + column: 0, + index: 0, + rect: { left: 100, top: 100, width: 92, height: 64 }, + }, + ]); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + const { hosts, boxes } = mountStackCards(world, [ + { + id: 'art-0', + column: 0, + index: 0, + rect: { left: 400, top: 500, width: 180, height: 128 }, + }, + { + id: 'art-4', + column: 0, + index: 1, + rect: { left: 700, top: 500, width: 180, height: 128 }, + }, + ]); + signatures.set('card:art-0', 'child:400,500:180,128'); + signatures.set('card:art-4', 'child:700,500:180,128'); + controller.play(manager, token, () => undefined, signatures); + const flying = flightFor(flights, hosts.get('art-4')); + expect(flying.frames[0]!.transform).not.toBe('none'); + + now.mockReturnValue(1_300); + const visual: MotionRect = { left: 650, top: 470, width: 140, height: 100 }; + const clean: MotionRect = { left: 720, top: 520, width: 220, height: 160 }; + vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation(() => + toDomRect(visual), + ); + flying.cancel.mockImplementation(() => { + vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation(() => + toDomRect(clean), + ); + flying.reject(); + }); + signatures.set('card:art-4', 'child:720,520:220,160'); + controller.sync(manager, signatures); + + const rebased = flights.filter( + (flight) => flight.host === hosts.get('art-4'), + ); + expect(rebased).toHaveLength(2); + expect(rebased[1]!.frames[0]!.transform).toBe( + resourceBookFlipTransform(visual, clean, { left: 700, top: 500 }), + ); + expect(rebased[1]!.frames[1]!.transform).toBe('none'); + controller.dispose(); + }); + + it('does not fly a card mounted after the transition out of a stale pile', async () => { + const flights = installAnimateMock(); + const { manager, world, signatures } = fixture(); + mountStackCards(world, [ + { + id: 'art-0', + column: 0, + index: 0, + rect: { left: 100, top: 100, width: 92, height: 64 }, + }, + ]); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + mountStackCards(world, [ + { + id: 'art-0', + column: 0, + index: 0, + rect: { left: 400, top: 500, width: 180, height: 128 }, + }, + { + id: 'art-4', + column: 0, + index: 1, + rect: { left: 700, top: 500, width: 180, height: 128 }, + }, + ]); + controller.play(manager, token, () => undefined, signatures); + for (const flight of flights) flight.finish(); + await vi.waitFor(() => expect(controller.isAnimating()).toBe(false)); + + // 转场收尾后堆锚点必须失效:否则此后挂载的卡片会从一段过期位置飞出来。 + const { hosts: lateHosts } = mountStackCards(world, [ + { + id: 'art-0', + column: 0, + index: 0, + rect: { left: 400, top: 500, width: 180, height: 128 }, + }, + { + id: 'art-9', + column: 0, + index: 1, + rect: { left: 900, top: 900, width: 180, height: 128 }, + }, + ]); + controller.sync(manager, signatures); + + expect( + flights.filter((flight) => flight.host === lateHosts.get('art-9')), + ).toHaveLength(0); + controller.dispose(); + }); +}); + describe('resource book FLIP geometry', () => { it('uses the classic FLIP transform when the origin is the last box', () => { expect( diff --git a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts index e7494b287..ec491e7b5 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts @@ -477,6 +477,64 @@ describe('buildResourceBookScenePlan', () => { expect(keys).toHaveLength(allResources.length); }); + /** + * 展开态(进「所有资源」)也要把每张卡认回"它在总览里属于哪一摞"。 + * + * 总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,展开态铺满全部卡片:没有 First + * 帧的卡片由转场层按 (栏目, 列号) 认领那一摞的起飞点(见 `resourceBookController` 的堆锚点)。 + * 列号一律写 0 的话,第 2 摞之后的卡片会全都从第 1 摞飞出来。 + */ + it('keeps every expanded card on its own overview pile column', () => { + const svg = { + ...resource('svg-0'), + path: 'assets/svg-0.svg', + mediaType: 'image/svg+xml', + }; + // 同一个栏目里两摞:'图片' 一摞(2 张)+ 'SVG' 一摞(1 张)。 + const mixed = [resources[0]!, resources[1]!, svg]; + const mixedPositions = new Map([ + [resources[0]!.id, { x: 0, y: 0 }], + [resources[1]!.id, { x: 200, y: 0 }], + [svg.id, { x: 0, y: 300 }], + ]); + const mixedCardSizes = new Map( + mixed.map((item) => [item.id, { width: 180, height: 128 }]), + ); + const allLayout = buildResourceBookAllLayout({ + categoryOrder: ['unclassified'], + resourcesByCategory: new Map([ + ['unclassified', mixed], + ]), + positions: mixedPositions, + cardSizes: mixedCardSizes, + }); + const plan = buildResourceBookScenePlan({ + ...base, + visibleCategoryOrder: [RESOURCE_BOOK_ALL_TARGET, 'unclassified'], + resourcesByCategory: new Map([ + [RESOURCE_BOOK_ALL_TARGET, mixed], + ['unclassified', mixed], + ]), + overviewRects: new Map(), + positions: mixedPositions, + cardSizes: mixedCardSizes, + visibleResourceIds: new Set(mixed.map((item) => item.id)), + state: { + view: 'child', + category: RESOURCE_BOOK_ALL_TARGET, + phase: 'idle', + token: 1, + }, + allLayout, + }); + + const cards = plan.find((group) => group.category === 'unclassified')!.cards; + expect(cards.map((card) => card.resource.id)).toEqual( + mixed.map((item) => item.id), + ); + expect(cards.map((card) => card.stackColumn)).toEqual([0, 0, 1]); + }); + it('fades the leaving all-resources cards from their band positions', () => { const allLayout = buildResourceBookAllLayout({ categoryOrder: ['unclassified'], diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 60bec3e9d..5e6302247 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5413,3 +5413,13 @@ - **验证**:appSurface 钉住新不变量——钉住的标题栏内联 `transform` 必须为空、`closest('.game-resource-book-scene-world')` 必须为 null、父节点是场景根,同时卡片必须仍在 world 内;`resourceBookController.test.ts` 新增「屏幕坐标系的宿主不被 world 缩放除」(worldScale=2 时 `translate(40px, 30px)` 而不是 `(20px, 15px)`)。变异验证三处:把标题栏搬回 world → `expected
to be null`;恢复抵消 transform → `expected 'translate(-160px, 158px) scale(1)' to be ''`;把 per-node 缩放退回 `world.scale` → `expected 'translate(20px, 15px) …' to be 'translate(40px, 30px) …'`,三处都如期变红。定向:`resourceBook*` 76 passed、appSurface 413 passed、`npm run typecheck` exit 0、`npm run check:encoding` 4409 files passed、`git diff --check` 干净。**像素级效果本机没有复现出差异**,因此这次修复是"把清晰度从浏览器光栅决策里拿回来"的结构修复,真机判据见下一条。 - **真机判据**:那一行的三段文字(栏目名 / 计数 / `资源总览`)与小标题等锐度;Ctrl+滚轮把子画布缩放到 110% / 90% 时,钉住的那一行不再随缩放变化锐度,也不随缩小而变糊。 - **关联**:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`(`ResourceBookScene.renderTitlebar`)、`apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts`(`nodeWorldTransform`)、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`、`apps/ai-game-creator-shell/tests/resourceBookController.test.ts`、`docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`。 + +## 2026-09-12 进栏目只有几张卡片有位移:总览每摞只铺 3 张,其余卡片没有 First 帧 + +- **现象**:资源总览点进任一栏目(或展开「所有资源」)时,只有总览里已经画出来的那几张卡有"从那一摞飞出来"的位移动画,其余卡片是原地出现;用户原话「当前只有卡片里的那几张图片会有位移动画,而其他的图片是直接出现的,希望是全都从那堆卡片里出现」。 +- **原因(已用代码核实,不是推断)**:总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT`(3)张卡(`resourceBookLayout.ts:45` 的 `overviewStackRotations.length`;`selectResourceBookOverviewCards`,`resourceBookLayout.ts:217`),其余数量只写在栏目卡标题栏的"N 项"里;子画布/展开态则铺满该栏目全部卡片(`resourceBookLayout.ts:452`、`:507` 的 `childLayout` 分支)。转场层按 DOM 收集节点、只给"提交前可见"的节点记 First(`resourceBookController.ts:633` 的 `begin`),所以超出 3 张的卡片在总览侧**根本没有节点**、拿不到 First;`play` 把它们当"新挂载节点"补一次原地淡入(`:553` 的 `mountFade`,`startOpacity = 0`,`transform` 首末都是 `none`)⇒ 观感就是"直接出现"。**先排查了基线顶端那笔"钉住的栏目标题栏移出画布缩放层"(4c180ac29)**:`git diff 46458a825 4c180ac29 -- index.tsx` 只动了 `renderTitlebar`(标题栏改挂场景根)与 `nodeWorldTransform`,卡片宿主仍留在 `.game-resource-book-scene-world` 内、节点身份与 `begin` 的捕获口径逐字未变 ⇒ 它不是本次现象的原因(它只影响标题栏自己的坐标系换算)。 +- **处理**:把"那一摞"变成一类 First 来源,不新增第二套动画系统、也不为动画多渲染节点——渲染层把摞身份写到卡片宿主上(`data-resource-book-stack-column` / `data-resource-book-stack-index`,`index.tsx:1302` 一带);`begin` 顺带按 `(栏目, 列号)` 记下**同一摞最深那张卡**的 First 矩形当"堆锚点"(`resourceBookController.ts:649`);`play` 时没有 First 帧的卡片认领自己那一摞的锚点当合成 First,`startOpacity` 取目标不透明度(不做淡入),终点仍是 identity ⇒ 仍是同一套 FLIP。锚点只在**本次转场**有效(`maybeFinish` / `settle` / `invalidate` 清掉),转场中重基(已在跑动画)不认领锚点(起点必须是"此刻像素")。`buildResourceBookScenePlan` 的 `allOpen` 分支补上真实类型摞列号(展开态此前一律 0,第 2 摞之后的卡片会全从第 1 摞飞出来)。 +- **验证**:`resourceBookController.test.ts` 新增 3 条——「进栏目时每一张卡都从**自己那一摞**位移出来」(7 张卡全部有位移、第 0/1 摞分别认领各自锚点、总览已渲染的仍用自己 First、合成起点的 opacity 首末为 1)、「转场中重基从此刻像素而不是被拉回摞上」、「转场收尾后挂载的卡片不从过期锚点飞出」;`resourceBookLayout.test.ts` 新增「展开态每张卡带自己那一摞的列号」(同栏目两摞 ⇒ `[0,0,1]`);appSurface 在既有总览用例里钉住宿主属性(总览 3 张 = 下标 `0,1,2`,进栏目后 5 张 = 下标 `0..4`、同类型列号 `0`)。变异验证 3 处,均如期变红:去掉合成 First(`readStackAnchor` 返回 null)⇒「art-4: expected 'none' not to be 'none'」+ 重基用例同时红;不在 `maybeFinish` 清锚点 ⇒「expected [ Array(1) ] to have a length of +0 but got 1」;`allOpen` 的列号写回 0 ⇒「expected [0,0,0] to deeply equal [0,0,1]」。定向:`resourceBookController.test.ts` 27 passed、`resourceBookLayout.test.ts` 21 passed、`appSurface.test.ts` 415 passed、`npm run typecheck` exit 0、`npm --prefix apps/ai-game-creator-shell run typecheck` exit 0、`npm run check:encoding` 通过、`git diff --check` 干净。 +- **真机判据**:点进任一栏目(或展开「所有资源」)时,**每一张**卡片都从它那一摞的位置/尺寸位移并缩放到自己的位置,而不只是总览里那 3 张;同一栏目里不同类型的两摞各自飞各自的卡。`prefers-reduced-motion: reduce` 下仍不播放动画(口径未改)。 +- **已知未覆盖**:① 子画布内直接切到另一个栏目(分页画布换栏目)时目标栏目的卡片在该次 `begin` 时未渲染、拿不到任何 First,整段转场仍按旧口径跳过(`play` 的 `!captured.entries.size` 早退),本次未改;② 真机动画观感由浏览器渲染,jsdom 只覆盖几何与调用契约,需要按上面那条判据人工确认一次。 +- **关联**:`apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts`(`begin` 的堆锚点 / `syncNodes` 的合成 First)、`resourceBookLayout.ts`(`allOpen` 列号)、`index.tsx`(宿主 `data-resource-book-stack-*`)、`apps/ai-game-creator-shell/tests/{resourceBookController,resourceBookLayout}.test.ts`、`tests/appSurface/project-development.suite.ts`、`docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`。 diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md index be229580e..2ca26305a 100644 --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md @@ -7,7 +7,8 @@ - 钉在视口上的那条栏目标题栏是**例外**:它是 `.game-resource-book-scene-world` 的**兄弟节点**(挂在场景根下),只在子画布态渲染;总览态的栏目标题栏仍在 world 里。原因是文字清晰度——world 带 `scale()`,挂进去就必须给标题栏自己加 `scale(1 / s)` 抵消才能回到 1:1,而反向缩放只保证几何,整条标题栏的文字(栏目名、计数徽标、`资源总览` 入口)仍要在被缩放的祖先里栅格化/合成,一旦那一层被提升或复用旧纹理就会整行发虚。钉住的那一层本来就活在屏幕坐标系里。转场由控制器按 key 记录 First 屏幕矩形、在新宿主上从该矩形回到静止位驱动,仍然是真实元素的 FLIP,只是标题栏宿主不再跨视图保持同一个节点(卡片仍然保持)。 - 反向 transform 写在宿主节点自身坐标系:`transform-origin: 0 0`,缩放比取 `First.size / Last.size`,位移按宿主原点偏移换算后除以**宿主自己的坐标系缩放**(world 里的卡片取 `.game-resource-book-scene-world` 的缩放,world 外的钉住标题栏取 1,见 `resourceBookController.nodeWorldTransform`)。 - `begin` 之后的所有布局提交都调 `sync`:比较每个节点的布局签名,变化时取消旧动画、重新测量,以**当前视觉矩形**(含运行中的 transform)为起点重基到新的干净终点。转场进行中用剩余时长(`startTime + 420 - now`,下限 80ms),空闲布局变化用完整 420ms。卡片预览尺寸是异步到达的,因此"动画期间甚至动画结束之后"的二次布局变化同样是动画而不是瞬移。窗口/容器尺寸变化与拖拽中的卡片不做布局动画。 -- 目标不可见的节点(内联 `opacity` 为 0)钉在 First 位置淡出,没有 First 的挂载节点淡入;搜索/排序导致的卸载保留一帧后用同一套淡出撤下,进入栏目期间其他栏目保留挂载淡出,返回总览时源栏目未回到摞上的卡片按子画布布局淡出。转场时长统一 420ms;动画创建失败、零尺寸、容器尺寸变化或 `prefers-reduced-motion` 时不创建动画,也不等待定时器。 +- 目标不可见的节点(内联 `opacity` 为 0)钉在 First 位置淡出,没有 First 的挂载节点淡入(卡片例外:**有那一摞的堆锚点就从摞里飞出来**,见下一条);搜索/排序导致的卸载保留一帧后用同一套淡出撤下,进入栏目期间其他栏目保留挂载淡出,返回总览时源栏目未回到摞上的卡片按子画布布局淡出。转场时长统一 420ms;动画创建失败、零尺寸、容器尺寸变化或 `prefers-reduced-motion` 时不创建动画,也不等待定时器。 +- 总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT`(3)张卡,其余数量写在栏目卡标题栏的"N 项"里,所以进栏目/进展开态时**大部分卡片在总览侧根本没有节点**、拿不到 First 帧。这类卡片不能原地淡入(用户看到的就是"只有那几张有位移,其余直接出现"):`begin` 顺带按 (栏目, 类型摞列号) 记下**同一摞最深那张卡**(宿主 `data-resource-book-stack-index` 最大者)的 First 矩形当堆锚点,`play` 时没有 First 帧的卡片认领它自己的锚点当合成 First,走的仍是同一套 FLIP、也没有为动画多渲染任何节点。锚点只在**本次转场**有效(`maybeFinish` / `settle` / `invalidate` 都清掉),否则转场之后才挂载的卡片会从一段过期位置飞出来;转场中重基(已在跑动画的节点)不认领锚点,起点必须是"此刻像素"。展开态的每张卡仍带自己那一摞的真实列号(`buildResourceBookScenePlan` 的 `allOpen` 分支),不是一律 0。 - `begin` 只记录**当时可见**的节点。不可见节点的屏幕矩形是"看不见的位置":非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,若把它当 First,返回总览时标题栏会从屏幕外飞进来。不可见节点没有 First,只走淡入,位置保持不动;进入栏目方向它们仍然可见,因此照旧钉在原地淡出。同理,卡片只有在提交前可见时才参与几何 FLIP。 - 再次导航先读取当前像素位置,再取消旧动画并重新建立目标;token 隔离旧 promise,过期完成和取消均不能提交新状态。 - 缩放、适应内容和空白拖动先结束视觉转场再执行用户输入;转场中的资源卡 pointer-down 不启动持久化拖拽。普通滚轮只平移当前视图,Ctrl/Meta 滚轮以指针为锚点缩放,均不切换栏目。