重构资源画本画布与转场状态

统一主画布与子画布的可见渲染层和 viewport 状态管理。

将子画布改为固定视口加无限平移 world,并保证首次 fit 的可读比例。

补充转场、滚轮、viewport 和资源画本回归测试,更新技术方案文档。
This commit is contained in:
2026-09-05 09:10:56 +08:00
parent 820fbfd988
commit 5bcaca7233
14 changed files with 1710 additions and 667 deletions
+39 -113
View File
@@ -5182,6 +5182,19 @@ iframe.preview-frame {
inset: 0;
overflow: hidden;
pointer-events: none;
transition: none;
}
.game-resource-book-scene-world {
position: absolute;
inset: 0;
transform-origin: 0 0;
transition: none;
}
.game-resource-book-scene--entering .game-resource-book-scene-world,
.game-resource-book-scene--returning .game-resource-book-scene-world {
transition: transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1);
}
.game-resource-book-scene--child {
@@ -5283,6 +5296,7 @@ iframe.preview-frame {
left 420ms cubic-bezier(0.2, 0.78, 0.2, 1),
top 420ms cubic-bezier(0.2, 0.78, 0.2, 1),
width 420ms cubic-bezier(0.2, 0.78, 0.2, 1),
transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1),
translate 180ms ease,
opacity 180ms ease;
}
@@ -5292,6 +5306,13 @@ iframe.preview-frame {
pointer-events: auto;
}
/* 子画布中的标题栏是固定 UI。拖动画布时 viewport 会逐帧更新,不能让
* 标题栏的逆变换继续走转场 transition,否则它会滞后跟随画布。 */
.game-resource-book-scene--child.game-resource-book-scene--idle
.game-resource-book-scene-titlebar.is-active {
transition: none;
}
.game-resource-book-scene-titlebar .game-resource-book-titlebar {
min-height: 42px;
border-bottom: 0;
@@ -5437,16 +5458,24 @@ iframe.preview-frame {
}
.game-resource-book-main {
display: flex;
flex-direction: column;
position: relative;
flex: 1 1 auto;
min-height: 0;
padding: clamp(24px, 4vw, 52px);
overflow: auto;
overflow: hidden;
user-select: none;
background:
radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px,
linear-gradient(145deg, #fffdfa 0%, #fff8f2 100%);
}
.game-resource-book-manager--entering .game-resource-book-main,
.game-resource-book-manager--returning .game-resource-book-main {
transition: transform 420ms cubic-bezier(0.2, 0.78, 0.2, 1);
}
.game-resource-book-main-heading {
display: flex;
align-items: flex-end;
@@ -5722,122 +5751,19 @@ iframe.preview-frame {
box-shadow: 0 3px 10px rgb(158 87 57 / 14%);
}
@keyframes resource-book-child-enter {
from {
opacity: 0.25;
clip-path: inset(
var(--resource-book-origin-top) 0 0 var(--resource-book-origin-left) round
16px
);
transform: translate(
calc(var(--resource-book-origin-left) * -1),
calc(var(--resource-book-origin-top) * -1)
)
scale(0.28);
}
to {
opacity: 1;
clip-path: inset(0 0 0 0 round 0);
transform: translateY(0) scale(1);
}
}
@keyframes resource-book-preview-expand {
from {
opacity: 1;
left: var(--resource-book-origin-left);
top: var(--resource-book-origin-top);
width: var(--resource-book-origin-width);
height: var(--resource-book-origin-height);
}
to {
opacity: 1;
left: 0;
top: 0;
width: var(--resource-book-target-width);
height: var(--resource-book-target-height);
}
}
@keyframes resource-book-preview-shrink {
from {
opacity: 1;
left: 0;
top: 0;
width: var(--resource-book-target-width);
height: var(--resource-book-target-height);
}
to {
opacity: 1;
left: var(--resource-book-origin-left);
top: var(--resource-book-origin-top);
width: var(--resource-book-origin-width);
height: var(--resource-book-origin-height);
}
}
@keyframes resource-book-card-expand {
from {
left: 12px;
top: calc(16px + var(--preview-stack-index) * 7px);
width: min(80%, 180px);
height: 64px;
opacity: 1;
transform: rotate(calc((var(--preview-stack-index) - 1) * 1deg));
}
to {
left: var(--target-x);
top: var(--target-y);
width: var(--target-width);
height: var(--target-height);
opacity: 1;
transform: rotate(0deg);
}
}
@keyframes resource-book-card-shrink {
from {
left: var(--target-x);
top: var(--target-y);
width: var(--target-width);
height: var(--target-height);
opacity: 1;
transform: rotate(0deg);
}
to {
left: 12px;
top: calc(16px + var(--preview-stack-index) * 7px);
width: min(80%, 180px);
height: 64px;
opacity: 1;
transform: rotate(calc((var(--preview-stack-index) - 1) * 1deg));
}
}
@keyframes resource-book-child-return {
from {
opacity: 1;
clip-path: inset(0 0 0 0 round 0);
transform: translateY(0) scale(1);
}
to {
opacity: 0.15;
clip-path: inset(
var(--resource-book-origin-top) 0 0 var(--resource-book-origin-left) round
16px
);
transform: translate(
calc(var(--resource-book-origin-left) * -1),
calc(var(--resource-book-origin-top) * -1)
)
scale(0.28);
}
}
@media (prefers-reduced-motion: reduce) {
.game-resource-book-main {
animation: none;
}
.game-resource-book-scene,
.game-resource-book-scene::before,
.game-resource-book-scene-titlebar,
.game-resource-book-scene-card,
.game-resource-book-scene-card .game-resource-card,
.game-resource-book-thumbnail {
transition: none !important;
}
}
.game-resource-search {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
export type ResourceBookTransitionController = {
begin(): number;
invalidate(): number;
isCurrent(token: number): boolean;
schedule(
callback: (token: number) => void,
delayMs: number,
token: number,
): void;
requestFrame(callback: (token: number) => void, token: number): void;
dispose(): void;
};
export type ResourceBookTransitionClock = {
setTimeout(callback: () => void, delayMs: number): number;
clearTimeout(timerId: number): void;
requestAnimationFrame(callback: () => void): number;
cancelAnimationFrame(frameId: number): void;
};
function browserTransitionClock(): ResourceBookTransitionClock {
return {
setTimeout: (callback, delayMs) => window.setTimeout(callback, delayMs),
clearTimeout: (timerId) => window.clearTimeout(timerId),
requestAnimationFrame: (callback) => window.requestAnimationFrame(callback),
cancelAnimationFrame: (frameId) => window.cancelAnimationFrame(frameId),
};
}
export function createResourceBookTransitionController(
clock: ResourceBookTransitionClock = browserTransitionClock(),
): ResourceBookTransitionController {
let token = 0;
let timerId: number | null = null;
let frameId: number | null = null;
const clearPending = () => {
if (timerId !== null) {
clock.clearTimeout(timerId);
timerId = null;
}
if (frameId !== null) {
clock.cancelAnimationFrame(frameId);
frameId = null;
}
};
const invalidate = () => {
token += 1;
clearPending();
return token;
};
return {
begin: invalidate,
invalidate,
isCurrent: (expectedToken) => expectedToken === token,
schedule: (callback, delayMs, expectedToken) => {
if (timerId !== null) {
clock.clearTimeout(timerId);
}
timerId = clock.setTimeout(() => {
timerId = null;
if (expectedToken === token) {
callback(expectedToken);
}
}, delayMs);
},
requestFrame: (callback, expectedToken) => {
if (frameId !== null) {
clock.cancelAnimationFrame(frameId);
}
frameId = clock.requestAnimationFrame(() => {
frameId = null;
if (expectedToken === token) {
callback(expectedToken);
}
});
},
dispose: () => {
invalidate();
},
};
}
@@ -0,0 +1,93 @@
import type { ResourceBookCategory } from './resourceBookModel';
import type { ResourceCanvasCardSize } from './resourceCanvasLayoutModel';
import type { ProjectResource } from './resourceProjectionModel';
import { projectResourceTypeLabel } from './resourceProjectionModel';
export type ResourceBookOverviewRect = {
left: number;
top: number;
width: number;
height: number;
};
export type ResourceBookStack = [type: string, items: ProjectResource[]];
export type ResourceBookCardLayout = {
x: number;
y: number;
width: number;
height: number;
dragX: number;
dragY: number;
rotation: number;
};
export function groupResourceBookStacks(
resources: readonly ProjectResource[],
): ResourceBookStack[] {
const groups = new Map<string, ProjectResource[]>();
for (const resource of resources) {
const items = groups.get(projectResourceTypeLabel(resource)) ?? [];
items.push(resource);
groups.set(projectResourceTypeLabel(resource), items);
}
return Array.from(
groups,
([type, items]) => [type, items] as ResourceBookStack,
);
}
export function groupResourceBookResourcesByCategory(
resources: readonly ProjectResource[],
) {
const result = new Map<ResourceBookCategory, ProjectResource[]>();
for (const resource of resources) {
const items = result.get(resource.category) ?? [];
items.push(resource);
result.set(resource.category, items);
}
return result;
}
export function resourceBookOverviewCardLayout({
rect,
stackIndex,
stackColumn,
}: {
rect: ResourceBookOverviewRect | undefined;
stackIndex: number;
stackColumn: number;
}): ResourceBookCardLayout {
return {
x: (rect?.left ?? 0) + 18 + (stackColumn % 3) * 102,
y: (rect?.top ?? 0) + 58 + Math.min(stackIndex, 2) * 5,
width: 92,
height: 64,
dragX: 0,
dragY: 0,
rotation: stackIndex % 2 ? -(stackIndex + 0.5) : stackIndex + 0.5,
};
}
export function resourceBookChildCardLayout({
position,
size,
}: {
position: { x: number; y: number } | undefined;
size: ResourceCanvasCardSize;
}): ResourceBookCardLayout {
const x = position?.x ?? 0;
const y = position?.y ?? 0;
// The child scene applies the viewport transform on its card-world wrapper.
// Keep card geometry logical here so persistence, drag callbacks, and the
// public ResourceCard style contract are not polluted by screen space.
return {
x,
y,
width: size.width,
height: size.height,
dragX: x,
dragY: y,
rotation: 0,
};
}
@@ -0,0 +1,138 @@
import type { ProjectResourceCategory } from './resourceProjectionModel';
export type ResourceBookCategory = ProjectResourceCategory;
export type ResourceBookPhase = 'idle' | 'entering' | 'returning';
export type ResourceBookState = {
view: 'main' | 'child';
category: ResourceBookCategory | null;
pendingCategory: ResourceBookCategory | null;
phase: ResourceBookPhase;
token: number;
};
export type ResourceBookAction =
| { type: 'open-category'; category: ResourceBookCategory; token: number }
| { type: 'commit-enter'; token: number }
| { type: 'finish-transition'; token: number }
| { type: 'return-to-main'; token: number }
| { type: 'commit-return'; token: number }
| { type: 'reset'; token: number };
export const initialResourceBookState: ResourceBookState = {
view: 'main',
category: null,
pendingCategory: null,
phase: 'idle',
token: 0,
};
export function resourceBookReducer(
state: ResourceBookState,
action: ResourceBookAction,
): ResourceBookState {
switch (action.type) {
case 'open-category': {
if (
state.view === 'child' &&
state.category === action.category &&
state.phase === 'idle'
) {
return state;
}
if (state.view === 'child') {
return {
...state,
pendingCategory: action.category,
phase: 'returning',
token: action.token,
};
}
return {
...state,
category: null,
pendingCategory: action.category,
phase: 'entering',
token: action.token,
};
}
case 'commit-enter': {
if (state.phase !== 'entering' || state.token !== action.token) {
return state;
}
const category = state.pendingCategory;
if (!category) {
return state;
}
return {
...state,
view: 'child',
category,
pendingCategory: null,
};
}
case 'finish-transition':
return state.token === action.token ? { ...state, phase: 'idle' } : state;
case 'return-to-main':
if (state.view !== 'child' || state.phase === 'returning') {
return state;
}
return {
...state,
phase: 'returning',
pendingCategory: null,
token: action.token,
};
case 'commit-return':
return state.phase === 'returning' && state.token === action.token
? state.pendingCategory
? {
...state,
view: 'main',
category: null,
phase: 'entering',
}
: {
...state,
view: 'main',
category: null,
pendingCategory: null,
// Keep the return leg in the animated phase while the scene
// viewport transitions from the child viewport back to the
// main viewport. The controller dispatches finish-transition
// after the same animation window used by child entry.
phase: 'entering',
}
: state;
case 'reset':
return {
...initialResourceBookState,
token: action.token,
};
default:
return state;
}
}
export function resourceBookSceneCategory(state: ResourceBookState) {
return state.view === 'child' ? state.category : state.pendingCategory;
}
export function resourceBookShowsOverviewCards(state: ResourceBookState) {
// The overview is the steady state of the main canvas as well as the
// return/enter handoff. Keeping it visible outside the entering phase is
// what makes category titles and the shared preview cards present on the
// initial main canvas instead of only during a transition.
return state.view === 'main' || state.phase === 'returning';
}
export function resourceBookShowsChildCards(state: ResourceBookState) {
return state.view === 'child';
}
export function resourceBookIsReturningCategory(
state: ResourceBookState,
category: ResourceBookCategory,
) {
return state.phase === 'returning' && state.category === category;
}
@@ -0,0 +1,119 @@
import {
type CanvasViewport,
MAX_SCALE,
MIN_SCALE,
resolveViewportFromWheel,
} from '@genarrative/image-canvas-core';
export const DEFAULT_RESOURCE_BOOK_VIEWPORT: CanvasViewport = {
x: 0,
y: 0,
scale: 1,
};
/**
* Child resource books are infinite canvases. Do not shrink their initial
* card world below its logical 1:1 size just to fit every card at once; that
* makes the canvas look tiny and gives the user no useful pan space.
*/
export function keepResourceBookViewportAtReadableScale({
viewport,
bounds,
canvasSize,
padding = 16,
}: {
viewport: CanvasViewport;
bounds: { x: number; y: number; width: number; height: number };
canvasSize: { width: number; height: number };
padding?: number;
}): CanvasViewport {
const current = normalizeResourceBookViewport(viewport);
if (current.scale >= 1) {
return current;
}
const inset = Math.max(0, padding);
const availableWidth = Math.max(1, canvasSize.width - inset * 2);
const availableHeight = Math.max(1, canvasSize.height - inset * 2);
return {
scale: 1,
x: inset + availableWidth / 2 - (bounds.x + bounds.width / 2),
y: inset + availableHeight / 2 - (bounds.y + bounds.height / 2),
};
}
export function normalizeResourceBookViewport(
viewport: CanvasViewport | undefined,
): CanvasViewport {
return {
x: Number.isFinite(viewport?.x) ? viewport!.x : 0,
y: Number.isFinite(viewport?.y) ? viewport!.y : 0,
scale: Math.min(
MAX_SCALE,
Math.max(
MIN_SCALE,
Number.isFinite(viewport?.scale) ? viewport!.scale : 1,
),
),
};
}
export function zoomResourceBookViewport(
viewport: CanvasViewport,
factor: number,
anchor: { x: number; y: number },
): CanvasViewport {
const current = normalizeResourceBookViewport(viewport);
const nextScale = Math.min(
MAX_SCALE,
Math.max(MIN_SCALE, current.scale * factor),
);
if (nextScale === current.scale || !Number.isFinite(nextScale)) {
return current;
}
const ratio = nextScale / current.scale;
return {
scale: nextScale,
x: anchor.x - (anchor.x - current.x) * ratio,
y: anchor.y - (anchor.y - current.y) * ratio,
};
}
export function panResourceBookViewport(
viewport: CanvasViewport,
delta: { x: number; y: number },
): CanvasViewport {
const current = normalizeResourceBookViewport(viewport);
return {
...current,
x: current.x + (Number.isFinite(delta.x) ? delta.x : 0),
y: current.y + (Number.isFinite(delta.y) ? delta.y : 0),
};
}
export function resolveResourceBookWheelViewport({
viewport,
deltaX,
deltaY,
shiftKey,
ctrlKey,
metaKey,
screenPoint,
}: {
viewport: CanvasViewport;
deltaX: number;
deltaY: number;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
screenPoint: { x: number; y: number };
}) {
return resolveViewportFromWheel({
viewport: normalizeResourceBookViewport(viewport),
deltaX,
deltaY,
shiftKey,
ctrlKey,
metaKey,
screenPoint,
});
}
@@ -0,0 +1,94 @@
export type ResourceCanvasPageWheelState<TCategory> = {
activeCategory: TCategory | null;
accumulatedDeltaY: number;
lastSwitchAt: number;
pendingStepCount: number;
pendingTimerId: number | null;
};
export function createResourceCanvasPageWheelState<
TCategory,
>(): ResourceCanvasPageWheelState<TCategory> {
return {
activeCategory: null,
accumulatedDeltaY: 0,
lastSwitchAt: 0,
pendingStepCount: 0,
pendingTimerId: null,
};
}
export function resetResourceCanvasPageWheel<TCategory>(
state: ResourceCanvasPageWheelState<TCategory>,
activeCategory: TCategory | null,
) {
state.accumulatedDeltaY = 0;
state.lastSwitchAt = 0;
state.pendingStepCount = 0;
state.pendingTimerId = null;
state.activeCategory = activeCategory;
}
export function queueResourceCanvasPageWheel<TCategory>(
state: ResourceCanvasPageWheelState<TCategory>,
input: {
deltaY: number;
deltaMode: number;
canvasHeight: number;
maxQueuedSteps: number;
},
) {
if (!Number.isFinite(input.deltaY) || input.deltaY === 0) {
return;
}
const canvasHeight = Math.max(input.canvasHeight, 1);
const deltaModeMultiplier =
input.deltaMode === 1 ? 16 : input.deltaMode === 2 ? canvasHeight : 1;
const deltaPerStep =
input.deltaMode === 1 ? 48 : input.deltaMode === 2 ? canvasHeight : 120;
const normalizedDeltaY = input.deltaY * deltaModeMultiplier;
const direction = Math.sign(normalizedDeltaY);
if (
(state.accumulatedDeltaY !== 0 &&
direction !== Math.sign(state.accumulatedDeltaY)) ||
(state.pendingStepCount !== 0 &&
direction !== Math.sign(state.pendingStepCount))
) {
state.accumulatedDeltaY = 0;
state.pendingStepCount = 0;
}
state.accumulatedDeltaY += normalizedDeltaY;
if (Math.abs(state.accumulatedDeltaY) < 20) {
return;
}
const stepCount = Math.max(
1,
Math.floor(Math.abs(state.accumulatedDeltaY) / deltaPerStep),
);
state.accumulatedDeltaY = 0;
state.pendingStepCount = Math.max(
-input.maxQueuedSteps,
Math.min(
input.maxQueuedSteps,
state.pendingStepCount + direction * stepCount,
),
);
}
export function takeResourceCanvasPageWheelStep<TCategory>(
state: ResourceCanvasPageWheelState<TCategory>,
now: number,
switchIntervalMs = 180,
) {
if (state.pendingStepCount === 0) {
return { stepOffset: 0, waitMs: 0 };
}
const waitMs = switchIntervalMs - (now - state.lastSwitchAt);
if (waitMs > 0) {
return { stepOffset: 0, waitMs };
}
const stepOffset = state.pendingStepCount;
state.pendingStepCount = 0;
state.lastSwitchAt = now;
return { stepOffset, waitMs: 0 };
}
@@ -61,6 +61,32 @@ function ApprovedGddStartHarness() {
);
}
async function openResourceBookCategory(label: string) {
const categoryByLabel: Record<string, string> = {
: 'document',
: 'art',
: 'audio',
: 'version',
: 'code',
};
const category = categoryByLabel[label];
const outline = await screen.findByLabelText('资源栏目大纲');
fireEvent.click(
within(outline).getByRole('button', { name: new RegExp(label) }),
);
await waitFor(() => {
const manager = document.querySelector('[data-resource-book-view="child"]');
expect(manager).not.toBeNull();
if (category) {
expect(
manager?.querySelector(
`.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${category}"]`,
),
).not.toBeNull();
}
});
}
export function registerClientHomeTests() {
it('anchors the empty home input placeholder to the editor while the page scrolls', () => {
renderLauncherAt('/?launcher');
@@ -224,15 +250,14 @@ export function registerClientHomeTests() {
fireEvent.click(screen.getByRole('button', { name: '同步最新 manifest' }));
await openResourceBookCategory('美术资源');
expect(
await screen.findByRole('button', { name: /live-hero\.png/ }),
).not.toBeNull();
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /^项目版本/,
}),
);
expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull();
await openResourceBookCategory('项目版本');
expect(
await screen.findByRole('button', { name: /版本 1/ }),
).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
@@ -395,6 +420,7 @@ export function registerClientHomeTests() {
runtimeHarness.emitManifestInvalidated('art-asset-plan');
});
await openResourceBookCategory('美术资源');
expect(
await screen.findByRole(
'button',
@@ -402,12 +428,10 @@ export function registerClientHomeTests() {
{ timeout: 5_000 },
),
).not.toBeNull();
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /^项目版本/,
}),
);
expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull();
await openResourceBookCategory('项目版本');
expect(
await screen.findByRole('button', { name: /版本 1/ }),
).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
expect(
@@ -574,6 +598,7 @@ export function registerClientHomeTests() {
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
pickProjectFromLauncher(secondProjectPath);
await openResourceBookCategory('美术资源');
expect(
await screen.findByRole('button', { name: /second\.png/ }),
).not.toBeNull();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import {
createResourceBookTransitionController,
type ResourceBookTransitionClock,
} from '../src/view/project-development/resourceBookController';
function createClock() {
let nextId = 1;
const timers = new Map<number, () => void>();
const frames = new Map<number, () => void>();
const clock: ResourceBookTransitionClock = {
setTimeout(callback) {
const id = nextId++;
timers.set(id, callback);
return id;
},
clearTimeout(id) {
timers.delete(id);
},
requestAnimationFrame(callback) {
const id = nextId++;
frames.set(id, callback);
return id;
},
cancelAnimationFrame(id) {
frames.delete(id);
},
};
return {
clock,
runTimer() {
const callback = timers.values().next().value as (() => void) | undefined;
if (callback) {
callback();
}
},
runFrame() {
const callback = frames.values().next().value as (() => void) | undefined;
if (callback) {
callback();
}
},
};
}
describe('resource book transition controller', () => {
it('invalidates delayed callbacks when a newer transition begins', () => {
const fake = createClock();
const controller = createResourceBookTransitionController(fake.clock);
const firstToken = controller.begin();
const calls: number[] = [];
controller.schedule((token) => calls.push(token), 320, firstToken);
const secondToken = controller.begin();
fake.runTimer();
expect(calls).toEqual([]);
expect(controller.isCurrent(secondToken)).toBe(true);
});
it('cancels a pending frame on disposal', () => {
const fake = createClock();
const controller = createResourceBookTransitionController(fake.clock);
const token = controller.begin();
const calls: number[] = [];
controller.requestFrame((frameToken) => calls.push(frameToken), token);
controller.dispose();
fake.runFrame();
expect(calls).toEqual([]);
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import {
initialResourceBookState,
resourceBookReducer,
resourceBookSceneCategory,
resourceBookShowsOverviewCards,
} from '../src/view/project-development/resourceBookModel';
describe('resource book state machine', () => {
it('keeps overview titles and preview cards visible on the steady main canvas', () => {
expect(resourceBookShowsOverviewCards(initialResourceBookState)).toBe(true);
});
it('keeps main view free of real cards until the enter frame commits', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
expect(entering).toMatchObject({
view: 'main',
category: null,
pendingCategory: 'document',
phase: 'entering',
token: 1,
});
expect(resourceBookSceneCategory(entering)).toBe('document');
const child = resourceBookReducer(entering, {
type: 'commit-enter',
token: 1,
});
expect(child).toMatchObject({
view: 'child',
category: 'document',
pendingCategory: null,
phase: 'entering',
});
});
it('retains the requested destination across a return leg', () => {
const current = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
const child = resourceBookReducer(current, {
type: 'commit-enter',
token: 1,
});
const switching = resourceBookReducer(child, {
type: 'open-category',
category: 'art',
token: 2,
});
expect(switching).toMatchObject({
view: 'child',
category: 'document',
pendingCategory: 'art',
phase: 'returning',
});
const overview = resourceBookReducer(switching, {
type: 'commit-return',
token: 2,
});
expect(overview).toMatchObject({
view: 'main',
category: null,
pendingCategory: 'art',
phase: 'entering',
});
expect(resourceBookSceneCategory(overview)).toBe('art');
});
it('ignores stale transition callbacks', () => {
const entering = resourceBookReducer(initialResourceBookState, {
type: 'open-category',
category: 'document',
token: 1,
});
const switched = resourceBookReducer(entering, {
type: 'open-category',
category: 'art',
token: 2,
});
expect(
resourceBookReducer(switched, { type: 'commit-enter', token: 1 }),
).toEqual(switched);
expect(
resourceBookReducer(switched, { type: 'finish-transition', token: 1 }),
).toEqual(switched);
});
});
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
keepResourceBookViewportAtReadableScale,
panResourceBookViewport,
} from '../src/view/project-development/resourceBookViewport';
describe('resource book viewport', () => {
it('keeps an initially fitted child canvas readable instead of shrinking it below 1:1', () => {
expect(
keepResourceBookViewportAtReadableScale({
viewport: { x: 120, y: 80, scale: 0.42 },
bounds: { x: 0, y: 0, width: 1_600, height: 1_000 },
canvasSize: { width: 900, height: 640 },
padding: 16,
}),
).toEqual({
x: -350,
y: -180,
scale: 1,
});
});
it('allows panning beyond the content bounds in both directions', () => {
expect(
panResourceBookViewport(
{ x: 0, y: 0, scale: 1 },
{ x: -4_000, y: 3_000 },
),
).toEqual({ x: -4_000, y: 3_000, scale: 1 });
});
});
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import {
createResourceCanvasPageWheelState,
queueResourceCanvasPageWheel,
resetResourceCanvasPageWheel,
takeResourceCanvasPageWheelStep,
} from '../src/view/project-development/resourceCanvasController';
describe('resource canvas page wheel controller', () => {
it('keeps the 20px threshold and converts wheel deltas into page steps', () => {
const state = createResourceCanvasPageWheelState<'document'>();
queueResourceCanvasPageWheel(state, {
deltaY: 10,
deltaMode: 0,
canvasHeight: 600,
maxQueuedSteps: 3,
});
expect(state.pendingStepCount).toBe(0);
queueResourceCanvasPageWheel(state, {
deltaY: 120,
deltaMode: 0,
canvasHeight: 600,
maxQueuedSteps: 3,
});
expect(takeResourceCanvasPageWheelStep(state, 1_000)).toMatchObject({
stepOffset: 1,
waitMs: 0,
});
});
it('reverses and clamps queued steps without leaking the old direction', () => {
const state = createResourceCanvasPageWheelState<'document'>();
queueResourceCanvasPageWheel(state, {
deltaY: 480,
deltaMode: 0,
canvasHeight: 600,
maxQueuedSteps: 2,
});
queueResourceCanvasPageWheel(state, {
deltaY: -120,
deltaMode: 0,
canvasHeight: 600,
maxQueuedSteps: 2,
});
expect(state.pendingStepCount).toBe(-1);
resetResourceCanvasPageWheel(state, 'document');
expect(state).toMatchObject({
activeCategory: 'document',
accumulatedDeltaY: 0,
pendingStepCount: 0,
pendingTimerId: null,
});
});
it('honors the 180ms switch interval for queued steps', () => {
const state = createResourceCanvasPageWheelState<'document'>();
state.pendingStepCount = 1;
state.lastSwitchAt = 900;
expect(takeResourceCanvasPageWheelStep(state, 1_000)).toEqual({
stepOffset: 0,
waitMs: 80,
});
expect(takeResourceCanvasPageWheelStep(state, 1_080)).toEqual({
stepOffset: 1,
waitMs: 0,
});
});
});
@@ -1,5 +1,33 @@
# Game Agent 资源自由画板与快速编辑
## 2026-09-04 动画回归排查与重构收敛方案
最近一组资源画本提交(`f339b383f``019b1761f``f883d4bf2``069248b29``820fbfd98`)把主画布、子画布、真实资源卡、分页画布和滚轮转发逐步叠加在同一个页面组件中,造成状态、坐标和副作用互相耦合。本阶段先完成低风险收敛:
- 真实 `ResourceCard` 只保留一棵由 `ResourceBookScene` 管理的节点树;主画布缩略卡只负责入口展示,不再复制真实卡片或预览。
- 修复主画布稳定 `main/idle` 状态误把标题和共享预览卡隐藏的问题;主画布与返回中的 overview 统一保持可见,只有进入子画布后才切换为活动栏目。
- 转场 timer、`requestAnimationFrame` 和项目切换绑定同一个 token;新目标、切项目或卸载时统一失效旧回调,避免旧动画写入新栏目。
- 从大纲或“下一页”进入时使用已测量的缩略卡布局作为过渡起点;子画布互切先同步逻辑目标栏目,再按既有 320ms/520ms 节奏完成返回与进入动画。
- 主画布 viewport 与子画布 viewport 分离;普通滚轮直接复用分页画布 handler,Ctrl/Meta 滚轮只更新当前 viewport,不再向隐藏 DOM 重新派发 `WheelEvent`
- 转场阶段由 `ResourceBookScene` 统一承载当前 viewport:主画布缩放后的背景、标题、预览卡,以及进入/返回子画布时的目标缩放,都在同一条 `transform` 动画链路中完成,避免缩放后卡片和背景使用不同坐标系。
- 依赖图尚未完成读取时不挂载真实资源卡;失败时允许使用明确的 fallback 快照,避免预览和布局在未确定的图上提前启动。
重构按三个边界推进,不改变当前动画视觉参数:
1. 已抽离 `resourceBookModel``resourceBookLayout``resourceBookViewport` 纯模块,以 reducer 管理 `main / child / entering / returning`,组件只订阅投影并渲染。
2. 已抽离 `resourceBookController``resourceCanvasController`,由 tokenized transition controller 管理 timer/RAF,由分页 controller 管理滚轮阈值、方向、节流和重置;主画布、子画布和分页画布不再通过隐藏 DOM 转发推进状态。
3. 已删除历史资源画本动画规则和重复 hover 选择器,并为快速切换、项目切换、origin 解析、viewport 隔离、唯一预览观察和滚轮状态补充回归测试。
### 当前验证与未收敛项(2026-09-04)
`npm --prefix apps/ai-game-creator-shell run typecheck``npm run check:encoding``git diff --check` 和相关 ESLint 检查已通过。`apps/ai-game-creator-shell/tests/appSurface.test.ts` 已恢复为 `384 passed / 0 failed`,资源画本模型与 controller 测试合计 `9 passed / 0 failed`;另补充主画布稳定态标题与共享预览卡回归场景。
测试仍会输出既有的 React `act(...)` 提示,以及 jsdom 未实现 `HTMLMediaElement.prototype.pause` 的 stderr;这些是测试环境警告,不影响断言结果,也未新增生产兼容层。
### 2026-09-05 子画布无限平移与初始可读比例
子画布改为固定视口加可无限平移的 world 层:视口本身保持 `overflow: hidden`,平移和缩放只作用于 `.game-resource-book-scene-world`,不再让内容容器的边界限制拖动范围。首次 fit(包括隐藏分页画布提前完成的 fit)统一保证比例不低于 `1:1`,避免子画布展开后卡片缩成不可读的小块;用户后续主动缩放或平移的视口仍按每个分类独立保留。新增 viewport 回归测试覆盖初始可读比例和超出内容边界双向平移。
## 2026-09-04 资源画本 UI 阶段补充
资源工作台当前产品方向调整为“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略卡片,不直接展开真实资源内容;缩略卡片内部按资源类型显示有限层叠卡片,最多展示三层,更多资源以虚化卡片提示。进入子画布时使用共享元素转场:标题栏和资源卡片沿用同一组 DOM 元素,从缩略卡片的原始位置移动、展开到子画布中的目标位置,不允许先播放独立放大动画再硬切另一套内容;主画布预览和子画布展开态必须复用同一套卡片视觉与标题栏结构。进入子画布时在标题栏提供缩回主画布的缩放按钮;从一个子画布切换到另一个子画布时必须先完成缩回主画布,再进入目标子画布。