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 几何从不持久化。