重构资源画本画布与转场状态
统一主画布与子画布的可见渲染层和 viewport 状态管理。 将子画布改为固定视口加无限平移 world,并保证首次 fit 的可读比例。 补充转场、滚轮、viewport 和资源画本回归测试,更新技术方案文档。
This commit is contained in:
@@ -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 元素,从缩略卡片的原始位置移动、展开到子画布中的目标位置,不允许先播放独立放大动画再硬切另一套内容;主画布预览和子画布展开态必须复用同一套卡片视觉与标题栏结构。进入子画布时在标题栏提供缩回主画布的缩放按钮;从一个子画布切换到另一个子画布时必须先完成缩回主画布,再进入目标子画布。
|
||||
|
||||
Reference in New Issue
Block a user