From 4fb0de2c942405d5a65f96552015a34df476e1c5 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 9 Sep 2026 20:24:10 +0800 Subject: [PATCH] =?UTF-8?q?C3=20=E8=B5=84=E6=BA=90=E7=94=BB=E6=9C=AC?= =?UTF-8?q?=E8=BD=AC=E5=9C=BA=E6=94=B9=E4=B8=BA=E7=9C=9F=E5=AE=9E=E5=85=83?= =?UTF-8?q?=E7=B4=A0=20FLIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 经验条目 --- apps/ai-game-creator-shell/src/styles.css | 17 +- .../ResourceBookTransitionLayer.tsx | 9 - .../src/view/project-development/index.tsx | 359 +++---- .../resourceBookController.ts | 910 +++++++++++------- .../project-development/resourceBookLayout.ts | 184 +++- .../project-development/resourceBookModel.ts | 29 + .../appSurface/project-development.suite.ts | 19 + .../tests/resourceBookController.test.ts | 818 +++++++++------- .../tests/resourceBookLayout.test.ts | 151 +++ .../tests/resourceBookModel.test.ts | 57 ++ docs/project-memory/shared-memory/pitfalls.md | 6 +- ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 16 +- 12 files changed, 1649 insertions(+), 926 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index f6c61717a..9d7d81557 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5626,19 +5626,6 @@ iframe.preview-frame { transition: none; } -.game-resource-book-transition-layer { - position: absolute; - inset: 0; - z-index: 35; - overflow: hidden; - pointer-events: none; -} - -.game-resource-book-manager[data-book-motion='running'] - .game-resource-book-scene-world { - opacity: 0; -} - .game-resource-book-scene--child { pointer-events: auto; } @@ -5728,6 +5715,8 @@ iframe.preview-frame { box-shadow: 0 8px 18px rgb(112 70 52 / 8%); pointer-events: none; transform: translateY(0); + /* FLIP 反向 transform 围绕节点自身原点缩放。 */ + transform-origin: 0 0; transition: none; } @@ -5746,6 +5735,8 @@ iframe.preview-frame { position: absolute; z-index: 25; pointer-events: none; + /* 卡片包装节点是 0x0,FLIP 反向 transform 围绕它的原点缩放。 */ + transform-origin: 0 0; translate: 0 0; transition: none; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx deleted file mode 100644 index f5ebb6efc..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceBookTransitionLayer.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export function ResourceBookTransitionLayer() { - return ( - `; - document.body.append(root); - vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 800, 600)); - const card = root.querySelector('.game-resource-card')!; - const measure = vi - .spyOn(card, 'getBoundingClientRect') - .mockReturnValue(rect()); - const flights: { - finish(): void; - reject(): void; - cancel: ReturnType; - setKeyframes: ReturnType; - frames: Keyframe[]; - }[] = []; - vi.spyOn(Element.prototype, 'animate').mockImplementation((frames) => { - let finish!: () => void; - let reject!: () => void; - const finished = new Promise((resolve, fail) => { - finish = () => resolve({} as Animation); - reject = () => fail(new Error('canceled')); - }); - const cancel = vi.fn(reject); - const setKeyframes = vi.fn(); - flights.push({ - finish, - reject, - cancel, - setKeyframes, - frames: frames as Keyframe[], - }); - return { finished, cancel, effect: { setKeyframes } } as unknown as Animation; - }); - return { root, card, measure, flights }; -} +type Flight = { + host: Element; + frames: Keyframe[]; + options: KeyframeAnimationOptions; + cancel: Mock; + finish: () => void; + reject: () => void; +}; const originalAnimate = Object.getOwnPropertyDescriptor( Element.prototype, 'animate', ); + +function installAnimateMock() { + const flights: Flight[] = []; + Object.defineProperty(Element.prototype, 'animate', { + configurable: true, + writable: true, + value( + this: Element, + frames: Keyframe[], + options: KeyframeAnimationOptions, + ) { + let finish!: () => void; + let reject!: () => void; + const finished = new Promise((resolve, fail) => { + finish = () => resolve({} as Animation); + reject = () => fail(new Error('canceled')); + }); + const cancel = vi.fn(reject); + flights.push({ host: this, frames, options, cancel, finish, reject }); + return { + finished, + cancel, + effect: { getComputedTiming: () => ({}) }, + } as unknown as Animation; + }, + }); + return flights; +} + +function fixture(worldScale = 1) { + const manager = document.createElement('div'); + manager.className = 'game-resource-book-manager'; + manager.innerHTML = `
+
+
+
+
+
+
+
`; + document.body.append(manager); + const world = manager.querySelector( + '.game-resource-book-scene-world', + )!; + const cardHost = manager.querySelector( + '.game-resource-book-scene-card', + )!; + const cardBox = manager.querySelector('.game-resource-card')!; + const titleHost = manager.querySelector( + '.game-resource-book-scene-titlebar', + )!; + const rects = new Map(); + const setRect = (element: Element, rect: MotionRect) => { + rects.set(element, rect); + }; + for (const element of [manager, world, cardHost, cardBox, titleHost]) { + vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() => + toDomRect(rects.get(element) ?? { left: 0, top: 0, width: 0, height: 0 }), + ); + } + setRect(manager, { left: 0, top: 0, width: 800, height: 600 }); + setRect(world, { left: 0, top: 0, width: 800, height: 600 }); + setRect(titleHost, { left: 0, top: 0, width: 280, height: 42 }); + const realGetComputedStyle = window.getComputedStyle.bind(window); + vi.spyOn(window, 'getComputedStyle').mockImplementation((( + element: Element, + pseudo?: string | null, + ) => { + const style = realGetComputedStyle(element, pseudo); + if (element !== world) return style; + return new Proxy(style, { + get: (target, property) => + property === 'transform' + ? `matrix(${worldScale}, 0, 0, ${worldScale}, 0, 0)` + : Reflect.get(target, property), + }); + }) as typeof window.getComputedStyle); + const signatures = new Map([ + ['card:one', 'child:10,20:180,128'], + ['title:document', 'idle:0,0,280'], + ]); + return { + manager, + world, + cardHost, + cardBox, + titleHost, + setRect, + signatures, + }; +} + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -72,317 +144,375 @@ afterEach(() => { document.body.replaceChildren(); }); -function setup() { - Object.defineProperty(Element.prototype, 'animate', { - configurable: true, - writable: true, - value: () => ({}), - }); - return fixture(); -} - -describe('resource book FLIP controller', () => { - it('uses screen geometry independently of either viewport', () => { +describe('resource book FLIP geometry', () => { + it('uses the classic FLIP transform when the origin is the last box', () => { expect( - resourceBookFlipTransform(rect(30, 40, 50, 20), rect(10, 10, 100, 80)), + resourceBookFlipTransform( + { left: 30, top: 40, width: 50, height: 20 }, + { left: 10, top: 10, width: 100, height: 80 }, + ), ).toBe('translate(20px, 30px) scale(0.5, 0.25)'); }); - it('finishes from animation completion, removes clones and preserves real controls', async () => { - const { root, measure, flights } = setup(); - const controller = createResourceBookTransitionController(); - const token = controller.begin(root); - measure.mockReturnValue(rect(150, 100, 200, 160)); - const done = vi.fn(); - controller.play(root, token, done); - expect(root.dataset.bookMotion).toBe('running'); - expect(root.querySelectorAll('#original')).toHaveLength(1); - expect(flights[0].frames[0].transform).toBe( - 'translate(-150px, -100px) scale(0.5, 0.5)', - ); - expect(done).not.toHaveBeenCalled(); - flights[0].finish(); - await vi.waitFor(() => expect(done).toHaveBeenCalledTimes(1)); - expect(root.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0); - expect(root.hasAttribute('data-book-motion')).toBe(false); - controller.dispose(); - }); - - it('freezes blob-backed card previews into the snapshot', () => { - const { root, measure } = setup(); - const image = root.querySelector( - '.game-resource-card img', - )!; - Object.defineProperty(image, 'complete', { - configurable: true, - value: true, - }); - Object.defineProperty(image, 'naturalWidth', { - configurable: true, - value: 8, - }); - Object.defineProperty(image, 'naturalHeight', { - configurable: true, - value: 6, - }); - vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ - drawImage: vi.fn(), - } as unknown as CanvasRenderingContext2D); - vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue( - 'data:image/png;base64,FROZEN', - ); - - const controller = createResourceBookTransitionController(); - const token = controller.begin(root); - measure.mockReturnValue(rect(150, 100, 200, 160)); - controller.play(root, token, () => undefined); - - const snapshotImage = root.querySelector( - '.game-resource-book-transition-layer img', - ); - expect(snapshotImage?.getAttribute('src')).toBe( - 'data:image/png;base64,FROZEN', - ); - controller.dispose(); - }); - - it('keeps the static stacking order inside the transition layer', () => { - Object.defineProperty(Element.prototype, 'animate', { - configurable: true, - writable: true, - value: () => ({ - finished: Promise.resolve({} as Animation), - cancel: () => undefined, - }), - }); - const root = document.createElement('div'); - root.innerHTML = `
-
标题
-
-
`; - document.body.append(root); - vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 800, 600)); - for (const element of root.querySelectorAll( - '.game-resource-book-scene-titlebar, .game-resource-card', - )) { - vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect()); - } - - const controller = createResourceBookTransitionController(); - const token = controller.begin(root); - controller.play(root, token, () => undefined); - - const wrappers = Array.from( - root.querySelectorAll('[data-motion-snapshot]'), - ); - const titleWrapper = wrappers.find((element) => - element.dataset.motionSnapshot?.startsWith('title:'), - ); - const cardWrapper = wrappers.find((element) => - element.dataset.motionSnapshot?.startsWith('card:'), - ); - // 克隆按目标场景的最终绘制顺序分配单调层级:标题栏必须在卡片之上。 - expect(Number(titleWrapper?.style.zIndex)).toBeGreaterThan( - Number(cardWrapper?.style.zIndex), - ); - controller.dispose(); - }); - - it('appends clones in the target DOM order so equal z-index cards keep their occlusion', () => { - Object.defineProperty(Element.prototype, 'animate', { - configurable: true, - writable: true, - value: () => ({ - finished: Promise.resolve({} as Animation), - cancel: () => undefined, - }), - }); - const root = document.createElement('div'); - root.innerHTML = `
-
-
-
`; - document.body.append(root); - vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 800, 600)); - for (const element of root.querySelectorAll( - '.game-resource-card', - )) { - vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect()); - } - - const controller = createResourceBookTransitionController(); - const token = controller.begin(root); - const cardWrapper = root - .querySelector('[data-resource-card-id="a"]')! - .closest('.game-resource-book-scene-card')!; - cardWrapper.parentElement!.append(cardWrapper); - controller.play(root, token, () => undefined); - - const wrappers = Array.from( - root.querySelectorAll('[data-motion-snapshot]'), - ); - expect(wrappers.map((element) => element.dataset.motionSnapshot)).toEqual([ - 'card:b', - 'card:a', - ]); - // 同层级时靠 DOM 顺序决定遮挡:目标顺序里 b 在前,b 的层级必须更低。 - expect(Number(wrappers[0]?.style.zIndex)).toBeLessThan( - Number(wrappers[1]?.style.zIndex), - ); - controller.dispose(); - }); - - it('keeps the source stack order while flying out of the overview', () => { - Object.defineProperty(Element.prototype, 'animate', { - configurable: true, - writable: true, - value: () => ({ - finished: Promise.resolve({} as Animation), - cancel: () => undefined, - }), - }); - const root = document.createElement('div'); - root.innerHTML = `
-
-
-
`; - document.body.append(root); - vi.spyOn(root, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 800, 600)); - for (const element of root.querySelectorAll( - '.game-resource-card', - )) { - vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect()); - } - - const controller = createResourceBookTransitionController(); - const token = controller.begin(root); - const cardWrapper = root - .querySelector('[data-resource-card-id="a"]')! - .closest('.game-resource-book-scene-card')!; - cardWrapper.parentElement!.append(cardWrapper); - controller.play(root, token, () => undefined, 'source'); - - const wrappers = Array.from( - root.querySelectorAll('[data-motion-snapshot]'), - ); - expect(wrappers.map((element) => element.dataset.motionSnapshot)).toEqual([ - 'card:a', - 'card:b', - ]); - controller.dispose(); - }); - - it('retargets from the current overlay geometry and ignores old completion', async () => { - const { root, measure, flights } = setup(); - const controller = createResourceBookTransitionController(); - const firstDone = vi.fn(); - let token = controller.begin(root); - measure.mockReturnValue(rect(200, 100, 200, 160)); - controller.play(root, token, firstDone); - const moving = root.querySelector('[data-motion-snapshot]')!; - vi.spyOn(moving, 'getBoundingClientRect').mockReturnValue( - rect(90, 40, 150, 120), - ); - token = controller.begin(root); - measure.mockReturnValue(rect(300, 200, 100, 80)); - const secondDone = vi.fn(); - controller.play(root, token, secondDone); - expect(flights[1].frames[0].transform).toBe( - 'translate(-210px, -160px) scale(1.5, 1.5)', - ); - await Promise.resolve(); - expect(firstDone).not.toHaveBeenCalled(); - expect(secondDone).not.toHaveBeenCalled(); - flights[1].finish(); - await vi.waitFor(() => expect(secondDone).toHaveBeenCalledTimes(1)); - controller.dispose(); - }); - - it('retargets a running animation to new geometry without restarting it', () => { - const { root, measure, flights } = setup(); - const controller = createResourceBookTransitionController(); - const token = controller.begin(root); - measure.mockReturnValue(rect(150, 100, 200, 160)); - controller.play(root, token, () => undefined); - expect(flights).toHaveLength(1); - - measure.mockReturnValue(rect(300, 200, 100, 80)); - controller.retarget(root); - - expect(flights).toHaveLength(1); - expect(flights[0]?.setKeyframes).toHaveBeenCalledTimes(1); - const frames = flights[0]?.setKeyframes.mock.calls[0]?.[0] as Keyframe[]; - expect(frames[1]?.transform).toBe( + it('accounts for a host origin that is not the last box', () => { + // 卡片包装节点是 0x0,缩放围绕它的原点,位置要按原点偏移换算。 + expect( resourceBookFlipTransform( - rect(300, 200, 100, 80), - rect(150, 100, 200, 160), + { left: 30, top: 40, width: 50, height: 20 }, + { left: 10, top: 10, width: 100, height: 80 }, + { left: 0, top: 0 }, + ), + ).toBe('translate(25px, 37.5px) scale(0.5, 0.25)'); + }); + + it('divides screen translation by the world scale', () => { + expect( + resourceBookFlipTransform( + { left: 300, top: 250, width: 90, height: 64 }, + { left: 200, top: 200, width: 180, height: 128 }, + { left: 100, top: 100 }, + 2, + ), + ).toBe('translate(75px, 50px) scale(0.5, 0.5)'); + }); + + it('rejects degenerate rectangles', () => { + expect( + resourceBookValidMotionRect({ left: 0, top: 0, width: 0, height: 10 }), + ).toBe(false); + expect( + resourceBookValidMotionRect({ left: 0, top: 0, width: 10, height: 10 }), + ).toBe(true); + }); +}); + +describe('resource book real element FLIP', () => { + it('animates the same card node instead of cloning it', async () => { + const flights = installAnimateMock(); + const { manager, cardHost, cardBox, setRect, signatures } = fixture(); + setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 }); + setRect(cardHost, { left: 100, top: 100, width: 0, height: 0 }); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + + setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 }); + setRect(cardHost, { left: 200, top: 150, width: 0, height: 0 }); + const done = vi.fn(); + controller.play(manager, token, done, signatures); + + const cardFlights = flights.filter((flight) => flight.host === cardHost); + expect(cardFlights).toHaveLength(1); + expect(cardFlights[0]!.frames[0]!.transform).toBe( + 'translate(-100px, -50px) scale(0.511, 0.5)', + ); + expect(cardFlights[0]!.frames[1]!.transform).toBe('none'); + expect(cardFlights[0]!.options.duration).toBe( + RESOURCE_BOOK_MOTION_DURATION, + ); + // 真实元素:没有克隆节点,也没有快照层。 + expect(manager.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0); + expect(manager.querySelectorAll('.game-resource-card')).toHaveLength(1); + expect(controller.isAnimating()).toBe(true); + + expect(done).not.toHaveBeenCalled(); + cardFlights[0]!.finish(); + await vi.waitFor(() => expect(done).toHaveBeenCalledTimes(1)); + expect(controller.isAnimating()).toBe(false); + controller.dispose(); + }); + + it('composes the FLIP transform with the titlebar base transform', () => { + const flights = installAnimateMock(); + const { manager, titleHost, cardBox, setRect, signatures } = fixture(); + const base = 'translate(-10px, -20px) scale(0.5)'; + vi.mocked(titleHost.getBoundingClientRect).mockImplementation(() => + titleHost.style.transform + ? toDomRect({ left: -10, top: -20, width: 140, height: 21 }) + : toDomRect({ left: 0, top: 0, width: 280, height: 42 }), + ); + setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 }); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + + titleHost.style.transform = base; + controller.play(manager, token, () => undefined, signatures); + + const flight = flights.find((entry) => entry.host === titleHost)!; + expect(flight.frames[0]!.transform).toBe( + `translate(20px, 40px) scale(2, 2) ${base}`, + ); + expect(flight.frames[1]!.transform).toBe(base); + controller.dispose(); + }); + + it('pins a leaving node in place and fades it out', () => { + const flights = installAnimateMock(); + const { manager, cardHost, cardBox, setRect, signatures } = fixture(); + setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 }); + setRect(cardHost, { left: 100, top: 100, width: 0, height: 0 }); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + + // 提交后该栏目不再可见(内联 opacity 0),world 也变了导致屏幕位置改变。 + cardHost.style.opacity = '0'; + setRect(cardBox, { left: 700, top: 500, width: 184, height: 128 }); + setRect(cardHost, { left: 700, top: 500, width: 0, height: 0 }); + controller.play(manager, token, () => undefined, signatures); + + const flight = flights.find((entry) => entry.host === cardHost)!; + expect(flight.frames[0]!.transform).toBe(flight.frames[1]!.transform); + expect(flight.frames[0]!.opacity).toBe(1); + expect(flight.frames[1]!.opacity).toBe(0); + controller.dispose(); + }); + + it('fades in a card that mounts during the transition', () => { + const flights = installAnimateMock(); + const { manager, cardHost, cardBox, setRect, signatures } = fixture(); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + + setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 }); + controller.play(manager, token, () => undefined, signatures); + + const flight = flights.find((entry) => entry.host === cardHost)!; + expect(flight.frames[0]!.opacity).toBe(0); + expect(flight.frames[1]!.opacity).toBe(1); + expect(flight.frames[0]!.transform).toBe('none'); + expect(flight.frames[1]!.transform).toBe('none'); + controller.dispose(); + }); + + it('settles immediately without Web Animations support', () => { + const { manager, signatures } = fixture(); + const controller = createResourceBookTransitionController(); + const done = vi.fn(); + controller.play(manager, controller.begin(manager), done, signatures); + expect(done).toHaveBeenCalledTimes(1); + expect(controller.isAnimating()).toBe(false); + controller.dispose(); + }); + + it('settles immediately for reduced motion', () => { + installAnimateMock(); + vi.stubGlobal('matchMedia', () => ({ matches: true })); + const { manager, signatures } = fixture(); + const controller = createResourceBookTransitionController(); + const done = vi.fn(); + controller.play(manager, controller.begin(manager), done, signatures); + expect(done).toHaveBeenCalledTimes(1); + controller.dispose(); + }); + + it('cancels running animations on settle and reports completion once', () => { + const flights = installAnimateMock(); + const { manager, cardBox, setRect, signatures } = fixture(); + setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 }); + const controller = createResourceBookTransitionController(); + const done = vi.fn(); + const token = controller.begin(manager); + setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 }); + controller.play(manager, token, done, signatures); + expect(controller.isAnimating()).toBe(true); + + controller.settle(); + + expect(done).toHaveBeenCalledTimes(1); + expect(controller.isAnimating()).toBe(false); + expect( + flights.every((flight) => flight.cancel.mock.calls.length >= 1), + ).toBe(true); + controller.dispose(); + }); +}); + +describe('resource book layout rebase', () => { + it('rebases a running transition from the current pixels', () => { + const flights = installAnimateMock(); + const now = vi.spyOn(performance, 'now'); + now.mockReturnValue(1_000); + const { manager, cardHost, cardBox, setRect, signatures } = fixture(); + setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 }); + setRect(cardHost, { left: 100, top: 100, width: 0, height: 0 }); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 }); + setRect(cardHost, { left: 200, top: 150, width: 0, height: 0 }); + controller.play(manager, token, () => undefined, signatures); + expect(flights).toHaveLength(1); + + // 预览图尺寸晚到:动画跑到一半时布局再变一次。取消旧动画后几何才变成新布局。 + now.mockReturnValue(1_300); + const visual: MotionRect = { left: 150, top: 130, width: 140, height: 100 }; + const clean: MotionRect = { left: 210, top: 170, width: 220, height: 160 }; + setRect(cardBox, visual); + flights[0]!.cancel.mockImplementation(() => { + setRect(cardBox, clean); + flights[0]!.reject(); + }); + signatures.set('card:one', 'child:10,20:220,160'); + controller.sync(manager, signatures); + + expect(flights).toHaveLength(2); + const rebase = flights[1]!; + expect(rebase.frames[0]!.transform).toBe( + resourceBookFlipTransform(visual, clean, { left: 200, top: 150 }), + ); + expect(rebase.frames[1]!.transform).toBe('none'); + expect(rebase.options.duration).toBe(120); + controller.dispose(); + }); + + it('clamps the rebased duration to the minimum', () => { + const flights = installAnimateMock(); + const now = vi.spyOn(performance, 'now'); + now.mockReturnValue(1_000); + const { manager, cardBox, setRect, signatures } = fixture(); + setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 }); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 }); + controller.play(manager, token, () => undefined, signatures); + + now.mockReturnValue(1_000 + RESOURCE_BOOK_MOTION_DURATION + 50); + setRect(cardBox, { left: 150, top: 130, width: 140, height: 100 }); + flights[0]!.cancel.mockImplementation(() => { + setRect(cardBox, { left: 210, top: 170, width: 220, height: 160 }); + flights[0]!.reject(); + }); + signatures.set('card:one', 'child:10,20:220,160'); + controller.sync(manager, signatures); + + expect(flights[1]!.options.duration).toBe( + RESOURCE_BOOK_MOTION_MIN_DURATION, + ); + controller.dispose(); + }); + + it('animates an idle layout change with the full duration', () => { + const flights = installAnimateMock(); + const { manager, cardHost, cardBox, setRect, signatures } = fixture(); + setRect(cardHost, { left: 0, top: 0, width: 0, height: 0 }); + const previous: MotionRect = { + left: 200, + top: 150, + width: 180, + height: 128, + }; + setRect(cardBox, previous); + const controller = createResourceBookTransitionController(); + controller.sync(manager, signatures); + expect(flights).toHaveLength(0); + + // 预览图尺寸在动画结束之后才到:位置/尺寸变化必须是动画,而不是瞬移。 + const next: MotionRect = { left: 260, top: 150, width: 220, height: 160 }; + setRect(cardBox, next); + signatures.set('card:one', 'child:60,20:220,160'); + controller.sync(manager, signatures); + + expect(flights).toHaveLength(1); + expect(flights[0]!.frames[0]!.transform).toBe( + resourceBookFlipTransform(previous, next, { left: 0, top: 0 }), + ); + expect(flights[0]!.frames[1]!.transform).toBe('none'); + expect(flights[0]!.options.duration).toBe(RESOURCE_BOOK_MOTION_DURATION); + controller.dispose(); + }); + + it('ignores world-only changes such as pan and zoom', () => { + const flights = installAnimateMock(); + const { manager, cardBox, setRect, signatures } = fixture(2); + setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 }); + const controller = createResourceBookTransitionController(); + controller.sync(manager, signatures); + + setRect(cardBox, { left: 260, top: 190, width: 180, height: 128 }); + controller.sync(manager, signatures); + + expect(flights).toHaveLength(0); + controller.dispose(); + }); + + it('keeps the layout baseline valid across a pan', () => { + const flights = installAnimateMock(); + const { manager, world, cardHost, cardBox, setRect, signatures } = + fixture(); + setRect(cardHost, { left: 0, top: 0, width: 0, height: 0 }); + setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 }); + const controller = createResourceBookTransitionController(); + controller.sync(manager, signatures); + + // 平移主画布:world 原点移动,卡片跟着移动,布局签名不变。 + setRect(world, { left: 40, top: 30, width: 800, height: 600 }); + setRect(cardHost, { left: 40, top: 30, width: 0, height: 0 }); + setRect(cardBox, { left: 240, top: 180, width: 180, height: 128 }); + controller.sync(manager, signatures); + expect(flights).toHaveLength(0); + + // 平移之后预览图尺寸才到:起点必须是卡片"此刻"的屏幕位置,不是平移前的。 + const next: MotionRect = { left: 300, top: 180, width: 220, height: 160 }; + setRect(cardBox, next); + signatures.set('card:one', 'child:60,20:220,160'); + controller.sync(manager, signatures); + + expect(flights).toHaveLength(1); + expect(flights[0]!.frames[0]!.transform).toBe( + resourceBookFlipTransform( + { left: 240, top: 180, width: 180, height: 128 }, + next, + { left: 40, top: 30 }, ), ); controller.dispose(); }); - it.each(['reduced', 'zero', 'unsupported'])( - 'settles without a timer for %s motion', - (reason) => { - const { root, measure, flights } = setup(); - if (reason === 'zero') measure.mockReturnValue(rect(0, 0, 0, 0)); - if (reason === 'unsupported') - delete (Element.prototype as Partial).animate; - if (reason === 'reduced') - vi.stubGlobal('matchMedia', () => ({ matches: true })); - const controller = createResourceBookTransitionController(); - const token = controller.begin(root); - const done = vi.fn(); - controller.play(root, token, done); - expect(done).toHaveBeenCalledTimes(1); - expect(flights).toHaveLength(0); - controller.dispose(); - }, - ); - - it('settles on resize and suppresses completion after disposal', async () => { - const { root, flights } = setup(); + it('freezes the dragged card', () => { + const flights = installAnimateMock(); + const { manager, cardBox, setRect, signatures } = fixture(); + setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 }); const controller = createResourceBookTransitionController(); - const done = vi.fn(); - controller.play(root, controller.begin(root), done); - window.dispatchEvent(new Event('resize')); - expect(done).toHaveBeenCalledTimes(1); - controller.play(root, controller.begin(root), done); - controller.dispose(); - flights[1].finish(); - await Promise.resolve(); - expect(done).toHaveBeenCalledTimes(1); - expect(root.hasAttribute('data-book-motion')).toBe(false); - }); + controller.sync(manager, signatures); - it('cleans partially started animations when animation creation fails', () => { - const { root } = setup(); - vi.mocked(Element.prototype.animate).mockImplementation(() => { - throw new Error('animation unavailable'); - }); - const controller = createResourceBookTransitionController(); - const done = vi.fn(); - controller.play(root, controller.begin(root), done); - expect(done).toHaveBeenCalledTimes(1); - expect(root.querySelectorAll('[data-motion-snapshot]')).toHaveLength(0); - expect(root.hasAttribute('data-book-motion')).toBe(false); + setRect(cardBox, { left: 320, top: 260, width: 180, height: 128 }); + signatures.set('card:one', 'child:120,110:180,128'); + controller.sync(manager, signatures, new Set(['card:one'])); + + expect(flights).toHaveLength(0); controller.dispose(); }); - it('cancellation never waits for a CSS timer and leaves the viewport untouched', async () => { - const { root, flights } = setup(); - const world = root.querySelector( - '.game-resource-book-scene-world', - )!; - world.style.transform = 'translate(20px, 40px) scale(1.5)'; + it('does not animate when the manager size changes', () => { + const flights = installAnimateMock(); + const { manager, cardBox, setRect, signatures } = fixture(); + setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 }); const controller = createResourceBookTransitionController(); - const done = vi.fn(); - controller.play(root, controller.begin(root), done); - controller.settle(); - expect(flights[0].cancel).toHaveBeenCalledTimes(1); - expect(done).toHaveBeenCalledTimes(1); - await Promise.resolve(); - expect(world.style.transform).toBe('translate(20px, 40px) scale(1.5)'); + controller.sync(manager, signatures); + + setRect(manager, { left: 0, top: 0, width: 640, height: 480 }); + setRect(cardBox, { left: 160, top: 120, width: 180, height: 128 }); + signatures.set('card:one', 'child:20,10:180,128'); + controller.sync(manager, signatures); + + expect(flights).toHaveLength(0); + controller.dispose(); + }); + + it('reports remaining motion for fading exit nodes', () => { + installAnimateMock(); + const now = vi.spyOn(performance, 'now'); + now.mockReturnValue(500); + const { manager, cardBox, setRect, signatures } = fixture(); + setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 }); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + setRect(cardBox, { left: 200, top: 150, width: 180, height: 128 }); + controller.play(manager, token, () => undefined, signatures); + + expect(controller.motionRemainingMs()).toBe(RESOURCE_BOOK_MOTION_DURATION); + now.mockReturnValue(800); + expect(controller.motionRemainingMs()).toBe( + RESOURCE_BOOK_MOTION_DURATION - 300, + ); controller.dispose(); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts index a10cf457d..4006d9f4d 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + buildResourceBookScenePlan, groupResourceBookStacks, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT, resourceBookOverviewCardLayout, + resourceBookSceneCardKey, selectResourceBookOverviewCards, } from '../src/view/project-development/resourceBookLayout'; import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; @@ -121,3 +123,152 @@ describe('groupResourceBookStacks', () => { expect(stacks[0]?.[1]).toHaveLength(4); }); }); + +describe('buildResourceBookScenePlan', () => { + const resources = ['art-0', 'art-1', 'art-2', 'art-3'].map(resource); + const positions = new Map( + resources.map((item, index) => [item.id, { x: index * 200, y: 0 }]), + ); + const cardSizes = new Map( + resources.map((item) => [item.id, { width: 180, height: 128 }]), + ); + const base = { + visibleCategoryOrder: ['art'] as const, + resourcesByCategory: new Map([['art', resources]]), + overviewRects: new Map([['art', rect]]), + positions, + cardSizes, + visibleResourceIds: new Set(resources.map((item) => item.id)), + categoryViewCenters: new Map([['art', { x: 0, y: 64 }]]), + cardsReady: true, + }; + + it('renders the bounded overview stack on the steady main canvas', () => { + const plan = buildResourceBookScenePlan({ + ...base, + state: { + view: 'main', + category: null, + phase: 'idle', + token: 0, + }, + }); + + expect(plan).toHaveLength(1); + expect(plan[0]!.cards).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT); + expect(plan[0]!.cards[0]!.presentation).toBe('overview'); + expect(plan[0]!.cards[0]!.stackCount).toBe( + RESOURCE_BOOK_OVERVIEW_STACK_LIMIT, + ); + expect(plan[0]!.cards[0]!.layout).toMatchObject({ + x: rect.left + 18, + y: rect.top + 58, + width: 92, + height: 64, + }); + expect(plan[0]!.titlebarActive).toBe(false); + expect(plan[0]!.titlebarOpacity).toBe(1); + expect(plan[0]!.resourceCount).toBe(4); + }); + + it('keeps other categories mounted for a fade-out while entering', () => { + const plan = buildResourceBookScenePlan({ + ...base, + visibleCategoryOrder: ['art', 'code'], + resourcesByCategory: new Map([ + ['art', resources], + ['code', [resource('code-0')]], + ]), + overviewRects: new Map([ + ['art', rect], + ['code', { ...rect, left: 500 }], + ]), + state: { view: 'child', category: 'art', phase: 'entering', token: 1 }, + }); + + const art = plan.find((group) => group.category === 'art')!; + const code = plan.find((group) => group.category === 'code')!; + expect(art.cards.every((card) => card.presentation === 'child')).toBe(true); + expect(art.cards).toHaveLength(4); + expect(art.titlebarActive).toBe(true); + // 其他栏目留在 DOM 里淡出,而不是直接卸载。 + expect(code.cards.every((card) => card.presentation === 'overview')).toBe( + true, + ); + expect(code.titlebarActive).toBe(false); + expect(code.titlebarOpacity).toBe(0); + }); + + it('marks cards that do not return to the stack as exiting', () => { + const plan = buildResourceBookScenePlan({ + ...base, + state: { + view: 'main', + category: null, + phase: 'returning-main', + token: 2, + }, + exitingCategory: 'art', + }); + + const cards = plan[0]!.cards; + expect( + cards.filter((card) => card.presentation === 'overview'), + ).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT); + const exiting = cards.filter((card) => card.presentation === 'exiting'); + expect(exiting).toHaveLength(1); + // 淡出节点按子画布布局渲染,位置由 FLIP 钉在 First。 + expect(exiting[0]!.layout.x).toBe(positions.get('art-3')!.x); + }); + + it('keeps search-driven removals mounted without duplicating keys', () => { + const plan = buildResourceBookScenePlan({ + ...base, + state: { view: 'main', category: null, phase: 'idle', token: 0 }, + exitingCards: [ + { + key: resourceBookSceneCardKey('art-3'), + category: 'art', + resource: resources[3]!, + presentation: 'exiting', + stackIndex: 0, + stackColumn: 0, + stackCount: 1, + layout: { + x: 0, + y: 0, + width: 180, + height: 128, + dragX: 0, + dragY: 0, + rotation: 0, + }, + }, + { + key: resourceBookSceneCardKey('art-0'), + category: 'art', + resource: resources[0]!, + presentation: 'exiting', + stackIndex: 0, + stackColumn: 0, + stackCount: 1, + layout: { + x: 0, + y: 0, + width: 180, + height: 128, + dragX: 0, + dragY: 0, + rotation: 0, + }, + }, + ], + }); + + const keys = plan[0]!.cards.map((card) => card.key); + expect(keys).toContain(resourceBookSceneCardKey('art-3')); + expect( + keys.filter((key) => key === resourceBookSceneCardKey('art-0')), + ).toHaveLength(1); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts index 46ff789db..4069828c9 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookModel.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'; import { initialResourceBookState, + resourceBookCategoryCardPresentation, resourceBookReducer, resourceBookSceneCategory, + resourceBookShowsExitingCards, resourceBookShowsOverviewCards, } from '../src/view/project-development/resourceBookModel'; @@ -103,3 +105,58 @@ describe('resource book state machine', () => { ).toEqual(switched); }); }); + +describe('resource book card presentation', () => { + it('uses the overview stack on the steady main canvas', () => { + expect( + resourceBookCategoryCardPresentation(initialResourceBookState, 'art'), + ).toBe('overview'); + }); + + it('keeps other categories mounted while entering so they can fade out', () => { + const entering = resourceBookReducer(initialResourceBookState, { + type: 'open-category', + category: 'art', + token: 1, + }); + + expect(resourceBookCategoryCardPresentation(entering, 'art')).toBe('child'); + expect(resourceBookCategoryCardPresentation(entering, 'code')).toBe( + 'overview', + ); + }); + + it('drops other categories once the child canvas is steady', () => { + const steady = resourceBookReducer( + resourceBookReducer(initialResourceBookState, { + type: 'open-category', + category: 'art', + token: 1, + }), + { type: 'finish-transition', token: 1 }, + ); + + expect(resourceBookCategoryCardPresentation(steady, 'art')).toBe('child'); + expect(resourceBookCategoryCardPresentation(steady, 'code')).toBeNull(); + }); + + it('returns every category to the stack and exposes the exiting window', () => { + const returning = resourceBookReducer( + resourceBookReducer(initialResourceBookState, { + type: 'open-category', + category: 'art', + token: 1, + }), + { type: 'return-to-main', token: 2 }, + ); + + expect(resourceBookCategoryCardPresentation(returning, 'art')).toBe( + 'overview', + ); + expect(resourceBookCategoryCardPresentation(returning, 'code')).toBe( + 'overview', + ); + expect(resourceBookShowsExitingCards(returning)).toBe(true); + expect(resourceBookShowsExitingCards(initialResourceBookState)).toBe(false); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index aca7461f5..46a4d62d3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -38,8 +38,10 @@ - 无限画布的指针交互必须绑定未变换、铺满可视区域的视口,平移和缩放只作用于内部 world;若连交互容器一起移动或缩小,内容移走后的空白处会失去拖动入口。内容层不裁剪,裁剪仅由固定视口承担;主画布滚轮使用非 passive 监听,避免浏览器默认滚动或缩放。 -- `resourceBookController` 使用独立 FLIP 快照层和 Web Animations 完成信号;逻辑栏目立即提交,不用定时器猜 CSS 动画终点。再次导航从当前快照位置开始,旧完成回调由 token 失效。 -- 屏幕 DOMRect 与逻辑布局坐标不能混用。主画布缩略入口测量先逆 viewport;快照复制必须保留祖先缩放对内部图片、文字的影响,单纯把克隆外框改成屏幕宽高会产生内部留白。 +- `resourceBookController` 对真实元素做 FLIP:`begin` 在状态提交前记录 First,提交后 `play` 测 Last 并给同一个节点加反向 transform 过渡回 identity,`sync` 在每次布局提交后比较布局签名做可中断重基。逻辑栏目立即提交,不用定时器猜 CSS 动画终点;再次导航从当前像素开始,旧完成回调由 token 失效。 +- 屏幕 DOMRect 与逻辑布局坐标不能混用。主画布缩略入口测量先逆 viewport;FLIP 的 translate 写在 world 局部坐标系,屏幕位移必须除以 world 缩放,宿主节点原点不在卡片左上角时(卡片包装节点是 0x0)还要按原点偏移换算。 +- 克隆快照 + 动画结束交接给真实节点这条路线有两个结构性缺陷,反复补丁也治不了:一是交接要求两个不同 DOM 对象的终态像素级一致;二是快照层在 world 之外、真实卡片在 world 之内,克隆坐标是屏幕空间、真实节点是 world 局部空间,交接时必然要重映射一次坐标。共享元素转场只在"同一批 DOM 节点、同一父节点"成立时才用真实元素 FLIP,不要引入克隆层。 +- 卡片尺寸依赖异步到达的预览图尺寸,布局会在转场动画之后(甚至动画结束之后)再变一次。所有布局变化必须走同一套"同一节点反向 transform",并在动画运行中以"此刻像素"为起点重基(转场中取剩余时长,下限 80ms;空闲布局变化取完整时长),否则晚到的那次布局变化仍是瞬移。 - 点击和滚轮共用导航入口,但滚轮提交不得重置已有的冷却和队列;监听器使用最新导航回调引用,避免视觉 phase 更新重装监听器并清空待处理手势。 - jsdom 不执行真实转场;定向模型测试之外,仍需浏览器核验中断连续性、图片/文字尺寸、viewport 保留和窄屏布局。完整契约见资源自由画板技术方案。 diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md index bb7c42a11..fc720e7cb 100644 --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md @@ -3,26 +3,27 @@ ## 2026-09-05 资源画本独立转场 - `resourceBookModel` 立即提交目标 `main / child + category`,`entering / returning-main` 只表示尚未收尾的视觉过程,不承载待提交的栏目。缩略入口、大纲、下一页与资源定位共用分类导航入口;栏目互切直接到达目标,不经过总览。滚轮不切换栏目。 -- 真实 `ResourceCard` 仍由唯一的 `ResourceBookScene` 挂载,保持预览请求、播放器和交互的单一所有者。转场前 `resourceBookController.begin` 捕获当前可见标题与资源卡的屏幕矩形和表现;目标 DOM 提交后的 layout effect 调用 `play`。 -- `ResourceBookTransitionLayer` 是 manager 内未变换的独立层。快照仅复制表现,不挂载 React 业务组件,移除重复 ID、业务 data 属性及可访问控件身份,整层 `inert / aria-hidden / pointer-events:none`。播放中的视频优先捕获当前帧;跨源禁止读取时保留已有 poster,不启动第二个播放器。 -- FLIP 只动画快照的 transform/opacity,统一时长 420ms,所有 `Animation.finished` 完成后删除快照并恢复目标真实节点;动画创建失败、零尺寸或不支持 Web Animations 时直接完成。`prefers-reduced-motion` 不创建动画,不等待定时器。 -- 再次导航先读取当前动画快照的实际位置,再取消旧动画并重新建立目标;token 隔离旧 promise,过期完成和取消均不能提交新状态。窗口尺寸变化、排序或异步布局更新时直接收尾到当前目标,避免使用过期几何。 +- 转场对**真实元素**做 FLIP,不使用克隆快照层。总览与子画布之间同一张卡是同一个 DOM 节点(同一 React key、同一父节点,只有布局函数在 `resourceBookOverviewCardLayout` 与 `resourceBookChildCardLayout` 之间切换),标题栏同理。`resourceBookController.begin` 在状态提交前按 key 记录 First 屏幕矩形与当前不透明度;提交后的 layout effect 调 `play` 测 Last,给同一个节点加反向 transform 再过渡回 identity。动画结束没有"克隆 → 真实节点"的交接,因此不存在交接瞬间的跳变;遮挡关系由真实 DOM 的 z-index/DOM 顺序决定,与静止态一致。 +- 反向 transform 写在宿主节点自身坐标系:`transform-origin: 0 0`,缩放比取 `First.size / Last.size`,位移按宿主原点偏移换算后除以 `.game-resource-book-scene-world` 的缩放。标题栏激活时内联的"抵消 world"transform 作为 base 与反向 transform 组合,结束帧回到该 base。 +- `begin` 之后的所有布局提交都调 `sync`:比较每个节点的布局签名,变化时取消旧动画、重新测量,以**当前视觉矩形**(含运行中的 transform)为起点重基到新的干净终点。转场进行中用剩余时长(`startTime + 420 - now`,下限 80ms),空闲布局变化用完整 420ms。卡片预览尺寸是异步到达的,因此"动画期间甚至动画结束之后"的二次布局变化同样是动画而不是瞬移。窗口/容器尺寸变化与拖拽中的卡片不做布局动画。 +- 目标不可见的节点(内联 `opacity` 为 0)钉在 First 位置淡出,没有 First 的挂载节点淡入;搜索/排序导致的卸载保留一帧后用同一套淡出撤下,进入栏目期间其他栏目保留挂载淡出,返回总览时源栏目未回到摞上的卡片按子画布布局淡出。转场时长统一 420ms;动画创建失败、零尺寸、容器尺寸变化或 `prefers-reduced-motion` 时不创建动画,也不等待定时器。 +- 再次导航先读取当前像素位置,再取消旧动画并重新建立目标;token 隔离旧 promise,过期完成和取消均不能提交新状态。 - 缩放、适应内容和空白拖动先结束视觉转场再执行用户输入;转场中的资源卡 pointer-down 不启动持久化拖拽。普通滚轮只平移当前视图,Ctrl/Meta 滚轮以指针为锚点缩放,均不切换栏目。 - viewport 只属于用户交互状态,按主画布以及 `sortMode/category` 隔离保存。转场不插值也不持久化 viewport;测量主画布缩略入口时先逆变换回布局坐标,避免缩放后的 DOMRect 被二次缩放。只有真实非零测量才能标记首次 fit 完成。 -验收使用 `resourceBookController.test.ts`、`resourceBookModel.test.ts`、`resourceBookViewport.test.ts` 和 `appSurface.test.ts`;页面 `.suite.ts` 由 `appSurface.test.ts` 注册,不能作为独立 Vitest 文件运行。真实浏览器需覆盖总览/栏目往返、连续改选、滚轮平移、缩放、尺寸变化、减少动画偏好,以及快照图片和字体的正确比例。jsdom 不模拟真实插值,不能代替浏览器验收。 +验收使用 `resourceBookController.test.ts`、`resourceBookLayout.test.ts`、`resourceBookModel.test.ts`、`resourceBookViewport.test.ts` 和 `appSurface.test.ts`;页面 `.suite.ts` 由 `appSurface.test.ts` 注册,不能作为独立 Vitest 文件运行。真实浏览器需覆盖总览/栏目往返、连续改选、滚轮平移、缩放、尺寸变化、减少动画偏好,以及真实元素的图片和字体比例。jsdom 不实现 Web Animations,只覆盖几何计算、状态机与调用契约,不能代替浏览器验收。 ### 2026-09-05 子画布无限平移与初始可读比例 子画布改为固定视口加可无限平移的 world 层:视口本身保持 `overflow: hidden`,平移和缩放只作用于 `.game-resource-book-scene-world`,不再让内容容器的边界限制拖动范围。首次 fit(包括隐藏分页画布提前完成的 fit)统一保证比例不低于 `1:1`,避免子画布展开后卡片缩成不可读的小块;用户后续主动缩放或平移的视口仍按每个分类独立保留。新增 viewport 回归测试覆盖初始可读比例和超出内容边界双向平移。 -返回主画布时立即提交主画布布局,由独立快照层完成缩回;world/main viewport 不播放附加动画。 +返回主画布时立即提交主画布布局,由真实元素 FLIP 完成缩回;world/main viewport 不播放附加动画。 总览和栏目视图同样使用固定铺满 manager 的交互视口,只有内部 world 层承担平移和缩放,内容层不裁剪缩略入口。空白拖动允许双向越过全部内容边界;移走内容后仍可在原视口继续拖动。普通滚轮/触控板平移,Shift 滚轮横向平移,Ctrl/Meta 滚轮以指针为锚点缩放;wheel 监听阻止浏览器滚动或页面缩放,但不切换栏目。总览/栏目往返保留总览原有位置和比例,只有主动适应内容才复位。 ## 2026-09-04 资源画本 UI 阶段补充 -资源工作台采用“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略入口,内部按资源类型显示有限层叠卡片,最多三层,更多资源以虚化卡片提示。主画布预览和子画布展开态复用同一套卡片视觉与标题栏结构,共享元素快照从当前屏幕位置过渡到目标位置。子画布标题栏提供缩回主画布的按钮;子画布互切直接到达目标,不强制经过主画布。 +资源工作台采用“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略入口,内部按资源类型显示有限层叠卡片,最多三层,更多资源以虚化卡片提示。主画布预览和子画布展开态复用同一套卡片视觉与标题栏结构,同一批 DOM 节点从当前屏幕位置过渡到目标位置。子画布标题栏提供缩回主画布的按钮;子画布互切直接到达目标,不强制经过主画布。 本阶段先实现前端 UI、导航状态和转场表现,资源真实跨画布移动、主画布正式资源登记、上传/生成任务、版本绑定以及 UI 编辑器导入与 UI 包工作流继续沿用现有权威链路,待 UI 编辑器完善后再接入。临时画本视图状态不得替代 manifest、草稿或其它后端业务真相。 @@ -174,6 +175,7 @@ ```powershell npm run ai-game-creator-shell:typecheck +node node_modules/vitest/vitest.mjs run apps/ai-game-creator-shell/tests/resourceBookController.test.ts apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts apps/ai-game-creator-shell/tests/resourceBookModel.test.ts apps/ai-game-creator-shell/tests/resourceBookViewport.test.ts node node_modules/vitest/vitest.mjs run apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts apps/ai-game-creator-shell/tests/resourceCanvasLayoutContract.test.ts node node_modules/vitest/vitest.mjs run apps/ai-game-creator-shell/tests/appSurface.test.ts cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check