diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs index 54630f25e..3cf87fe77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs @@ -50,6 +50,7 @@ pub(crate) struct ProjectResourceConnectionIndex { pub(crate) struct ProjectResourceProducerAssignment { pub resource_id: String, pub task_id: String, + pub dependency_depth: u32, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -77,6 +78,7 @@ struct DirectedEdge { struct CycleAnalysis { cyclic_node_ids: BTreeSet, cyclic_edge_ids: BTreeSet, + component_by_node: BTreeMap, } #[derive(Debug, Default)] @@ -185,9 +187,68 @@ fn analyze_directed_cycles<'a>( result.cyclic_edge_ids.insert(edge.id.clone()); } } + result.component_by_node = component_by_node; result } +fn dependency_depth_by_node( + analysis: &CycleAnalysis, + edges: &[DirectedEdge], +) -> BTreeMap { + let component_count = analysis + .component_by_node + .values() + .copied() + .max() + .map_or(0, |max_component| max_component + 1); + let mut outgoing = vec![BTreeSet::::new(); component_count]; + let mut indegree = vec![0usize; component_count]; + for edge in edges { + let Some(&source_component) = analysis.component_by_node.get(&edge.source_id) else { + continue; + }; + let Some(&target_component) = analysis.component_by_node.get(&edge.target_id) else { + continue; + }; + if source_component != target_component + && outgoing[source_component].insert(target_component) + { + indegree[target_component] += 1; + } + } + + let mut ready = indegree + .iter() + .enumerate() + .filter_map(|(component, degree)| (*degree == 0).then_some(component)) + .collect::>(); + let mut depth_by_component = vec![0u32; component_count]; + while let Some(component) = ready.pop_first() { + for &target in &outgoing[component] { + depth_by_component[target] = + depth_by_component[target].max(depth_by_component[component].saturating_add(1)); + indegree[target] -= 1; + if indegree[target] == 0 { + ready.insert(target); + } + } + } + + analysis + .component_by_node + .iter() + .map(|(node_id, component)| { + ( + node_id.clone(), + depth_by_component + .get(*component) + .copied() + .unwrap_or_default(), + ) + }) + .collect() +} + fn audit_asset_producers( records: &[serde_json::Value], task_ids: &BTreeSet, @@ -403,6 +464,7 @@ pub(crate) fn build_project_resource_graph( }) .collect::>(); let task_cycles = analyze_directed_cycles(task_by_id.keys(), &task_dependency_edges); + let task_dependency_depths = dependency_depth_by_node(&task_cycles, &task_dependency_edges); let task_flows = task_dependency_edges .iter() .filter_map(|edge| { @@ -476,6 +538,10 @@ pub(crate) fn build_project_resource_graph( .into_iter() .map(|(resource_id, task_id)| ProjectResourceProducerAssignment { resource_id, + dependency_depth: task_dependency_depths + .get(&task_id) + .copied() + .unwrap_or_default(), task_id, }) .collect(), @@ -610,6 +676,14 @@ mod tests { assert_eq!(graph.task_flows.len(), 1); assert_eq!(graph.task_flows[0].source_task_id, "art-director"); assert_eq!(graph.task_flows[0].target_task_id, "design-foundation"); + assert_eq!( + graph + .producer_assignments + .iter() + .map(|assignment| (assignment.resource_id.as_str(), assignment.dependency_depth,)) + .collect::>(), + BTreeMap::from([("asset:spec", 0), ("asset:ui", 1)]), + ); assert!(graph .producer_assignments .iter() @@ -734,5 +808,46 @@ mod tests { .connection_index .iter() .all(|index| index.task_flow_ids.len() <= 2)); + assert_eq!( + graph + .producer_assignments + .iter() + .find(|assignment| assignment.resource_id == "resource:4095") + .map(|assignment| assignment.dependency_depth), + Some(4095), + ); + } + + #[test] + fn dependency_depth_collapses_cycles_before_following_downstream_tasks() { + let graph = build_project_resource_graph( + &manifest( + vec![ + task("source", &[]), + task("cycle-a", &["source", "cycle-b"]), + task("cycle-b", &["cycle-a"]), + task("target", &["cycle-b"]), + ], + Vec::new(), + ), + vec![ + resource("source-resource", None, Some("source")), + resource("cycle-a-resource", None, Some("cycle-a")), + resource("cycle-b-resource", None, Some("cycle-b")), + resource("target-resource", None, Some("target")), + ], + &[], + false, + ); + + let depths = graph + .producer_assignments + .iter() + .map(|assignment| (assignment.resource_id.as_str(), assignment.dependency_depth)) + .collect::>(); + assert_eq!(depths["source-resource"], 0); + assert_eq!(depths["cycle-a-resource"], 1); + assert_eq!(depths["cycle-b-resource"], 1); + assert_eq!(depths["target-resource"], 2); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx index 238abba58..19ccd9bbb 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx @@ -1,4 +1,13 @@ -import { useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { + forwardRef, + useCallback, + useId, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { ProjectResourceCanvasPosition, @@ -34,11 +43,21 @@ type RectLookup = { export type ResourceDependencyOverlayProps = { graph: ProjectResourceGraph; positions: readonly ProjectResourceCanvasPosition[]; - dragPreview: (Point & { resourceId: string }) | null; visibleResourceIds: ReadonlySet; selectedResourceId: string | null; }; +export type ResourceDependencyOverlayHandle = { + updateDragPreview: (preview: Point & { resourceId: string }) => void; + clearDragPreview: () => void; +}; + +type TaskFlowPathRefs = { + sourceBranches: Map; + targetBranches: Map; + trunk: SVGPathElement | null; +}; + const SECTION_SELECTOR = '[data-resource-section-plane]'; const TASK_FLOW_HUB_GAP = 20; const CONNECTION_MAX_HANDLE = 180; @@ -200,18 +219,25 @@ function taskFlowGeometry( return { sourceAnchors, targetAnchors, sourceHub, targetHub }; } -export function ResourceDependencyOverlay({ - graph, - positions, - dragPreview, - visibleResourceIds, - selectedResourceId, -}: ResourceDependencyOverlayProps) { +export const ResourceDependencyOverlay = forwardRef< + ResourceDependencyOverlayHandle, + ResourceDependencyOverlayProps +>(function ResourceDependencyOverlay( + { graph, positions, visibleResourceIds, selectedResourceId }, + ref, +) { const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, ''); const overlayRef = useRef(null); const referencePathRefs = useRef(new Map()); - const taskFlowGroupRefs = useRef(new Map()); - const activeDragResourceIdRef = useRef(null); + const taskFlowPathRefs = useRef(new Map()); + const activeDragPreviewRef = useRef< + (Point & { resourceId: string }) | null + >(null); + const graphRef = useRef(graph); + const positionByResourceIdRef = useRef( + new Map(positions.map((position) => [position.resourceId, position])), + ); + const rectByResourceIdRef = useRef>(new Map()); const [sectionOrigins, setSectionOrigins] = useState({}); useLayoutEffect(() => { @@ -290,6 +316,11 @@ export function ResourceDependencyOverlay({ } return result; }, [graph.resourceIds, positions, sectionOrigins, visibleResourceIds]); + graphRef.current = graph; + positionByResourceIdRef.current = new Map( + positions.map((position) => [position.resourceId, position]), + ); + rectByResourceIdRef.current = rectByResourceId; const neighbors = useMemo( () => projectResourceGraphNeighbors(graph, selectedResourceId), @@ -312,13 +343,6 @@ export function ResourceDependencyOverlay({ }${flow.cyclic ? ' is-cyclic' : ''}`; return ( { - if (node) { - taskFlowGroupRefs.current.set(flow.id, node); - } else { - taskFlowGroupRefs.current.delete(flow.id); - } - }} key={flow.id} className={className} data-edge-kind="task-flow" @@ -332,6 +356,22 @@ export function ResourceDependencyOverlay({ }`} {geometry.sourceAnchors.map(({ resourceId, point }) => ( { + let refs = taskFlowPathRefs.current.get(flow.id); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(flow.id, refs); + } + if (node) { + refs.sourceBranches.set(resourceId, node); + } else { + refs.sourceBranches.delete(resourceId); + } + }} key={`source:${resourceId}`} className="game-resource-dependency-branch" data-branch-side="source" @@ -340,11 +380,39 @@ export function ResourceDependencyOverlay({ /> ))} { + let refs = taskFlowPathRefs.current.get(flow.id); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(flow.id, refs); + } + refs.trunk = node; + }} className="game-resource-dependency-trunk" d={connectionPath(geometry.sourceHub, geometry.targetHub)} /> {geometry.targetAnchors.map(({ resourceId, point }) => ( { + let refs = taskFlowPathRefs.current.get(flow.id); + if (!refs) { + refs = { + sourceBranches: new Map(), + targetBranches: new Map(), + trunk: null, + }; + taskFlowPathRefs.current.set(flow.id, refs); + } + if (node) { + refs.targetBranches.set(resourceId, node); + } else { + refs.targetBranches.delete(resourceId); + } + }} key={`target:${resourceId}`} className="game-resource-dependency-branch" data-branch-side="target" @@ -410,49 +478,49 @@ export function ResourceDependencyOverlay({ ); useLayoutEffect(() => { - const affectedResourceIds = new Set(); - if (activeDragResourceIdRef.current) { - affectedResourceIds.add(activeDragResourceIdRef.current); - } - if (dragPreview) { - affectedResourceIds.add(dragPreview.resourceId); - } - activeDragResourceIdRef.current = dragPreview?.resourceId ?? null; - if (affectedResourceIds.size === 0) { - return; + const activeFlowIds = new Set(graph.taskFlows.map((flow) => flow.id)); + for (const flowId of taskFlowPathRefs.current.keys()) { + if (!activeFlowIds.has(flowId)) { + taskFlowPathRefs.current.delete(flowId); + } } + }, [graph.taskFlows]); + + const updateAffectedGeometry = useCallback( + ( + affectedResourceIds: ReadonlySet, + dragPreview: (Point & { resourceId: string }) | null, + ) => { + const currentGraph = graphRef.current; + const currentRects = rectByResourceIdRef.current; const dragBasePosition = dragPreview - ? positions.find( - (position) => position.resourceId === dragPreview.resourceId, - ) + ? positionByResourceIdRef.current.get(dragPreview.resourceId) : undefined; const rectLookup = { get(resourceId: string) { - const rect = rectByResourceId.get(resourceId); + const rect = currentRects.get(resourceId); if (!rect) { return undefined; } return dragPreview?.resourceId === resourceId ? { ...rect, - x: - rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x, - y: - rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y, + x: rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x, + y: rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y, } : rect; }, }; const affectedEdgeIds = new Set(); for (const resourceId of affectedResourceIds) { - const index = graph.connectionIndex.get(resourceId); + const index = currentGraph.connectionIndex.get(resourceId); index?.referenceEdgeIds.forEach((edgeId) => affectedEdgeIds.add(edgeId), ); index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId)); } for (const edgeId of affectedEdgeIds) { - const referenceEdge = graph.referenceEdgeById.get(edgeId); + const referenceEdge = currentGraph.referenceEdgeById.get(edgeId); if (referenceEdge) { const geometry = referenceGeometry(referenceEdge, rectLookup); const path = referencePathRefs.current.get(edgeId); @@ -461,45 +529,29 @@ export function ResourceDependencyOverlay({ } continue; } - const flow = graph.taskFlowById.get(edgeId); - const group = taskFlowGroupRefs.current.get(edgeId); - if (!flow || !group) { + const flow = currentGraph.taskFlowById.get(edgeId); + const paths = taskFlowPathRefs.current.get(edgeId); + if (!flow || !paths) { continue; } const geometry = taskFlowGeometry(flow, rectLookup); if (!geometry) { continue; } - const sourcePathByResourceId = new Map(); - const targetPathByResourceId = new Map(); - group - .querySelectorAll('.game-resource-dependency-branch') - .forEach((path) => { - const resourceId = path.dataset.resourceId; - if (!resourceId) { - return; - } - (path.dataset.branchSide === 'source' - ? sourcePathByResourceId - : targetPathByResourceId - ).set(resourceId, path); - }); geometry.sourceAnchors.forEach(({ resourceId, point }) => { - sourcePathByResourceId + paths.sourceBranches .get(resourceId) ?.setAttribute( 'd', taskFlowBranchPath(point, geometry.sourceHub), ); }); - group - .querySelector('.game-resource-dependency-trunk') - ?.setAttribute( - 'd', - connectionPath(geometry.sourceHub, geometry.targetHub), - ); + paths.trunk?.setAttribute( + 'd', + connectionPath(geometry.sourceHub, geometry.targetHub), + ); geometry.targetAnchors.forEach(({ resourceId, point }) => { - targetPathByResourceId + paths.targetBranches .get(resourceId) ?.setAttribute( 'd', @@ -507,7 +559,32 @@ export function ResourceDependencyOverlay({ ); }); } - }, [dragPreview, graph, positions, rectByResourceId]); + }, + [], + ); + + useImperativeHandle( + ref, + () => ({ + updateDragPreview(preview) { + const affectedResourceIds = new Set(); + if (activeDragPreviewRef.current) { + affectedResourceIds.add(activeDragPreviewRef.current.resourceId); + } + affectedResourceIds.add(preview.resourceId); + activeDragPreviewRef.current = preview; + updateAffectedGeometry(affectedResourceIds, preview); + }, + clearDragPreview() { + const active = activeDragPreviewRef.current; + activeDragPreviewRef.current = null; + if (active) { + updateAffectedGeometry(new Set([active.resourceId]), null); + } + }, + }), + [updateAffectedGeometry], + ); return ( ); -} +}); 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 3d8ed61bc..99870dfe7 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 @@ -18,6 +18,7 @@ import { } from 'lucide-react'; import { type CSSProperties, + memo, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, @@ -34,7 +35,6 @@ import type { GameCreationAppAgentGroup, GameCreationAppManifest, GameCreationAppPreviewState, - GameCreationAppTaskState, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasSection, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; @@ -58,7 +58,10 @@ import { type ProjectResourceGraphNodeInput, type ProjectResourceGraphReadModel, } from './resourceDependencyGraphModel'; -import { ResourceDependencyOverlay } from './ResourceDependencyOverlay'; +import { + ResourceDependencyOverlay, + type ResourceDependencyOverlayHandle, +} from './ResourceDependencyOverlay'; import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout'; type AttachmentResult = { @@ -88,6 +91,11 @@ type ResourceCardDrag = { startX: number; startY: number; moved: boolean; + element: HTMLButtonElement; + plane: HTMLElement | null; + planeBaseWidth: number; + planeBaseHeight: number; + visualGutter: number; }; type ProjectResource = { @@ -282,29 +290,6 @@ function imagePreviewErrorMessage(error: unknown) { return '图片暂时无法读取,请关闭后重试'; } -function taskDependencyDepth( - task: GameCreationAppTaskState, - taskById: Map, - seen = new Set(), -): number { - if (seen.has(task.id) || task.dependencies.length === 0) { - return 0; - } - const nextSeen = new Set(seen).add(task.id); - return ( - 1 + - Math.max( - 0, - ...task.dependencies.map((dependency) => { - const dependencyTask = taskById.get(dependency); - return dependencyTask - ? taskDependencyDepth(dependencyTask, taskById, nextSeen) - : 0; - }), - ) - ); -} - function resourcesFromProject( manifest: GameCreationAppManifest, attachments: AttachmentResult[], @@ -333,7 +318,7 @@ function resourcesFromProject( externalResourceId: null, referenceResourceIds: [], dependencies: task.dependencies, - dependencyDepth: taskDependencyDepth(task, taskById), + dependencyDepth: 0, }); } } @@ -467,7 +452,7 @@ function summarizeAgent( }; } -function ResourceCard({ +const ResourceCard = memo(function ResourceCard({ resource, selected, dragging, @@ -486,8 +471,11 @@ function ResourceCard({ relationState: 'upstream' | 'downstream' | 'both' | null; x: number; y: number; - onSelect: () => void; - onPointerDown: (event: ReactPointerEvent) => void; + onSelect: (resourceId: string) => void; + onPointerDown: ( + event: ReactPointerEvent, + resource: ProjectResource, + ) => void; onPointerMove: (event: ReactPointerEvent) => void; onPointerUp: (event: ReactPointerEvent) => void; onPointerCancel: (event: ReactPointerEvent) => void; @@ -508,8 +496,8 @@ function ResourceCard({ '--resource-y': `${y}px`, } as CSSProperties } - onClick={onSelect} - onPointerDown={onPointerDown} + onClick={() => onSelect(resource.id)} + onPointerDown={(event) => onPointerDown(event, resource)} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} @@ -522,7 +510,7 @@ function ResourceCard({ {resource.path} ); -} +}); export default function ProjectDevelopmentView({ projectName, @@ -549,9 +537,6 @@ export default function ProjectDevelopmentView({ const [draggedResourceId, setDraggedResourceId] = useState( null, ); - const [resourceDragPreview, setResourceDragPreview] = useState< - (Point & { resourceId: string }) | null - >(null); const [resourceDialogPosition, setResourceDialogPosition] = useState(null); const [imagePreview, setImagePreview] = useState({ @@ -563,6 +548,8 @@ export default function ProjectDevelopmentView({ const dockRef = useRef(null); const resourceDialogRef = useRef(null); const resourceCardDragRef = useRef(null); + const resourceDependencyOverlayRef = + useRef(null); const pendingResourceDragPreviewRef = useRef< (Point & { resourceId: string }) | null >(null); @@ -605,14 +592,20 @@ export default function ProjectDevelopmentView({ ); const [resourceGraphState, setResourceGraphState] = useState<{ scopeKey: string; + status: 'idle' | 'loading' | 'ready' | 'failed'; graph: ProjectResourceGraph; - }>({ scopeKey: '', graph: EMPTY_PROJECT_RESOURCE_GRAPH }); + }>({ + scopeKey: '', + status: 'idle', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); useEffect(() => { let cancelled = false; if (sortMode !== 'dependency') { setResourceGraphState({ scopeKey: '', + status: 'idle', graph: EMPTY_PROJECT_RESOURCE_GRAPH, }); return () => { @@ -623,12 +616,18 @@ export default function ProjectDevelopmentView({ if (!invoke) { setResourceGraphState({ scopeKey: resourceGraphScopeKey, + status: 'failed', graph: EMPTY_PROJECT_RESOURCE_GRAPH, }); return () => { cancelled = true; }; } + setResourceGraphState({ + scopeKey: resourceGraphScopeKey, + status: 'loading', + graph: EMPTY_PROJECT_RESOURCE_GRAPH, + }); void invoke( 'read_local_project_resource_graph', { @@ -641,6 +640,7 @@ export default function ProjectDevelopmentView({ if (!cancelled) { setResourceGraphState({ scopeKey: resourceGraphScopeKey, + status: 'ready', graph: normalizeProjectResourceGraph(readModel), }); } @@ -649,6 +649,7 @@ export default function ProjectDevelopmentView({ if (!cancelled) { setResourceGraphState({ scopeKey: resourceGraphScopeKey, + status: 'failed', graph: EMPTY_PROJECT_RESOURCE_GRAPH, }); } @@ -664,10 +665,19 @@ export default function ProjectDevelopmentView({ sortMode, ]); + const resourceGraphScopeMatches = + resourceGraphState.scopeKey === resourceGraphScopeKey; + const resourceGraphReady = + resourceGraphScopeMatches && resourceGraphState.status === 'ready'; const resourceGraph = - resourceGraphState.scopeKey === resourceGraphScopeKey + resourceGraphReady ? resourceGraphState.graph : EMPTY_PROJECT_RESOURCE_GRAPH; + const resourceGraphInitializationReady = + sortMode !== 'dependency' || + (resourceGraphScopeMatches && + (resourceGraphState.status === 'ready' || + resourceGraphState.status === 'failed')); const manifestTaskById = useMemo( () => new Map(manifest.tasks.map((task) => [task.id, task])), [manifest.tasks], @@ -681,18 +691,14 @@ export default function ProjectDevelopmentView({ const producerTask = producerTaskId ? manifestTaskById.get(producerTaskId) : undefined; - return producerTask && producerTaskId !== resource.producerTaskId - ? { - ...resource, - taskTitle: producerTask.title, - producerTaskId, - dependencies: producerTask.dependencies, - dependencyDepth: taskDependencyDepth( - producerTask, - manifestTaskById, - ), - } - : resource; + return { + ...resource, + taskTitle: producerTask?.title ?? resource.taskTitle, + producerTaskId, + dependencies: producerTask?.dependencies ?? resource.dependencies, + dependencyDepth: + resourceGraph.dependencyDepthByResourceId.get(resource.id) ?? 0, + }; }), [manifestTaskById, projectedResources, resourceGraph], ); @@ -706,6 +712,9 @@ export default function ProjectDevelopmentView({ projectId: manifest.projectId, mode: sortMode, resources, + initializationReady: resourceGraphInitializationReady, + rederiveAutomaticPositions: + sortMode === 'dependency' && resourceGraphReady, }); const resourcePositionById = useMemo( () => @@ -817,6 +826,34 @@ export default function ProjectDevelopmentView({ approvalOptions.find((option) => option.id === approvalMode)?.label ?? '严格审批'; + const applyResourceDragPreview = useCallback( + (preview: Point & { resourceId: string }) => { + const drag = resourceCardDragRef.current; + if (!drag || drag.resourceId !== preview.resourceId) { + return; + } + drag.element.style.setProperty('--resource-x', `${preview.x}px`); + drag.element.style.setProperty('--resource-y', `${preview.y}px`); + if (drag.plane) { + drag.plane.style.width = `${Math.max( + drag.planeBaseWidth, + preview.x + + RESOURCE_CANVAS_CARD_WIDTH + + RESOURCE_CANVAS_COLUMN_GAP + + drag.visualGutter, + )}px`; + drag.plane.style.height = `${Math.max( + drag.planeBaseHeight, + preview.y + + RESOURCE_CANVAS_CARD_HEIGHT + + RESOURCE_CANVAS_ROW_GAP, + )}px`; + } + resourceDependencyOverlayRef.current?.updateDragPreview(preview); + }, + [], + ); + const scheduleResourceDragPreview = useCallback( (preview: Point & { resourceId: string }) => { pendingResourceDragPreviewRef.current = preview; @@ -828,11 +865,11 @@ export default function ProjectDevelopmentView({ const pending = pendingResourceDragPreviewRef.current; pendingResourceDragPreviewRef.current = null; if (pending) { - setResourceDragPreview(pending); + applyResourceDragPreview(pending); } }); }, - [], + [applyResourceDragPreview], ); const clearScheduledResourceDragPreview = useCallback(() => { @@ -846,6 +883,7 @@ export default function ProjectDevelopmentView({ useEffect( () => () => { clearScheduledResourceDragPreview(); + resourceDependencyOverlayRef.current?.clearDragPreview(); }, [clearScheduledResourceDragPreview], ); @@ -860,8 +898,8 @@ export default function ProjectDevelopmentView({ setSelectedResourceId(null); resourceCardDragRef.current = null; clearScheduledResourceDragPreview(); + resourceDependencyOverlayRef.current?.clearDragPreview(); setDraggedResourceId(null); - setResourceDragPreview(null); }, [clearScheduledResourceDragPreview, projectPath]); useEffect(() => { @@ -987,81 +1025,135 @@ export default function ProjectDevelopmentView({ return () => window.removeEventListener('resize', clampOnResize); }, [clampResourceDialogPosition, selectedResource]); - function handleResourceCardPointerDown( - event: ReactPointerEvent, - resource: ProjectResource, - ) { - if (event.button !== 0) { + const handleResourceSelect = useCallback((resourceId: string) => { + if (suppressResourceClickRef.current === resourceId) { + suppressResourceClickRef.current = null; return; } - const position = resourcePositionById.get(resource.id); - if (!position || position.section !== resource.category) { - return; - } - clearScheduledResourceDragPreview(); - resourceCardDragRef.current = { - pointerId: event.pointerId, - resourceId: resource.id, - section: resource.category, - startClientX: event.clientX, - startClientY: event.clientY, - startX: position.x, - startY: position.y, - moved: false, - }; - event.currentTarget.setPointerCapture?.(event.pointerId); - } + setSelectedResourceId(resourceId); + }, []); - function handleResourceCardPointerMove( - event: ReactPointerEvent, - ) { - const drag = resourceCardDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - const deltaX = event.clientX - drag.startClientX; - const deltaY = event.clientY - drag.startClientY; - if ( - !drag.moved && - Math.hypot(deltaX, deltaY) < RESOURCE_CANVAS_DRAG_THRESHOLD - ) { - return; - } - drag.moved = true; - setDraggedResourceId(drag.resourceId); - scheduleResourceDragPreview({ - resourceId: drag.resourceId, - x: Math.max(0, drag.startX + deltaX), - y: Math.max(0, drag.startY + deltaY), - }); - event.preventDefault(); - } + const handleResourceCardPointerDown = useCallback( + ( + event: ReactPointerEvent, + resource: ProjectResource, + ) => { + if (event.button !== 0) { + return; + } + const position = resourcePositionById.get(resource.id); + if (!position || position.section !== resource.category) { + return; + } + clearScheduledResourceDragPreview(); + const plane = event.currentTarget.parentElement; + resourceCardDragRef.current = { + pointerId: event.pointerId, + resourceId: resource.id, + section: resource.category, + startClientX: event.clientX, + startClientY: event.clientY, + startX: position.x, + startY: position.y, + moved: false, + element: event.currentTarget, + plane, + planeBaseWidth: + Number.parseFloat(plane?.style.width ?? '') || + plane?.getBoundingClientRect().width || + 0, + planeBaseHeight: + Number.parseFloat(plane?.style.height ?? '') || + plane?.getBoundingClientRect().height || + 0, + visualGutter: + sortMode === 'dependency' ? RESOURCE_DEPENDENCY_VISUAL_GUTTER : 0, + }; + event.currentTarget.setPointerCapture?.(event.pointerId); + }, + [clearScheduledResourceDragPreview, resourcePositionById, sortMode], + ); - function handleResourceCardPointerEnd( - event: ReactPointerEvent, - cancelled: boolean, - ) { - const drag = resourceCardDragRef.current; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - clearScheduledResourceDragPreview(); - resourceCardDragRef.current = null; - if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { - event.currentTarget.releasePointerCapture?.(event.pointerId); - } - if (drag.moved && !cancelled) { - suppressResourceClickRef.current = drag.resourceId; - commitResourcePosition( - drag.resourceId, - drag.section, - Math.max(0, drag.startX + event.clientX - drag.startClientX), - Math.max(0, drag.startY + event.clientY - drag.startClientY), - ); - } - setDraggedResourceId(null); - setResourceDragPreview(null); - } + const handleResourceCardPointerMove = useCallback( + (event: ReactPointerEvent) => { + const drag = resourceCardDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + const deltaX = event.clientX - drag.startClientX; + const deltaY = event.clientY - drag.startClientY; + if ( + !drag.moved && + Math.hypot(deltaX, deltaY) < RESOURCE_CANVAS_DRAG_THRESHOLD + ) { + return; + } + if (!drag.moved) { + drag.moved = true; + setDraggedResourceId(drag.resourceId); + } + scheduleResourceDragPreview({ + resourceId: drag.resourceId, + x: Math.max(0, drag.startX + deltaX), + y: Math.max(0, drag.startY + deltaY), + }); + event.preventDefault(); + }, + [scheduleResourceDragPreview], + ); + + const handleResourceCardPointerEnd = useCallback( + (event: ReactPointerEvent, cancelled: boolean) => { + const drag = resourceCardDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + clearScheduledResourceDragPreview(); + const finalPreview = { + resourceId: drag.resourceId, + x: cancelled + ? drag.startX + : Math.max(0, drag.startX + event.clientX - drag.startClientX), + y: cancelled + ? drag.startY + : Math.max(0, drag.startY + event.clientY - drag.startClientY), + }; + applyResourceDragPreview(finalPreview); + resourceCardDragRef.current = null; + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture?.(event.pointerId); + } + if (drag.moved && !cancelled) { + suppressResourceClickRef.current = drag.resourceId; + commitResourcePosition( + drag.resourceId, + drag.section, + finalPreview.x, + finalPreview.y, + ); + const overlay = resourceDependencyOverlayRef.current; + window.requestAnimationFrame(() => overlay?.clearDragPreview()); + } else { + resourceDependencyOverlayRef.current?.clearDragPreview(); + } + setDraggedResourceId(null); + }, + [ + applyResourceDragPreview, + clearScheduledResourceDragPreview, + commitResourcePosition, + ], + ); + const handleResourceCardPointerUp = useCallback( + (event: ReactPointerEvent) => + handleResourceCardPointerEnd(event, false), + [handleResourceCardPointerEnd], + ); + const handleResourceCardPointerCancel = useCallback( + (event: ReactPointerEvent) => + handleResourceCardPointerEnd(event, true), + [handleResourceCardPointerEnd], + ); function handleResourceDialogPointerDown( event: ReactPointerEvent, @@ -1236,10 +1328,10 @@ export default function ProjectDevelopmentView({
{sortMode === 'dependency' ? ( @@ -1250,36 +1342,13 @@ export default function ProjectDevelopmentView({ const baseExtent = resourceBaseExtentByCategory.get( category, ) ?? { width: 0, height: 0 }; - const dragPosition = resourceDragPreview - ? resourcePositionById.get( - resourceDragPreview.resourceId, - ) - : undefined; - const previewInCategory = - dragPosition?.section === category - ? resourceDragPreview - : null; const extent = { width: - Math.max( - baseExtent.width, - previewInCategory - ? previewInCategory.x + - RESOURCE_CANVAS_CARD_WIDTH + - RESOURCE_CANVAS_COLUMN_GAP - : 0, - ) + + baseExtent.width + (sortMode === 'dependency' ? RESOURCE_DEPENDENCY_VISUAL_GUTTER : 0), - height: Math.max( - baseExtent.height, - previewInCategory - ? previewInCategory.y + - RESOURCE_CANVAS_CARD_HEIGHT + - RESOURCE_CANVAS_ROW_GAP - : 0, - ), + height: baseExtent.height, }; const Icon = categoryIcons[category]; return ( @@ -1311,10 +1380,6 @@ export default function ProjectDevelopmentView({ if (!position) { return null; } - const preview = - resourceDragPreview?.resourceId === resource.id - ? resourceDragPreview - : null; const upstream = sortMode === 'dependency' && selectedResourceNeighbors.upstreamResourceIds.has( @@ -1340,30 +1405,14 @@ export default function ProjectDevelopmentView({ selected={resource.id === selectedResourceId} dragging={resource.id === draggedResourceId} relationState={relationState} - x={preview?.x ?? position.x} - y={preview?.y ?? position.y} - onSelect={() => { - if ( - suppressResourceClickRef.current === - resource.id - ) { - suppressResourceClickRef.current = null; - return; - } - setSelectedResourceId(resource.id); - }} - onPointerDown={(event) => - handleResourceCardPointerDown( - event, - resource, - ) - } + x={position.x} + y={position.y} + onSelect={handleResourceSelect} + onPointerDown={handleResourceCardPointerDown} onPointerMove={handleResourceCardPointerMove} - onPointerUp={(event) => - handleResourceCardPointerEnd(event, false) - } - onPointerCancel={(event) => - handleResourceCardPointerEnd(event, true) + onPointerUp={handleResourceCardPointerUp} + onPointerCancel={ + handleResourceCardPointerCancel } /> ); diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts index ff46e11c5..57d56662f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts @@ -33,6 +33,7 @@ export type ProjectResourceConnectionIndexDto = { export type ProjectResourceProducerAssignment = { resourceId: string; taskId: string; + dependencyDepth: number; }; export type ProjectResourceGraphReadModel = { @@ -62,6 +63,7 @@ export type ProjectResourceGraph = { taskFlowById: ReadonlyMap; connectionIndex: ReadonlyMap; producerTaskIdByResourceId: ReadonlyMap; + dependencyDepthByResourceId: ReadonlyMap; unresolvedReferenceResourceIds: string[]; cyclicResourceIds: ReadonlySet; cyclicTaskIds: ReadonlySet; @@ -76,6 +78,7 @@ export type ProjectResourceGraphNeighbors = { const emptyStringSet: ReadonlySet = new Set(); const emptyStringMap: ReadonlyMap = new Map(); +const emptyNumberMap: ReadonlyMap = new Map(); const emptyConnectionMap: ReadonlyMap = new Map(); const emptyTaskFlowMap: ReadonlyMap = new Map< @@ -102,6 +105,7 @@ export const EMPTY_PROJECT_RESOURCE_GRAPH: ProjectResourceGraph = { taskFlowById: emptyTaskFlowMap, connectionIndex: emptyConnectionMap, producerTaskIdByResourceId: emptyStringMap, + dependencyDepthByResourceId: emptyNumberMap, unresolvedReferenceResourceIds: [], cyclicResourceIds: emptyStringSet, cyclicTaskIds: emptyStringSet, @@ -174,13 +178,23 @@ export function normalizeProjectResourceGraph( ), }); } - const producerTaskIdByResourceId = new Map( - readModel.producerAssignments.flatMap((assignment) => - resourceIds.has(assignment.resourceId) && assignment.taskId - ? [[assignment.resourceId, assignment.taskId] as const] - : [], - ), - ); + const producerTaskIdByResourceId = new Map(); + const dependencyDepthByResourceId = new Map(); + for (const assignment of readModel.producerAssignments) { + if (!resourceIds.has(assignment.resourceId) || !assignment.taskId) { + continue; + } + producerTaskIdByResourceId.set(assignment.resourceId, assignment.taskId); + if ( + Number.isSafeInteger(assignment.dependencyDepth) && + assignment.dependencyDepth >= 0 + ) { + dependencyDepthByResourceId.set( + assignment.resourceId, + assignment.dependencyDepth, + ); + } + } return { resourceIds, @@ -190,6 +204,7 @@ export function normalizeProjectResourceGraph( taskFlowById: new Map(taskFlows.map((flow) => [flow.id, flow])), connectionIndex, producerTaskIdByResourceId, + dependencyDepthByResourceId, unresolvedReferenceResourceIds: uniqueSorted( readModel.unresolvedReferenceResourceIds, ), diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index 745ca2c37..746ee88b8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -81,16 +81,58 @@ function layoutMatchesScope( return layout.projectId === scope.projectId && layout.mode === scope.mode; } +function positionsEqual( + left: ProjectResourceCanvasLayout['positions'], + right: ProjectResourceCanvasLayout['positions'], +) { + return ( + left.length === right.length && + left.every((position, index) => { + const other = right[index]; + return ( + other?.resourceId === position.resourceId && + other.section === position.section && + other.x === position.x && + other.y === position.y && + other.manuallyPlaced === position.manuallyPlaced + ); + }) + ); +} + +function reconcileLayout( + source: ProjectResourceCanvasLayout, + resources: ResourceCanvasItem[], + rederiveAutomaticPositions: boolean, +) { + if (!rederiveAutomaticPositions) { + return reconcileResourceCanvasLayout(source, resources); + } + const manualSource = { + ...source, + positions: source.positions.filter((position) => position.manuallyPlaced), + }; + const reconciled = reconcileResourceCanvasLayout(manualSource, resources); + return { + layout: reconciled.layout, + changed: !positionsEqual(source.positions, reconciled.layout.positions), + }; +} + export function useProjectResourceCanvasLayout({ projectPath, projectId, mode, resources, + initializationReady = true, + rederiveAutomaticPositions = false, }: { projectPath: string; projectId: string; mode: ProjectResourceCanvasLayoutMode; resources: ResourceCanvasItem[]; + initializationReady?: boolean; + rederiveAutomaticPositions?: boolean; }) { const scopeKey = createScopeKey(projectPath, projectId, mode); const resourceSignature = useMemo( @@ -98,13 +140,18 @@ export function useProjectResourceCanvasLayout({ [resources], ); const fallback = useMemo( - () => - reconcileResourceCanvasLayout( - createEmptyResourceCanvasLayout(projectId, mode), - resources, - ).layout, - [mode, projectId, resources], - ); + () => { + const empty = createEmptyResourceCanvasLayout(projectId, mode); + return initializationReady + ? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout + : empty; + }, [ + initializationReady, + mode, + projectId, + rederiveAutomaticPositions, + resources, + ]); const [layout, setLayout] = useState(fallback); const [notice, setNotice] = useState(''); const [saving, setSaving] = useState(false); @@ -151,9 +198,10 @@ export function useProjectResourceCanvasLayout({ if (scope.epoch !== scopeEpoch) { return; } - let next = reconcileResourceCanvasLayout( + let next = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, + rederiveAutomaticPositions, ).layout; for (const intent of writeQueueRef.current) { if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') { @@ -168,7 +216,7 @@ export function useProjectResourceCanvasLayout({ } applyLayout(next); }, - [applyLayout], + [applyLayout, rederiveAutomaticPositions], ); enqueueResourceSyncRef.current = (scopeEpoch, conflictRetries = 0) => { @@ -229,9 +277,10 @@ export function useProjectResourceCanvasLayout({ return; } - const reconciled = reconcileResourceCanvasLayout( + const reconciled = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, + rederiveAutomaticPositions, ); if (intent.kind === 'resources' && !reconciled.changed) { removeWriteIntent(intent); @@ -330,7 +379,11 @@ export function useProjectResourceCanvasLayout({ setNotice('布局已保存'); } if ( - reconcileResourceCanvasLayout(result.layout, resourcesRef.current) + reconcileLayout( + result.layout, + resourcesRef.current, + rederiveAutomaticPositions, + ) .changed ) { enqueueResourceSyncRef.current(currentScope.epoch); @@ -346,9 +399,10 @@ export function useProjectResourceCanvasLayout({ queued.scopeEpoch !== currentScope.epoch || queued.kind !== 'manual', ); - const needsResourceSync = reconcileResourceCanvasLayout( + const needsResourceSync = reconcileLayout( result.layout, resourcesRef.current, + rederiveAutomaticPositions, ).changed; const nextRetry = intent.kind === 'resources' ? intent.conflictRetries + 1 : 0; @@ -435,9 +489,18 @@ export function useProjectResourceCanvasLayout({ writeQueueRef.current = []; activeWriteIntentRef.current = null; redragRequiredScopeEpochRef.current = null; - const initialFallback = reconcileResourceCanvasLayout( - createEmptyResourceCanvasLayout(projectId, mode), + const emptyLayout = createEmptyResourceCanvasLayout(projectId, mode); + if (!initializationReady) { + persistedLayoutRef.current = emptyLayout; + applyLayout(emptyLayout); + setNotice(''); + setSaving(false); + return undefined; + } + const initialFallback = reconcileLayout( + emptyLayout, resourcesRef.current, + rederiveAutomaticPositions, ).layout; persistedLayoutRef.current = initialFallback; applyLayout(initialFallback); @@ -469,7 +532,11 @@ export function useProjectResourceCanvasLayout({ persistedLayoutRef.current = loaded; initializedScopeEpochRef.current = epoch; if ( - reconcileResourceCanvasLayout(loaded, resourcesRef.current).changed + reconcileLayout( + loaded, + resourcesRef.current, + rederiveAutomaticPositions, + ).changed ) { enqueueResourceSyncRef.current(epoch); } @@ -491,10 +558,12 @@ export function useProjectResourceCanvasLayout({ }; }, [ applyLayout, + initializationReady, mode, projectId, projectPath, rebuildOptimisticLayout, + rederiveAutomaticPositions, scopeKey, ]); @@ -502,25 +571,34 @@ export function useProjectResourceCanvasLayout({ const scope = scopeRef.current; if ( scope.key !== scopeKey || + !initializationReady || initializedScopeEpochRef.current !== scope.epoch ) { return; } - const reconciledCurrent = reconcileResourceCanvasLayout( + const reconciledCurrent = reconcileLayout( layoutRef.current, resourcesRef.current, + rederiveAutomaticPositions, ); applyLayout(reconciledCurrent.layout); if ( window.__TAURI__?.core?.invoke && - reconcileResourceCanvasLayout( + reconcileLayout( persistedLayoutRef.current, resourcesRef.current, + rederiveAutomaticPositions, ).changed ) { enqueueResourceSyncRef.current(scope.epoch); } - }, [applyLayout, resourceSignature, scopeKey]); + }, [ + applyLayout, + initializationReady, + rederiveAutomaticPositions, + resourceSignature, + scopeKey, + ]); useEffect(() => { if (!notice) { @@ -544,7 +622,11 @@ export function useProjectResourceCanvasLayout({ y: number, ) => { const scope = scopeRef.current; - if (scope.key !== scopeKey) { + if ( + scope.key !== scopeKey || + !initializationReady || + initializedScopeEpochRef.current !== scope.epoch + ) { return; } const queuedIntent = writeQueueRef.current.find( @@ -580,10 +662,11 @@ export function useProjectResourceCanvasLayout({ setSaving(true); pumpWritesRef.current(); }, - [applyLayout, scopeKey], + [applyLayout, initializationReady, scopeKey], ); const scopeMatches = + initializationReady && scopeRef.current.key === scopeKey && layout.projectId === projectId && layout.mode === mode; diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts index da4df334a..6348de9bf 100644 --- a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -1,5 +1,5 @@ /** @vitest-environment jsdom */ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; @@ -9,7 +9,10 @@ import { type ProjectResourceGraph, type ProjectResourceGraphReadModel, } from '../src/view/project-development/resourceDependencyGraphModel'; -import { ResourceDependencyOverlay } from '../src/view/project-development/ResourceDependencyOverlay'; +import { + ResourceDependencyOverlay, + type ResourceDependencyOverlayHandle, +} from '../src/view/project-development/ResourceDependencyOverlay'; function position( resourceId: string, @@ -103,22 +106,22 @@ function OverlayHarness({ positions, visibleResourceIds, selectedResourceId = null, - dragPreview = null, + overlayRef, }: { graph: ProjectResourceGraph; positions: ProjectResourceCanvasPosition[]; visibleResourceIds: ReadonlySet; selectedResourceId?: string | null; - dragPreview?: { resourceId: string; x: number; y: number } | null; + overlayRef?: React.Ref; }) { return React.createElement( 'div', null, React.createElement('div', { 'data-resource-section-plane': 'art' }), React.createElement(ResourceDependencyOverlay, { + ref: overlayRef, graph, positions, - dragPreview, visibleResourceIds, selectedResourceId, }), @@ -130,14 +133,14 @@ function overlayView( positions: ProjectResourceCanvasPosition[], visibleResourceIds: ReadonlySet, selectedResourceId: string | null = null, - dragPreview: { resourceId: string; x: number; y: number } | null = null, + overlayRef?: React.Ref, ) { return React.createElement(OverlayHarness, { graph, positions, visibleResourceIds, selectedResourceId, - dragPreview, + overlayRef, }); } @@ -181,8 +184,15 @@ describe('ResourceDependencyOverlay', () => { position('source:one', 0, 0), position('target:one', 240, 0), ]; + const overlayRef = React.createRef(); const view = render( - overlayView(graph, positions, new Set(['source:one', 'target:one'])), + overlayView( + graph, + positions, + new Set(['source:one', 'target:one']), + null, + overlayRef, + ), ); const overlay = await screen.findByTestId('resource-dependency-overlay'); const firstPath = await waitFor(() => { @@ -193,14 +203,12 @@ describe('ResourceDependencyOverlay', () => { return path?.getAttribute('d'); }); - view.rerender( - overlayView( - graph, - positions, - new Set(['source:one', 'target:one']), - null, - { resourceId: 'source:one', x: 80, y: 40 }, - ), + act(() => + overlayRef.current?.updateDragPreview({ + resourceId: 'source:one', + x: 80, + y: 40, + }), ); await waitFor(() => expect( @@ -242,7 +250,8 @@ describe('ResourceDependencyOverlay', () => { position('target:one', 240, 0), ]; const visible = new Set(['source:one', 'target:one']); - const view = render(overlayView(graph, positions, visible)); + const overlayRef = React.createRef(); + render(overlayView(graph, positions, visible, null, overlayRef)); const overlay = await screen.findByTestId('resource-dependency-overlay'); const selector = '[data-edge-kind="asset-reference"]'; await waitFor(() => @@ -251,8 +260,8 @@ describe('ResourceDependencyOverlay', () => { ), ); - view.rerender( - overlayView(graph, positions, visible, null, { + act(() => + overlayRef.current?.updateDragPreview({ resourceId: 'source:one', x: 80, y: 40, @@ -375,7 +384,8 @@ describe('ResourceDependencyOverlay', () => { position('resource:2049', 440, 0), ]; const visible = new Set(positions.map(({ resourceId }) => resourceId)); - const view = render(overlayView(graph, positions, visible)); + const overlayRef = React.createRef(); + render(overlayView(graph, positions, visible, null, overlayRef)); const overlay = await screen.findByTestId('resource-dependency-overlay'); await waitFor(() => expect( @@ -387,8 +397,8 @@ describe('ResourceDependencyOverlay', () => { 'setAttribute', ); try { - view.rerender( - overlayView(graph, positions, visible, null, { + act(() => + overlayRef.current?.updateDragPreview({ resourceId: 'resource:2048', x: 260, y: 32, @@ -419,23 +429,24 @@ describe('ResourceDependencyOverlay', () => { const positions = Array.from(graph.resourceIds).map( (resourceId, index) => position(resourceId, index * 200, 0), ); + const overlayRef = React.createRef(); const view = render( overlayView( graph, positions, new Set(graph.resourceIds), + null, + overlayRef, ), ); expect(observe).toHaveBeenCalledTimes(2); - view.rerender( - overlayView( - graph, - positions, - new Set(graph.resourceIds), - null, - { resourceId: 'source:one', x: 32, y: 24 }, - ), + act(() => + overlayRef.current?.updateDragPreview({ + resourceId: 'source:one', + x: 32, + y: 24, + }), ); expect(observe).toHaveBeenCalledTimes(2); view.unmount(); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 733c41033..96bee78ed 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -801,7 +801,9 @@ export function registerHomeProjectCreationTests() { expect(screen.getByLabelText('项目总控消息').textContent).toContain( '第一行\n第二行\n第三行', ); - expect(screen.getByText('assets/uploads/reference.png')).not.toBeNull(); + expect( + await screen.findByText('assets/uploads/reference.png'), + ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('init_local_game_project', { projectPath: '/tmp/home-created-game', projectId: 'local-project-draft', 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 7d70a4a2d..5997a0fab 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 @@ -56,6 +56,40 @@ import { within, } from './harness'; +function resourceGraphForInputs(args?: Record) { + const resources = + (args?.resources as + | Array<{ resourceId: string; producerTaskId: string | null }> + | undefined) ?? []; + return { + resourceIds: resources.map(({ resourceId }) => resourceId), + referenceEdges: [], + taskFlows: [], + connectionIndex: resources.map(({ resourceId }) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: resources.flatMap((resource) => + resource.producerTaskId + ? [ + { + resourceId: resource.resourceId, + taskId: resource.producerTaskId, + dependencyDepth: 0, + }, + ] + : [], + ), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + function gameChatRuntimeEvent({ agentId = 'project-supervisor', taskId = agentId, @@ -869,10 +903,12 @@ export function registerProjectWorkbenchFoundationTests() { { resourceId: 'asset:dependency-spec', taskId: 'art-director', + dependencyDepth: 0, }, { resourceId: 'asset:dependency-ui', taskId: 'design-foundation', + dependencyDepth: 1, }, ], unresolvedReferenceResourceIds: [], @@ -1025,6 +1061,204 @@ export function registerProjectWorkbenchFoundationTests() { expect(nextOverlay.querySelector('[data-edge-kind]')).toBeNull(); }); + it('waits for the scoped resource graph before initializing dependency layout', async () => { + const projectId = 'workbench-delayed-resource-graph'; + const projectPath = '/tmp/workbench-delayed-resource-graph'; + const manifest = createGameCreationAppManifest(projectId, '延迟依赖图测试'); + const agentResults = [ + { + agentId: 'design-foundation', + runId: 'delayed-graph-run', + label: '玩法策划 Agent', + title: '延迟依赖图回执', + content: '图就绪后再初始化布局', + updatedAt: 1, + }, + ]; + let resolveGraph: (() => void) | null = null; + let layoutReads = 0; + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return await new Promise((resolve) => { + resolveGraph = () => resolve(resourceGraphForInputs(args)); + }); + } + if (command === 'read_local_project_resource_canvas_layout') { + layoutReads += 1; + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: 'dependency', + revision: layoutRevision, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: 'dependency', + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: '延迟依赖图测试', + projectPath, + manifest, + attachments: [], + agentResults, + supervisor: React.createElement('div', null, '项目总控'), + }), + ); + + await waitFor(() => expect(resolveGraph).not.toBeNull()); + expect(layoutReads).toBe(0); + expect(screen.queryByText('延迟依赖图回执')).toBeNull(); + + await act(async () => { + resolveGraph?.(); + await Promise.resolve(); + }); + expect(await screen.findByText('延迟依赖图回执')).not.toBeNull(); + expect(layoutReads).toBe(1); + }); + + it( + 'keeps 4096 real resource cards out of React commits during preview frames', + async () => { + const projectId = 'workbench-resource-drag-performance'; + const projectPath = '/tmp/workbench-resource-drag-performance'; + const manifest = createGameCreationAppManifest( + projectId, + '资源拖动性能测试', + ); + const agentResults = Array.from({ length: 4096 }, (_, index) => ({ + agentId: 'design-foundation', + runId: `performance-run-${index}`, + label: `性能 Agent ${index}`, + title: `性能资源 ${index}`, + content: `性能正文 ${index}`, + updatedAt: index, + })); + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: 'dependency', + revision: layoutRevision, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: 'dependency', + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const commits: string[] = []; + + render( + React.createElement( + React.Profiler, + { + id: 'resource-workbench', + onRender: () => commits.push('commit'), + }, + React.createElement(ProjectDevelopmentView, { + projectName: '资源拖动性能测试', + projectPath, + manifest, + attachments: [], + agentResults, + supervisor: React.createElement('div', null, '项目总控'), + }), + ), + ); + + const target = (await screen.findByText('性能资源 2048')).closest( + 'button', + ); + expect(target).not.toBeNull(); + expect(document.querySelectorAll('.game-resource-card')).toHaveLength( + 4096, + ); + commits.length = 0; + let previewFrame: FrameRequestCallback | null = null; + const requestAnimationFrame = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + previewFrame = callback; + return 77; + }); + const cancelAnimationFrame = vi + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation(() => undefined); + try { + fireEvent.pointerDown(target!, { + pointerId: 88, + button: 0, + clientX: 20, + clientY: 30, + }); + for (let index = 0; index < 100; index += 1) { + fireEvent.pointerMove(target!, { + pointerId: 88, + clientX: 40 + index, + clientY: 60 + index, + }); + } + act(() => previewFrame?.(16)); + + expect(commits).toHaveLength(1); + expect(target?.getAttribute('style')).toContain('--resource-x: 119px'); + expect(requestAnimationFrame).toHaveBeenCalledTimes(1); + } finally { + fireEvent.pointerCancel(target!, { + pointerId: 88, + clientX: 139, + clientY: 159, + }); + requestAnimationFrame.mockRestore(); + cancelAnimationFrame.mockRestore(); + } + }, + 15_000, + ); + it('persists a resource position with CAS and restores it after remount', async () => { const projectId = 'workbench-layout-persistence'; const projectPath = '/tmp/workbench-layout-persistence'; @@ -1058,6 +1292,9 @@ export function registerProjectWorkbenchFoundationTests() { }; const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } if (command === 'read_local_project_resource_canvas_layout') { return structuredClone(persistedLayout); } @@ -1097,7 +1334,7 @@ export function registerProjectWorkbenchFoundationTests() { } renderWorkbench(); - const card = screen.getByText('布局持久化回执').closest('button'); + const card = (await screen.findByText('布局持久化回执')).closest('button'); expect(card).not.toBeNull(); await waitFor(() => { expect(card?.getAttribute('style')).toContain('--resource-x: 12px'); @@ -1142,7 +1379,9 @@ export function registerProjectWorkbenchFoundationTests() { cleanup(); renderWorkbench(); - const restoredCard = screen.getByText('布局持久化回执').closest('button'); + const restoredCard = ( + await screen.findByText('布局持久化回执') + ).closest('button'); await waitFor(() => { expect(restoredCard?.getAttribute('style')).toContain( '--resource-x: 92px', @@ -1174,7 +1413,11 @@ export function registerProjectWorkbenchFoundationTests() { ], updatedAt: 400, }; - const invoke = vi.fn(async (command: string) => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } if (command === 'read_local_project_resource_canvas_layout') { return { ...structuredClone(latestLayout), @@ -1192,7 +1435,8 @@ export function registerProjectWorkbenchFoundationTests() { return { status: 'conflict', layout: structuredClone(latestLayout) }; } throw new Error(`unexpected invoke ${command}`); - }); + }, + ); window.__TAURI__ = { core: { invoke } }; render( @@ -1214,7 +1458,7 @@ export function registerProjectWorkbenchFoundationTests() { supervisor: React.createElement('div', null, '项目总控'), }), ); - const card = screen.getByText('布局冲突回执').closest('button'); + const card = (await screen.findByText('布局冲突回执')).closest('button'); await waitFor(() => { expect(card?.getAttribute('style')).toContain('--resource-x: 20px'); }); @@ -1280,7 +1524,11 @@ export function registerProjectWorkbenchFoundationTests() { }) => void) | null = null; let updateCalls = 0; - const invoke = vi.fn(async (command: string) => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } if (command === 'read_local_project_resource_canvas_layout') { return { ...structuredClone(latestLayout), @@ -1295,7 +1543,8 @@ export function registerProjectWorkbenchFoundationTests() { }); } throw new Error(`unexpected invoke ${command}`); - }); + }, + ); window.__TAURI__ = { core: { invoke } }; render( @@ -1317,7 +1566,7 @@ export function registerProjectWorkbenchFoundationTests() { supervisor: React.createElement('div', null, '项目总控'), }), ); - const card = screen.getByText('排队冲突回执').closest('button'); + const card = (await screen.findByText('排队冲突回执')).closest('button'); await waitFor(() => { expect(card?.getAttribute('style')).toContain('--resource-x: 20px'); }); @@ -1391,6 +1640,9 @@ export function registerProjectWorkbenchFoundationTests() { }> = []; const invoke = vi.fn( async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } const mode = args?.mode as 'dependency' | 'type'; if (command === 'read_local_project_resource_canvas_layout') { return { @@ -1464,7 +1716,11 @@ export function registerProjectWorkbenchFoundationTests() { it('keeps newly reconciled resources visible when their automatic layout save fails', async () => { const projectId = 'workbench-layout-save-failure'; const manifest = createGameCreationAppManifest(projectId, '布局失败测试'); - const invoke = vi.fn(async (command: string) => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } if (command === 'read_local_project_resource_canvas_layout') { return { schemaVersion: 'game-creator-resource-layout.v1', @@ -1479,7 +1735,8 @@ export function registerProjectWorkbenchFoundationTests() { throw new Error('disk full'); } throw new Error(`unexpected invoke ${command}`); - }); + }, + ); window.__TAURI__ = { core: { invoke } }; render( diff --git a/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts index 0566384ae..0ed293914 100644 --- a/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceDependencyGraphModel.test.ts @@ -132,9 +132,21 @@ describe('resource dependency graph model', () => { readModel({ resourceIds: ['asset:spec', 'asset:ui'], producerAssignments: [ - { resourceId: 'asset:spec', taskId: 'art-director' }, - { resourceId: 'asset:ui', taskId: 'design-foundation' }, - { resourceId: 'asset:deleted', taskId: 'task-1' }, + { + resourceId: 'asset:spec', + taskId: 'art-director', + dependencyDepth: 0, + }, + { + resourceId: 'asset:ui', + taskId: 'design-foundation', + dependencyDepth: 1, + }, + { + resourceId: 'asset:deleted', + taskId: 'task-1', + dependencyDepth: 99, + }, ], producerMappingTruncated: true, }), @@ -146,6 +158,12 @@ describe('resource dependency graph model', () => { ['asset:ui', 'design-foundation'], ]), ); + expect(graph.dependencyDepthByResourceId).toEqual( + new Map([ + ['asset:spec', 0], + ['asset:ui', 1], + ]), + ); expect(graph.producerMappingTruncated).toBe(true); expect(projectResourceGraphNeighbors(graph, 'asset:deleted')).toEqual({ upstreamResourceIds: new Set(), diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index d07892f34..ac8add1d8 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -58,6 +58,17 @@ function position( }; } +function automaticPosition( + resourceId: string, + x: number, + y: number, +): ProjectResourceCanvasPosition { + return { + ...position(resourceId, x, y), + manuallyPlaced: false, + }; +} + afterEach(() => { cleanup(); window.__TAURI__ = undefined; @@ -72,6 +83,117 @@ describe('useProjectResourceCanvasLayout', () => { ); }); + it('waits for dependency graph initialization before reading or writing layout', async () => { + const updates: ProjectResourceCanvasPosition[][] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 0, []); + } + if (command === 'update_local_project_resource_canvas_layout') { + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + updates.push(positions); + return { + status: 'updated', + layout: persistedLayout('dependency', 1, positions), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const shallowResource = resource('resource-a'); + const deepResource = { ...shallowResource, dependencyDepth: 2 }; + const { result, rerender } = renderHook( + ({ initializationReady, resources }) => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources, + initializationReady, + rederiveAutomaticPositions: true, + }), + { + initialProps: { + initializationReady: false, + resources: [shallowResource], + }, + }, + ); + + expect(invoke).not.toHaveBeenCalled(); + expect(result.current.layout.positions).toEqual([]); + + rerender({ initializationReady: true, resources: [deepResource] }); + await waitFor(() => expect(updates).toHaveLength(1)); + expect(result.current.layout.positions[0]).toMatchObject({ + resourceId: 'resource-a', + x: 392, + manuallyPlaced: false, + }); + expect(invoke.mock.calls[0]?.[0]).toBe( + 'read_local_project_resource_canvas_layout', + ); + }); + + it('rederives automatic dependency positions while preserving manual positions', async () => { + const resourceA = { ...resource('resource-a'), dependencyDepth: 2 }; + const resourceB = resource('resource-b'); + const updates: ProjectResourceCanvasPosition[][] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 4, [ + automaticPosition('resource-a', 0, 0), + position('resource-b', 600, 40), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + updates.push(positions); + return { + status: 'updated', + layout: persistedLayout('dependency', 5, positions), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resourceA, resourceB], + rederiveAutomaticPositions: true, + }), + ); + + await waitFor(() => expect(updates).toHaveLength(1)); + expect(result.current.layout.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-a', + x: 392, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-b', + x: 600, + y: 40, + manuallyPlaced: true, + }), + ]), + ); + }); + it('rejects an unsafe revision from the initial IPC read without writing', async () => { const resourceA = resource('resource-a'); const invoke = vi.fn(async (command: string) => { diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index ef4fa28e4..2ca695d2a 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -1,6 +1,6 @@ # AI 游戏创作项目开发工作台 PRD -更新时间:`2026-07-31` +更新时间:`2026-08-03` ## 1. 产品定位 @@ -153,7 +153,7 @@ P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可 ### 5.2 资源画布布局(P1) -实现状态(2026-07-31):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。 +实现状态(2026-08-03):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑与确定性依赖深度、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。依赖图加载完成前设布局初始化屏障,避免以临时 `dependencyDepth=0` 生成并持久化错误坐标。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。 ```ts type ProjectResourceCanvasLayout = { @@ -237,10 +237,10 @@ type UpdateProjectResourceCanvasLayoutResult = - 资源卡改用 Pointer Events 驱动二维拖动;超过统一移动阈值后才进入拖动态,普通点击仍打开唯一资源详情浮层,`pointercancel` 恢复拖动前位置。 - 资源只能在原 `section` 内拖动。不同 section 之间既不能通过指针拖入,也不能通过持久 payload 改变当前资源的前端分类事实。 - dependency 默认布局按 `dependencyDepth` 形成横向层级,同层资源纵向寻找第一个不重叠位置;type 默认布局固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序,在分区内从左到右、从上到下寻找第一个空位。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact`、`attachment`、`agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸、间距和拖动阈值必须由单一前端布局模型常量维护。 -- 资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID;无论 `manuallyPlaced` 为何,已经写入的现存坐标都不得因重新排序、模式切换或新增资源被自动改写。 +- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的用户坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪或可信 producer / dependency depth 变化后按最终拓扑确定性重算。自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。 - 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。 - 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。 -- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;项目或 mode 已切换后返回的旧异步结果必须丢弃。 +- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready` 或 `failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。 - 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。同一 scope 内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。 - 某笔 CAS 在途期间,同一 scope 内对相同 `resourceId + section` 重复产生但尚未发送的拖动意图必须折叠为最后坐标;已经在途的请求不得取消,不同资源的顺序不得跨越。队列增长必须受当前资源与分区数量约束,不能随连续 pointer 事件无界累积。 - 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。 @@ -250,7 +250,7 @@ type UpdateProjectResourceCanvasLayoutResult = #### 5.2.5 资源依赖关系图层 - 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。 -- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、环检测、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。 +- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。实时拖动坐标属于 DOM 临时状态,不得逐帧通过 Tauri IPC 交给 Rust。 - `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。 - `task-flow` 表示任务产物流转,使用灰色圆头虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。 - 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate` 的 `assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifest;External Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。 @@ -259,7 +259,7 @@ type UpdateProjectResourceCanvasLayoutResult = - 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。 - 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。 - 拖动预览坐标必须直接进入 SVG 几何计算,使连线随 pointer move 实时更新;拖动结束仍只保存卡片布局坐标,不持久化 path、marker、section 原点或任何图结构。 -- Pointer Move 必须按动画帧合并;基础 positions 不随每帧复制,静态 SVG 拓扑保持复用,每帧只更新当前资源局部索引关联的 path。`ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。 +- Pointer Move 必须按动画帧合并并完全避开工作台父组件 state:拖动卡片通过 ref 直接更新 CSS 坐标,SVG 通过命令式句柄只更新当前资源局部索引关联的 path。基础 positions 不随每帧复制,静态卡片和 SVG 拓扑保持复用,非拖动卡片不得因预览帧重新渲染。`ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。 ### 5.3 资源类型与替换兼容性(P1) @@ -405,6 +405,7 @@ type ProjectAgentMudPointAttribution = { 5. 搜索、选择和拖动分别触发端点过滤、直接上下游高亮和实时几何更新;原有点击、详情浮层与拖动保存行为不回归。 6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。 7. 4096 资源链式 fixture 下,拖动一张卡片只更新它关联的线段;同一图层 100 次拖动期间 Observer 仍只构造一次。真实 Chromium 目标为拖动 p95 小于 `16.7ms`、不出现超过 `50ms` 的 long task,并完整显示最右侧自环与箭头。 +8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。重新打开旧布局时手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。 ## 8. 非目标 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1b4d0dae0..9b889b71f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,12 @@ # 决策记录 +## 2026-08-03 依赖布局等待 Rust 图终态且拖动热路径脱离 React state + +- 背景:资源图异步返回前,dependency 布局会先以 `dependencyDepth=0` 创建并持久化自动坐标;图返回后的 reconcile 保留既有位置,导致首次布局永久停留在错误层级。4096 张真实资源卡拖动时,逐帧父组件 state 还会重渲染全部卡片,即使 SVG 已只更新局部 path 也无法满足帧预算。 +- 决策:Rust 关系图 read model 负责在完整任务图 SCC 压缩后返回确定性 resource dependency depth;dependency 模式等待当前 scope 图进入 `ready / failed` 后才启动布局读取与协调。手动位置永久保留,自动位置允许按最终图重新派生。拖动 preview 留在前端 ref/DOM 热路径,命令式更新卡片 CSS 与局部 SVG path,不逐帧跨 Tauri IPC,也不写 layout sidecar。 +- 边界:不修改 `game-creator-resource-layout.v1`、布局 Rust 持久层、manifest、api-server 或 SpacetimeDB;type 模式不等待资源图且继续保留全部已有坐标。图失败只降级初始化一次,项目或 mode 切换后旧图结果必须丢弃。 +- 验证:延迟图 Promise 证明终态前零布局读取/写入,手动位置保持且自动位置按最终深度协调;4096 张真实卡片连续拖动证明非拖动卡片零重渲染、静态 SVG 不重建、Observer 单实例,并以 Chromium p95 `<16.7ms` 和零 `>50ms` long task 验收。 + > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 ## 记录格式 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8a5d2f94d..1df822307 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -22,6 +22,14 @@ - 验证:Rust fixture 把 `source.taskId` 固定为 `task-1 / task-2`,只有审计提供 `art-director / design-foundation` 后才生成 task flow;移除审计后橙色引用保留、灰色任务流消失。AppSurface 使用相同生产数据形状回归。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs`、`apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 +## 依赖图未就绪时不能先初始化资源布局 + +- 现象:首次打开 dependency 画布时所有资源短暂按深度 0 排列;Rust 图返回后连线正确,但卡片仍停留在同一列,错误自动坐标还可能已经写入 sidecar。 +- 原因:资源图和布局读取独立异步启动,布局 Hook 在图未返回时使用空图资源创建 fallback;后续 reconcile 按旧合同保留全部已有坐标,真实 producer 与 dependency depth 无法纠正首次自动位置。 +- 处理:dependency 模式增加按项目与资源输入隔离的图加载屏障,`ready / failed` 前不启动布局 Hook 的 fallback、读取、协调或保存。Rust read model 返回确定性依赖深度;已有布局只永久保留手动位置,自动位置按最终图重新派生。type 模式不受图加载影响。 +- 验证:用 deferred graph Promise 断言终态前 Tauri layout read/update 调用均为 0;图就绪后首次坐标直接按最终深度生成,旧 scope 迟到结果无效,手动坐标不变且相同自动布局不增加 revision。 +- 关联:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs`。 + ## Jenkins 异步备份不能用 nohup 脱离作业 - 现象:Stdb Publish 成功,上传日志只留下“已获取进程锁 / 上传已有备份 / 目标对象”,没有成功或可捕获错误;本地 tar.gz 和 `uploadStatus=deferred` manifest 每次发布后继续增长。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e2a6f4f4b..283fa5b6a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -396,6 +396,10 @@ game-project/ - 基础 positions 保持稳定,拖动预览单独传入。Pointer Move 通过 `requestAnimationFrame` 合帧;静态 task/reference React SVG 子树不依赖 drag preview,每帧只按局部连接索引重新计算当前资源关联的 edge / flow 并更新对应 path `d`。搜索、选择、项目切换、真实 positions 或 section origin 变化才允许重新协调静态图层;结束后仍只通过原布局 Hook 保存卡片坐标,SVG 几何从不持久化。 - Rust、前端 DTO/SVG 和工作台 AppSurface 回归覆盖生产 `task-1` 数据形状、真实 producer、证据缺失、精确引用、去重、无效 ID、完整任务环、4096 链式拓扑、聚合复杂度、搜索过滤、选择高亮、局部拖动更新、稳定 Observer、type 模式卸载与项目切换销毁。真实 Chromium 性能目标为拖动 p95 `<16.7ms`、不出现 `>50ms` long task;若实测仍超过预算,再增加 viewport + overscan 边裁剪,不在首轮预先引入额外复杂度。 +2026-08-03 评审加固:Rust read model 在现有 producer assignment 上同时返回经完整任务图 SCC 压缩计算的确定性 `dependencyDepth`,前端不再递归推导正式依赖层级。dependency 模式以 scope 化 `idle / loading / ready / failed` 状态阻断布局 Hook;图终态前不创建 fallback、不读取或写入 sidecar,图失败只以空图初始化一次。读取已有 dependency 布局时保留全部 `manuallyPlaced=true` 坐标,把 `manuallyPlaced=false` 作为可派生自动位置按最终深度重新协调;结果未变化时不写入。 + +拖动热路径不再把 preview 写入工作台父组件 state。卡片使用稳定回调与 `React.memo`,动画帧直接更新拖动卡片 CSS 变量和 section 临时 extent;SVG 图层暴露命令式 preview 句柄,复用 Rust 局部连接索引和已缓存 path 节点,只更新受影响 reference / task-flow 几何。ResizeObserver 仍为单图层单实例。4096 张真实卡片测试必须证明非拖动卡片零重渲染、静态 SVG 子树不重建,Chromium p95 继续以 `<16.7ms` 为门槛。实时 DOM 几何不得通过 Tauri IPC 往返 Rust。 + ## 分阶段实施 1. 在 `platform-agent` 建立游戏创作专业组与种子任务图契约。