From ff0bc5a3e713e63a591c910874cd96a643c88267 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 17:09:42 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E9=92=89=E4=BD=8F=E7=9A=84=E6=A0=87=E9=A2=98=E6=A0=8F=E7=A7=BB?= =?UTF-8?q?=E5=87=BA=E7=94=BB=E5=B8=83=E7=BC=A9=E6=94=BE=E5=B1=82=EF=BC=9A?= =?UTF-8?q?=E6=95=B4=E8=A1=8C=E6=96=87=E5=AD=97=E4=B8=8D=E5=86=8D=E5=8F=91?= =?UTF-8?q?=E8=99=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 钉在视口上的栏目标题栏(栏目名 / 计数徽标 / 资源总览入口)改挂场景根,成为带 scale() 的 .game-resource-book-scene-world 的兄弟节点,不再用 scale(1 / s) 抵消 world;总览态的栏目标题栏仍留在 world 里与缩略卡对齐 - index.tsx 抽出 ResourceBookScene.renderTitlebar(placement.pinned) 一处产出两种宿主,删除只服务抵消变换的 titlebarTransform 与 safeViewport/safeMainViewport 中间量 - resourceBookController 按节点自己的坐标系换算:world 内用 world 缩放,world 外(钉住标题栏)用 1;新增 nodeWorldTransform,readWorld 补 element 输出,nodeWorld 贯穿 cleanRect / FLIP 反向 transform / worldScale - 更新 resourceBookFlipValues 与 readOrigin 的口径注释:translate 单位是宿主自身坐标系,不再假定所有宿主都在 world 里 - appSurface 用例改钉新不变量:钉住的标题栏内联 transform 必须为空、不在 world 内、父节点为场景根,卡片必须仍在 world 内;并说明标题栏宿主不再跨视图同一节点、转场仍由控制器按 key 记录的 First 屏幕矩形驱动 - resourceBookController 用例新增「屏幕坐标系的宿主不被 world 缩放除」,并保留 world 内卡片仍按缩放除的对照 - 技术方案 2026-09-05 段落同步新结构;pitfalls 增记取证(截图墨色峰值 98.6 vs 同色正文 91.7、点阵周期反推 dpr=1)、本机复现尝试与结论、三处变异验证和真机判据 --- .../src/view/project-development/index.tsx | 100 +++++++++++------- .../resourceBookController.ts | 47 ++++++-- .../appSurface/project-development.suite.ts | 35 +++++- .../tests/resourceBookController.test.ts | 71 ++++++++++++- docs/project-memory/shared-memory/pitfalls.md | 10 ++ ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 5 +- 6 files changed, 206 insertions(+), 62 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 779889167..d012243c0 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 @@ -1154,10 +1154,48 @@ function ResourceBookScene({ onPointerCancel: (event: ReactPointerEvent) => void; onWheel: (event: ReactWheelEvent) => void; }) { - const safeViewport = normalizeResourceBookViewport(viewport); - const safeMainViewport = normalizeResourceBookViewport(mainViewport); - const sceneViewport = - resourceBookState.view === 'main' ? safeMainViewport : safeViewport; + const sceneViewport = normalizeResourceBookViewport( + resourceBookState.view === 'main' ? mainViewport : viewport, + ); + + /** + * 栏目标题栏宿主:总览态的栏目标题栏与子画布态钉在视口上的那一条共用同一个 DOM 形状。 + * + * `pinned` 决定它挂在哪个坐标系里,这条选择就是**文字清晰度**的开关: + * - 总览态(`pinned: false`):缩略卡本身活在 `.game-resource-book-main-world` 的缩放层里, + * 标题栏必须在同一个缩放坐标系里,才能严丝合缝地叠在缩略卡的标题位上; + * - 子画布态(`pinned: true`):这一条钉在视口上,**不挂进带 `scale()` 的 world**。挂在 world 里 + * 就得给标题栏自己加 `scale(1 / s)` 反向抵消才能回到 1:1,而反向缩放只保证几何:整条标题栏的 + * 文字(栏目名、计数徽标、`资源总览` 入口)都落在被缩放的祖先里,栅格化与合成层由浏览器决定, + * 一旦这一层被提升或复用旧纹理,文字就会整体发虚(栏目名与计数深浅不同也一起发虚)。 + * 钉住的那一层本来就活在屏幕坐标系里,直接挂场景根下即可,永远是 1:1。 + */ + const renderTitlebar = ( + group: ResourceBookSceneCategoryPlan, + placement: { pinned: boolean; rect?: ResourceBookOverviewRect }, + ) => { + const style: CSSProperties = placement.pinned + ? { left: 0, top: 0, width: '100%', opacity: 1 } + : { + left: placement.rect?.left ?? 0, + top: placement.rect?.top ?? 0, + width: `${placement.rect?.width ?? 280}px`, + opacity: group.titlebarOpacity, + }; + return ( +
+ +
+ ); + }; return ( // 共享 SelectionOverlay 的框选描边/底色 token 只声明在 `.genarrative-image-canvas` @@ -1180,47 +1218,20 @@ function ResourceBookScene({ } > {plan.map((group) => { - const active = group.titlebarActive; - const rect = overviewRects.get(group.category); - const titleLayout = active - ? { left: 0, top: 0, width: '100%', opacity: 1 } - : { - left: rect?.left ?? 0, - top: rect?.top ?? 0, - width: `${rect?.width ?? 280}px`, - opacity: group.titlebarOpacity, - }; - const titlebarTransform = active - ? `translate(${-safeViewport.x / safeViewport.scale}px, ${-safeViewport.y / safeViewport.scale}px) scale(${1 / safeViewport.scale})` - : undefined; return ( {/* 展开态的真实栏目不出标题栏:那一页是**一张平铺画布**,只有 `all` 那一条钉在 - 视口上的标题栏(见 `ResourceBookSceneCategoryPlan.titlebar`)。 + 视口上的标题栏(见 `ResourceBookSceneCategoryPlan.titlebar`);钉住的那一条只在 + 场景根下渲染一次(见下方 `renderTitlebar(group, { pinned: true })`), + world 里只留总览态的栏目标题栏。 */} - {group.titlebar ? ( -
- -
- ) : null} + {group.titlebar && !group.titlebarActive + ? renderTitlebar(group, { + pinned: false, + rect: overviewRects.get(group.category), + }) + : null} {group.cards.map((card) => { const expanded = card.presentation === 'child'; const cardVisible = @@ -1281,6 +1292,15 @@ function ResourceBookScene({ })} {worldOverlay} + {/* + 钉在视口上的栏目标题栏:**world 之外**的兄弟节点,文字按 CSS 像素 1:1 栅格化。 + 它必须排在 world 之后(同层里后画的压在上面),才能盖住世界层里的卡片。 + */} + {plan.map((group) => + group.titlebar && group.titlebarActive + ? renderTitlebar(group, { pinned: true }) + : null, + )} {overlay} ); diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts index 09b9978e7..ca69964c1 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts @@ -105,8 +105,8 @@ export function resourceBookValidMotionRect( /** * FLIP 反向 transform。transform-origin 固定 0 0,缩放围绕宿主节点自身的原点进行, - * 所以位置需要按原点偏移换算;translate 的数值单位是宿主节点所在坐标系(world 局部 - * 空间),屏幕位移要先除以 world 的缩放。 + * 所以位置需要按原点偏移换算;translate 的数值单位是**宿主节点自己的坐标系**:world 里的 + * 卡片按 world 局部空间(屏幕位移除以 world 缩放),钉住的栏目标题栏按屏幕空间(比例 1)。 */ export function resourceBookFlipValues( first: ResourceBookMotionRect, @@ -166,7 +166,7 @@ function readVisualOpacity(host: HTMLElement, fallback: number) { /** * 宿主节点自身原点的屏幕位置。卡片包装节点是 0x0,原点就是它的矩形左上角;标题栏是 - * 有尺寸的节点,激活时还带一层抵消 world 的内联 transform,需要临时摘掉再量。 + * 有尺寸的节点,内联 transform 若是上一段动画留下的残留,需要临时摘掉再量。 */ function readOrigin(host: HTMLElement): ResourceBookMotionOrigin { const base = host.style.transform; @@ -185,13 +185,24 @@ type WorldTransform = { origin: ResourceBookMotionOrigin; }; +type SceneWorldTransform = WorldTransform & { element: HTMLElement | null }; + +/** + * 不在 world 里的节点(钉在视口上的栏目标题栏)所在坐标系:它本身就在屏幕坐标系里, + * 屏幕位移不需要除以 world 缩放,几何换算走恒等。 + */ +const SCREEN_SPACE_TRANSFORM: WorldTransform = { + scale: 1, + origin: { left: 0, top: 0 }, +}; + /** * world 变换(平移 + 缩放,transform-origin 0 0)。宿主节点的几何基准统一存成 * world 局部坐标,平移和缩放就不会把"上一次的干净位置"变成过期的屏幕坐标。 */ -function readWorld(root: HTMLElement): WorldTransform { +function readWorld(root: HTMLElement): SceneWorldTransform { const world = root.querySelector(WORLD_SELECTOR); - if (!world) return { scale: 1, origin: { left: 0, top: 0 } }; + if (!world) return { element: null, scale: 1, origin: { left: 0, top: 0 } }; const rect = world.getBoundingClientRect(); const transform = getComputedStyle(world).transform; const match = /^matrix(?:3d)?\(([^)]+)\)$/.exec(transform); @@ -199,7 +210,19 @@ function readWorld(root: HTMLElement): WorldTransform { ? Number.parseFloat(match[1]!.split(',')[0] ?? '') : Number.NaN; const scale = Number.isFinite(parsed) && parsed > 0 ? parsed : 1; - return { scale, origin: { left: rect.left, top: rect.top } }; + return { element: world, scale, origin: { left: rect.left, top: rect.top } }; +} + +/** + * 节点自己的坐标系。钉住的栏目标题栏是 world 的**兄弟**节点(见 `ResourceBookScene`), + * 它不经 world 缩放,必须按屏幕坐标系换算,否则 FLIP 的反向平移会被 world 缩放除错, + * 动画终点会偏移。 + */ +function nodeWorldTransform( + host: HTMLElement, + world: SceneWorldTransform, +): WorldTransform { + return world.element?.contains(host) ? world : SCREEN_SPACE_TRANSFORM; } function toWorldRect( @@ -430,13 +453,15 @@ export function createResourceBookTransitionController() { if (!existing) records.set(node.key, record); record.host = node.host; record.box = node.box; + // 每个节点用自己的坐标系:world 里的卡片按 world 缩放换算,钉住的栏目标题栏按屏幕换算。 + const nodeWorld = nodeWorldTransform(node.host, world); if (options.frozenKeys?.has(node.key)) { // 拖拽中的卡片只跟指针走:不参与布局 FLIP,但保持基准几何最新, // 落位提交后不会把卡片拉回拖拽前的位置。 cancelRecord(record); record.signature = signature; record.targetOpacity = targetOpacity; - record.cleanRect = toWorldRect(readRect(node.box), world); + record.cleanRect = toWorldRect(readRect(node.box), nodeWorld); continue; } @@ -461,7 +486,7 @@ export function createResourceBookTransitionController() { record.signature = signature; record.targetOpacity = targetOpacity; if (!hadAnimation) { - record.cleanRect = toWorldRect(readRect(node.box), world); + record.cleanRect = toWorldRect(readRect(node.box), nodeWorld); } continue; } @@ -476,7 +501,7 @@ export function createResourceBookTransitionController() { cancelRecord(record); record.signature = signature; record.targetOpacity = targetOpacity; - record.cleanRect = toWorldRect(readRect(node.box), world); + record.cleanRect = toWorldRect(readRect(node.box), nodeWorld); if (suppressed) continue; // 转场用 begin 记录的 First;动画中途重基用"此刻像素";空闲布局变化用上一次的干净矩形。 const first = @@ -484,7 +509,7 @@ export function createResourceBookTransitionController() { (hadAnimation ? visual : previousCleanRect - ? fromWorldRect(previousCleanRect, world) + ? fromWorldRect(previousCleanRect, nodeWorld) : visual); startMotion(record, { first, @@ -493,7 +518,7 @@ export function createResourceBookTransitionController() { snapshotEntry && !hadAnimation ? snapshotEntry.opacity : startOpacity, endOpacity: targetOpacity, duration, - worldScale: world.scale, + worldScale: nodeWorld.scale, }); } 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 738da1745..1b1b5c769 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 @@ -785,14 +785,24 @@ export function registerProjectWorkbenchFoundationTests() { expect(documentCardBefore).not.toBeNull(); expect(documentTitlebarBefore).not.toBeNull(); await openResourceBookCategory('待归类'); - // 真实元素 FLIP:卡片和标题栏在总览与子画布之间是同一批 DOM 节点, - // 转场不引入克隆快照层。 + // 真实元素 FLIP:卡片在总览与子画布之间是同一批 DOM 节点,转场不引入克隆快照层。 expect(documentCardBefore!.isConnected).toBe(true); expect(documentCardBefore!.closest('[data-resource-book-view]')).toBe( document.querySelector('[data-resource-book-view="child"]'), ); - expect(documentTitlebarBefore!.isConnected).toBe(true); - expect(documentTitlebarBefore!.classList.contains('is-active')).toBe(true); + /** + * 钉在视口上的标题栏是**屏幕坐标系里的另一份宿主**:它不挂进带 `scale()` 的 world + * (见 `ResourceBookScene.renderTitlebar`),所以进入栏目时总览那一份卸下、钉住的那一份挂上。 + * 转场仍然是真实元素的 FLIP:控制器按 key 记下 First 屏幕矩形,动画在新宿主上从该矩形回到静止位 + * (见 `resourceBookController.play` 的 `remounted` 分支),没有克隆层。 + */ + const pinnedTitlebar = document.querySelector( + '[data-resource-book-view="child"] .game-resource-book-scene-titlebar.is-active[data-resource-book-category="unclassified"]', + ); + expect(pinnedTitlebar).not.toBeNull(); + expect( + pinnedTitlebar!.closest('.game-resource-book-scene-world'), + ).toBeNull(); expect( document.querySelector('.game-resource-book-transition-layer'), ).toBeNull(); @@ -856,13 +866,28 @@ export function registerProjectWorkbenchFoundationTests() { const childTitlebar = document.querySelector( '[data-resource-book-view="child"] .game-resource-book-scene-titlebar.is-active', ); - expect(childTitlebar?.style.transform).toContain('scale('); + expect(childTitlebar).not.toBeNull(); const childScene = childCard?.closest( '.game-resource-book-scene', ); const childSceneWorld = childScene?.querySelector( '.game-resource-book-scene-world', ); + /** + * 钉在视口上的那一行(栏目名 / 计数 / `资源总览`)必须活在**屏幕坐标系**里: + * 它是缩放层 world 的兄弟节点,自身不带任何抵消缩放的 transform。 + * 反过来(挂进 world 再用 `scale(1 / s)` 抵消)整条标题栏的文字都要在被缩放的祖先里栅格化, + * 会出现整行文字一起发虚。这里把结构钉死,避免以后又被搬回 world。 + */ + expect(childTitlebar!.style.transform).toBe(''); + expect( + childTitlebar!.closest('.game-resource-book-scene-world'), + ).toBeNull(); + expect(childTitlebar!.parentElement).toBe(childScene); + // world 仍然只包画布内容:卡片留在缩放层里(缩略卡与场景卡共用同一个坐标系)。 + expect(childCard!.closest('.game-resource-book-scene-world')).toBe( + childSceneWorld, + ); const childSceneTransformBeforeZoom = childSceneWorld?.style.transform; fireEvent.click(screen.getByRole('button', { name: '放大画布' })); await waitFor(() => diff --git a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts index ea4a41af4..37b6c5571 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts @@ -72,7 +72,7 @@ function installAnimateMock() { return flights; } -function fixture(worldScale = 1) { +function fixture(worldScale = 1, options: { pinnedTitlebar?: boolean } = {}) { const manager = document.createElement('div'); manager.className = 'game-resource-book-manager'; manager.innerHTML = `
@@ -82,6 +82,11 @@ function fixture(worldScale = 1) {
+ ${ + options.pinnedTitlebar + ? '
' + : '' + } `; document.body.append(manager); const world = manager.querySelector( @@ -92,13 +97,23 @@ function fixture(worldScale = 1) { )!; const cardBox = manager.querySelector('.game-resource-card')!; const titleHost = manager.querySelector( - '.game-resource-book-scene-titlebar', + '.game-resource-book-scene-world .game-resource-book-scene-titlebar', )!; + const pinnedTitleHost = manager.querySelector( + '.game-resource-book-scene > .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]) { + for (const element of [ + manager, + world, + cardHost, + cardBox, + titleHost, + ...(pinnedTitleHost ? [pinnedTitleHost] : []), + ]) { vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() => toDomRect(rects.get(element) ?? { left: 0, top: 0, width: 0, height: 0 }), ); @@ -130,6 +145,7 @@ function fixture(worldScale = 1) { cardHost, cardBox, titleHost, + pinnedTitleHost, setRect, signatures, }; @@ -301,7 +317,7 @@ describe('resource book real element FLIP', () => { it('still animates the active titlebar back to the overview stack', () => { const flights = installAnimateMock(); const { manager, cardBox, titleHost, setRect, signatures } = fixture(); - // 子画布静止态:活动标题栏用抵消 world 的内联 transform 钉在屏幕上。 + // 子画布静止态:标题栏的屏幕矩形与 world 局部矩形不一致(world 有平移/缩放)。 vi.mocked(titleHost.getBoundingClientRect).mockImplementation(() => titleHost.style.transform ? toDomRect({ left: 0, top: 0, width: 560, height: 84 }) @@ -321,6 +337,53 @@ describe('resource book real element FLIP', () => { controller.dispose(); }); + it('flips a pinned titlebar in screen space, not in world space', () => { + const flights = installAnimateMock(); + // 钉在视口上的标题栏是缩放层 world 的兄弟节点(见 ResourceBookScene):它不在 world 坐标系里, + // 反向平移不能再除以 world 缩放,否则动画终点会被缩放除错。 + const { manager, cardBox, pinnedTitleHost, setRect, signatures } = fixture( + 2, + { + pinnedTitlebar: true, + }, + ); + setRect(cardBox, { left: 100, top: 100, width: 92, height: 64 }); + setRect(pinnedTitleHost!, { left: 40, top: 30, width: 560, height: 84 }); + const controller = createResourceBookTransitionController(); + const token = controller.begin(manager); + + // 收起回总览:钉住的那一条换成总览矩形(同位、更窄更矮)。 + setRect(pinnedTitleHost!, { left: 0, top: 0, width: 800, height: 42 }); + controller.play(manager, token, () => undefined, signatures); + + const flight = flights.find((entry) => entry.host === pinnedTitleHost)!; + // world 缩放为 2:世界层里的节点会被除以 2;屏幕坐标系里的宿主必须原样保留 (40, 30)。 + expect(flight.frames[0]!.transform).toBe( + 'translate(40px, 30px) scale(0.7, 2)', + ); + expect(flight.frames[1]!.transform).toBe('none'); + controller.dispose(); + }); + + it('still divides a world-space card translation by the world scale', () => { + const flights = installAnimateMock(); + const { manager, cardHost, cardBox, setRect, signatures } = fixture(2); + 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); + + const flight = flights.find((entry) => entry.host === cardHost)!; + expect(flight.frames[0]!.transform).toBe( + 'translate(-50px, -25px) scale(0.511, 0.5)', + ); + controller.dispose(); + }); + it('fades a titlebar out in place while entering a category', () => { const flights = installAnimateMock(); const { manager, cardBox, titleHost, setRect, signatures } = fixture(); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d6f01d3ab..baa8873e4 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5380,3 +5380,13 @@ - **处理**:把 CAS 判据收窄到契约面——新增 `manifestCasFingerprint`(`{ assets, versions }`),同 revision 下三态:判据面不同 → 仍 `revision-conflict`(真实的漏推 revision 必须继续可见,不许被吞掉);判据面相同且整份清单相同 → `duplicate`;判据面相同、只有非 CAS 账目状态(`preview` / 任务进度 / 项目名)不同 → `accepted`,revision **不推也不退**,让内容真正落地。同类簿记写点(`record_command_run`、`update_manifest_task_status_at`、`rename_local_game_project`、Godot 根校准)同样不推 revision,收窄判据面后不再产生用户可见拒收。 - **验证**:新增用例 `adopts same-revision bookkeeping changes instead of rejecting them`(同 revision 只有 `preview` 从 stopped 变 running ⇒ 必须 `accepted`、`projectManifestMergeRejectionDecision` 为 null、revision 仍为 4)与对照钉子 `still fails closed when the protected assets change under the same revision`(同 revision 多出一条资产 ⇒ 必须仍 `revision-conflict`)。变异验证:把判据改回整份 JSON 指纹 → 新用例以 `expected 'revision-conflict' to be 'accepted'` 失败(正是用户看到的那条决策),恢复后转绿。定向:`projectResourceLiveUpdateModel` 17 passed、`workspaceLauncherManifestMerge` 5 passed、appSurface 413 passed、`npm run typecheck` exit 0、`npm run check:encoding` 4399 files passed、`git diff --check` 干净。 - **关联**:`apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts`、`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx`、`apps/ai-game-creator-shell/src/App.tsx`(supervisor 配对读)、`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs`、`apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts`。 + +## 2026-09-12 资源工作台顶部那一行发虚:钉住的标题栏不该活在画布缩放层里 + +- **现象**:资源管理页顶部那一条栏目标题栏(栏目名「待归类」+ 计数「2 项」+「资源总览」入口)肉眼比页面其它文字糊,用户截图把左(栏目名)右(计数 + 入口)两块都框了出来;同一行里深浅不同、字号不同的三段文字一起发虚。 +- **取证**:① 截图里那一行墨色峰值 luminance 98.6,而同一张图里**同一个 token 色**(`--platform-text-base` `#6f5848`)的正文峰值 91.7 ⇒ 那一行的字形边缘确实比同色文字更软(不是"颜色浅"三个字能解释的)。② 用截图里场景点阵的周期反推 dpr:`18px` 网格量到 18 设备像素 ⇒ dpr=1、无页面缩放;标题栏仍是 13px `` + 11px 计数。③ 用**真实 `styles.css` + `theme.css` + 真实 DOM 形状**在本机 Edge(有头带 LCD 彩边、无头软件光栅与 GPU 光栅各一遍)复现:world `translate(x,y) scale(s)` + 标题栏 `translate(-x/s,-y/s) scale(1/s)` 抵消之后,与"完全不挂 world"的同一行**逐像素相同**(ink / coverage / 最暗值 / LCD 彩边数完全一致),`s=1`、`s=1.1`、有/无卡片 `translate3d` 合成层、有/无带 `backdrop-filter` 的缩放胶囊都不改变这个结论。 +- **原因(代码层)**:这一行是**唯一**活在带 `scale()` 的 world 里的文本,它的 1:1 完全靠 `scale(1 / s)` 反向抵消换来(旧 `index.tsx` 把抵消 transform 内联在 `.game-resource-book-scene-titlebar` 上)。反向缩放只保证几何,栅格化与合成层仍由浏览器决定:实测把同一个节点提升成独立合成层(`will-change: transform`)后,LCD 彩边像素从 90 直接掉到 0,整行转灰度 AA。也就是说这一行的清晰度押在了"浏览器恰好按合成矩阵 1:1 光栅化"上——这是环境相关的(本机复现不出来,恰好说明它有环境依赖),而结构上它本来就不该在画布缩放层里:钉在视口上的 chrome 与画布内容不是同一个坐标系。 +- **处理**:`ResourceBookScene` 把**钉住那一条**标题栏改挂场景根(`.game-resource-book-scene-world` 的兄弟节点),不再带任何抵消 transform;总览态的栏目标题栏仍留在 world 里跟缩略卡对齐。`resourceBookController` 按"节点是否在 world 内"选择坐标系缩放(world 外 = 1),否则 FLIP 的反向平移会被 world 缩放除错。转场仍是真实元素 FLIP:控制器按 key 记 First 屏幕矩形,动画在**新宿主**上从该矩形回到静止位(`remounted` 分支),无克隆层;代价是标题栏宿主不再跨视图保持同一个 DOM 节点(卡片仍然保持)。 +- **验证**: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`。 diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md index 98855d7b8..be229580e 100644 --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md @@ -3,8 +3,9 @@ ## 2026-09-05 资源画本独立转场 - `resourceBookModel` 立即提交目标 `main / child + category`,`entering / returning-main` 只表示尚未收尾的视觉过程,不承载待提交的栏目。总览栏目缩略卡片、「所有资源」入口、下一页与资源定位共用分类导航入口;栏目互切直接到达目标,不经过总览(列表卡片本身就在总览上,所以从总览进栏目是先总览后直达)。滚轮不切换栏目。 -- 转场对**真实元素**做 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。 +- 转场对**真实元素**做 FLIP,不使用克隆快照层。总览与子画布之间同一张卡是同一个 DOM 节点(同一 React key、同一父节点,只有布局函数在 `resourceBookOverviewCardLayout` 与 `resourceBookChildCardLayout` 之间切换)。`resourceBookController.begin` 在状态提交前按 key 记录 First 屏幕矩形与当前不透明度;提交后的 layout effect 调 `play` 测 Last,给同一个节点加反向 transform 再过渡回 identity。动画结束没有"克隆 → 真实节点"的交接,因此不存在交接瞬间的跳变;遮挡关系由真实 DOM 的 z-index/DOM 顺序决定,与静止态一致。 +- 钉在视口上的那条栏目标题栏是**例外**:它是 `.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` 时不创建动画,也不等待定时器。 - `begin` 只记录**当时可见**的节点。不可见节点的屏幕矩形是"看不见的位置":非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,若把它当 First,返回总览时标题栏会从屏幕外飞进来。不可见节点没有 First,只走淡入,位置保持不动;进入栏目方向它们仍然可见,因此照旧钉在原地淡出。同理,卡片只有在提交前可见时才参与几何 FLIP。