diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index f849b43ca..75f08c70f 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3902,6 +3902,8 @@ iframe.preview-frame { .game-resource-dependency-edge, .game-resource-dependency-edge path { fill: none; + stroke-linecap: round; + stroke-linejoin: round; vector-effect: non-scaling-stroke; transition: opacity 140ms ease, @@ -3909,25 +3911,44 @@ iframe.preview-frame { } .game-resource-dependency-edge--reference { - stroke: #d87342; + stroke: #d96f3d; + stroke-width: 2.25px; + opacity: 0.94; +} + +.game-resource-dependency-edge--task path { + stroke: #918b87; + stroke-width: 1.4px; + stroke-dasharray: 4 7; +} + +.game-resource-dependency-edge--task .game-resource-dependency-trunk { + stroke-width: 1.7px; + opacity: 0.88; +} + +.game-resource-dependency-edge--task .game-resource-dependency-branch { + opacity: 0.72; +} + +.game-resource-dependency-edge.is-highlighted { + opacity: 1; +} + +.game-resource-dependency-edge--reference.is-highlighted { + stroke-width: 3px; +} + +.game-resource-dependency-edge--task.is-highlighted path { + opacity: 1; stroke-width: 2px; } -.game-resource-dependency-edge--task, -.game-resource-dependency-edge--task path { - stroke: #9d948f; - stroke-width: 1.5px; - stroke-dasharray: 7 6; +.game-resource-dependency-edge--task.is-highlighted .game-resource-dependency-trunk { + stroke-width: 2.4px; } -.game-resource-dependency-edge.is-highlighted, -.game-resource-dependency-edge.is-highlighted path { - opacity: 1; - stroke-width: 2.8px; -} - -.game-resource-dependency-edge.is-dimmed, -.game-resource-dependency-edge.is-dimmed path { +.game-resource-dependency-edge.is-dimmed { opacity: 0.14; } @@ -3937,11 +3958,13 @@ iframe.preview-frame { } .game-resource-dependency-marker--reference path { - fill: #d87342; + fill: #d96f3d; + stroke-linejoin: round; } .game-resource-dependency-marker--task path { - fill: #9d948f; + fill: #918b87; + stroke-linejoin: round; } .game-resource-section { 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 e318ad0e4..a3ffc1ab4 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 @@ -36,6 +36,8 @@ export type ResourceDependencyOverlayProps = { const SECTION_SELECTOR = '[data-resource-section-plane]'; const TASK_FLOW_HUB_GAP = 20; +const CONNECTION_MAX_HANDLE = 180; +const TASK_FLOW_BRANCH_MAX_HANDLE = 96; const SELF_REFERENCE_LOOP_WIDTH = 56; const SELF_REFERENCE_LOOP_ANCHOR_OFFSET = 18; @@ -60,17 +62,39 @@ function connectionPath(source: Point, target: Point) { } ${source.y + 48}, ${source.x} ${source.y + 1}`; } const direction = target.x >= source.x ? 1 : -1; - const bend = Math.max(36, Math.abs(target.x - source.x) * 0.45); + const bend = Math.min( + CONNECTION_MAX_HANDLE, + Math.max( + 32, + Math.abs(target.x - source.x) * 0.42 + + Math.abs(target.y - source.y) * 0.08, + ), + ); return `M ${source.x} ${source.y} C ${source.x + direction * bend} ${ source.y }, ${target.x - direction * bend} ${target.y}, ${target.x} ${target.y}`; } -function linePath(source: Point, target: Point) { - const middleX = (source.x + target.x) / 2; - return `M ${source.x} ${source.y} L ${middleX} ${source.y} L ${middleX} ${ - target.y - } L ${target.x} ${target.y}`; +function taskFlowBranchPath(source: Point, target: Point) { + const horizontalDistance = Math.abs(target.x - source.x); + if (horizontalDistance < 1) { + const direction = target.y >= source.y ? 1 : -1; + const handle = Math.min( + TASK_FLOW_BRANCH_MAX_HANDLE, + Math.abs(target.y - source.y) * 0.5, + ); + return `M ${source.x} ${source.y} C ${source.x} ${ + source.y + direction * handle + }, ${target.x} ${target.y - direction * handle}, ${target.x} ${target.y}`; + } + const direction = target.x >= source.x ? 1 : -1; + const handle = Math.min( + TASK_FLOW_BRANCH_MAX_HANDLE, + horizontalDistance * 0.5, + ); + return `M ${source.x} ${source.y} C ${source.x + direction * handle} ${ + source.y + }, ${target.x - direction * handle} ${target.y}, ${target.x} ${target.y}`; } function rectCenter(rect: Rect): Point { @@ -278,26 +302,28 @@ export function ResourceDependencyOverlay({ - + - + @@ -328,7 +354,7 @@ export function ResourceDependencyOverlay({ key={`source:${resourceId}`} className="game-resource-dependency-branch" data-resource-id={resourceId} - d={linePath(point, geometry.sourceHub)} + d={taskFlowBranchPath(point, geometry.sourceHub)} /> ))} ))} diff --git a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts index 2f56beff8..e5b508d09 100644 --- a/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts +++ b/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts @@ -120,8 +120,22 @@ describe('ResourceDependencyOverlay', () => { ); const taskFlow = overlay.querySelector('[data-edge-kind="task-flow"]'); expect(taskFlow).not.toBeNull(); - expect(taskFlow?.querySelectorAll('path')).toHaveLength(7); + const taskPaths = Array.from( + taskFlow?.querySelectorAll('path') ?? [], + ); + expect(taskPaths).toHaveLength(7); + expect( + taskPaths.every((path) => path.getAttribute('d')?.includes(' C ')), + ).toBe(true); + expect( + taskPaths.some((path) => path.getAttribute('d')?.includes(' L ')), + ).toBe(false); expect(taskFlow?.querySelectorAll('path[marker-end]')).toHaveLength(3); + expect( + overlay + .querySelector('marker[id$="-task-flow-arrow"]') + ?.getAttribute('markerUnits'), + ).toBe('userSpaceOnUse'); }); it('filters hidden endpoints and updates path geometry when positions change', async () => { diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 7e18fc25f..e00ab7567 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -251,8 +251,8 @@ type UpdateProjectResourceCanvasLayoutResult = - 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。 - 输入固定为当前资源投影的全部卡片坐标与前端 `ProjectResourceGraph`;输出使用原生 SVG 的 path 和箭头 marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。 -- `asset-reference` 表示精确资源引用,使用橙色实线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。 -- `task-flow` 表示任务产物流转,使用灰色虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制分支;禁止对上下游资源生成笛卡尔积连线。 +- `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。 +- `task-flow` 表示任务产物流转,使用灰色圆头虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。 - 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。 - 资源自引用的起点与终点为同一张卡片时,必须绘制在卡片外侧的可见闭环并保留箭头,不得让路径穿过卡片后被底层 SVG 层级遮挡。 - 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 63a147601..e45244831 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -371,7 +371,7 @@ game-project/ - `resourceDependencyGraphModel.ts` 负责从当前资源卡投影和 manifest task 构建 `ProjectResourceGraph`。精确引用把资产 `source.referenceResourceIds` 唯一匹配到另一资产的 `source.resourceId`,再转换为实际卡片 ID;无匹配、多匹配和已删除资源只记录为 unresolved,不生成边。引用边按 `sourceResourceId + targetResourceId` 稳定去重。 - task flow 只读取存在于当前 manifest 的任务依赖。资源按 `producerTaskId` 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow;SVG 侧绘制 source 分支、唯一主线和 target 分支,路径数量为 `O(S+T)`,禁止资源笛卡尔积。 - 资源引用图和完整任务依赖图分别使用迭代式强连通分量分析。任务环检测不能依赖可视 task flow 是否有两端资源,否则无产物任务参与的环会漏报;循环边只带 cyclic 标记,不触发递归展开。 -- `ResourceDependencyOverlay.tsx` 使用原生 SVG path/marker,绝对定位在 `.game-resource-canvas-content` 底层并统一 `pointer-events: none`。橙色实线表示 `asset-reference`,灰色虚线表示 `task-flow`;不引入 D3、React Flow 或其它图表依赖。 +- `ResourceDependencyOverlay.tsx` 使用原生 SVG path/marker,绝对定位在 `.game-resource-canvas-content` 底层并统一 `pointer-events: none`。橙色实线表示 `asset-reference`,灰色圆头虚线表示 `task-flow`;两类连线统一使用连续贝塞尔曲线,任务主线略强于两端分支,箭头使用不随高亮线宽缩放的稳定用户空间尺寸,避免直角折线、突兀拐弯和箭头跳变。不引入 D3、React Flow 或其它图表依赖。 - `asset-reference` 的 source / target 是同一资源时使用卡片右侧外绕贝塞尔闭环,两个锚点分开且 marker 保留在返回锚点;路径不穿过卡片,并与普通边一样直接由拖动预览坐标重算。 - 图层用 SVG 自身节点定位所属画布容器,测量各 section plane 相对原点;`ResizeObserver` 与 window resize 只负责重新测量,并在卸载时清理。这样不依赖父组件 ref 在子组件 layout effect 中的绑定时序。type 模式不挂载图层;项目身份作为 key,切换 mode、项目或工作台卸载都会销毁旧 SVG。 - 搜索可见 ID 在几何阶段过滤端点;选中资源只查询一跳上下游与关联边,关联卡片和边高亮,其余边弱化。拖动预览坐标直接替换当前卡片坐标传入图层,pointer move 实时更新 path;结束后仍只通过原布局 Hook 保存卡片坐标,SVG 几何从不持久化。