资源工作台钉住的标题栏移出画布缩放层:整行文字不再发虚

- 钉在视口上的栏目标题栏(栏目名 / 计数徽标 / 资源总览入口)改挂场景根,成为带 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)、本机复现尝试与结论、三处变异验证和真机判据
This commit is contained in:
2026-09-12 17:09:42 +08:00
parent 46788edbb8
commit ff0bc5a3e7
6 changed files with 206 additions and 62 deletions
@@ -1154,10 +1154,48 @@ function ResourceBookScene({
onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;
onWheel: (event: ReactWheelEvent<HTMLDivElement>) => 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 (
<div
className={`game-resource-book-scene-titlebar${placement.pinned ? ' is-active' : ''}`}
data-resource-book-category={group.category}
style={style}
>
<ResourceBookTitleBar
category={group.category}
countLabel={`${group.resourceCount}`}
onCollapse={placement.pinned ? onCollapse : undefined}
/>
</div>
);
};
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 (
<Fragment key={group.category}>
{/*
**** `all`
`ResourceBookSceneCategoryPlan.titlebar`
`ResourceBookSceneCategoryPlan.titlebar`
`renderTitlebar(group, { pinned: true })`
world
*/}
{group.titlebar ? (
<div
className={`game-resource-book-scene-titlebar${active ? ' is-active' : ''}`}
data-resource-book-category={group.category}
style={
{
left: `${titleLayout.left}px`,
top: `${titleLayout.top}px`,
width: titleLayout.width,
opacity: titleLayout.opacity,
transform: titlebarTransform,
transformOrigin: active ? '0 0' : undefined,
} as CSSProperties
}
>
<ResourceBookTitleBar
category={group.category}
countLabel={`${group.resourceCount}`}
onCollapse={active ? onCollapse : undefined}
/>
</div>
) : 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}
</div>
{/*
**world ** CSS 1:1
world
*/}
{plan.map((group) =>
group.titlebar && group.titlebarActive
? renderTitlebar(group, { pinned: true })
: null,
)}
{overlay}
</div>
);
@@ -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<HTMLElement>(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,
});
}
@@ -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<HTMLElement>(
'[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<HTMLElement>(
'[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<HTMLElement>(
'.game-resource-book-scene',
);
const childSceneWorld = childScene?.querySelector<HTMLElement>(
'.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(() =>
@@ -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 = `<div class="game-resource-book-scene">
@@ -82,6 +82,11 @@ function fixture(worldScale = 1) {
<div class="game-resource-card" data-resource-card-id="one"></div>
</div>
</div>
${
options.pinnedTitlebar
? '<div class="game-resource-book-scene-titlebar is-active" data-resource-book-category="unclassified"></div>'
: ''
}
</div>`;
document.body.append(manager);
const world = manager.querySelector<HTMLElement>(
@@ -92,13 +97,23 @@ function fixture(worldScale = 1) {
)!;
const cardBox = manager.querySelector<HTMLElement>('.game-resource-card')!;
const titleHost = manager.querySelector<HTMLElement>(
'.game-resource-book-scene-titlebar',
'.game-resource-book-scene-world .game-resource-book-scene-titlebar',
)!;
const pinnedTitleHost = manager.querySelector<HTMLElement>(
'.game-resource-book-scene > .game-resource-book-scene-titlebar',
);
const rects = new Map<Element, MotionRect>();
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();
@@ -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 `<strong>` + 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 <div …> 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`
@@ -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。