From 53e37ea36199201b9064bb0fa74d478c6e19b8a9 Mon Sep 17 00:00:00 2001 From: menghao Date: Fri, 7 Aug 2026 10:13:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81App=20Run=E5=B9=B6=E8=A1=8C?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E5=B9=B6=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增App Run独立开发profile并隔离端口、数据库、Tauri身份与AppData 常驻资源排列切换并避免布局状态挤压操作入口 显式扩展资源画布横向滚动范围并收敛依赖图可见连线 补充启动器、资源布局、界面回归测试与项目文档 --- apps/ai-game-creator-shell/package.json | 1 + .../scripts/check-config.mjs | 33 ++ .../scripts/start-dev-stack.mjs | 167 +++++++--- .../scripts/start-tauri-dev.mjs | 32 +- .../src-tauri/tauri.app-run-dev.conf.json | 21 ++ apps/ai-game-creator-shell/src/styles.css | 57 ++-- .../ResourceDependencyOverlay.tsx | 305 ------------------ .../src/view/project-development/index.tsx | 60 ++-- .../resourceCanvasLayoutModel.ts | 12 + .../tests/ResourceDependencyOverlay.test.ts | 57 +--- .../appSurface/project-development.suite.ts | 27 +- .../tests/resourceCanvasLayoutModel.test.ts | 27 ++ .../tests/start-dev-stack.test.ts | 77 +++++ .../tests/start-tauri-dev.test.ts | 53 +++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 15 +- .../shared-memory/decision-log.md | 16 + .../shared-memory/development-workflow.md | 10 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- package.json | 1 + 21 files changed, 533 insertions(+), 458 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/tauri.app-run-dev.conf.json diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index e2982bbc3..bc54417c2 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", + "dev:app-run": "node scripts/start-tauri-dev.mjs --app-run", "game-chat": "node scripts/start-tauri-dev.mjs --game-chat", "dev-server": "node scripts/start-dev-server.mjs", "dev-stack": "node scripts/start-dev-stack.mjs", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 91e8f5e2d..cf0288064 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -27,6 +27,12 @@ const tauriConfig = JSON.parse( 'utf8', ), ); +const appRunTauriConfig = JSON.parse( + fs.readFileSync( + new URL('../src-tauri/tauri.app-run-dev.conf.json', import.meta.url), + 'utf8', + ), +); const gameChatReleaseTauriConfig = JSON.parse( fs.readFileSync( new URL('../src-tauri/tauri.game-chat-release.conf.json', import.meta.url), @@ -1263,6 +1269,33 @@ if (packageConfig.scripts?.dev !== 'node scripts/start-tauri-dev.mjs') { ); } +if ( + packageConfig.scripts?.['dev:app-run'] !== + 'node scripts/start-tauri-dev.mjs --app-run' || + rootPackageConfig.scripts?.['agc:app-run'] !== + 'npm --prefix apps/ai-game-creator-shell run dev:app-run' +) { + throw new Error( + 'AI game creator app-run dev profile must use the managed Tauri dev launcher', + ); +} + +const appRunWindow = appRunTauriConfig.app?.windows?.[0]; +if ( + appRunTauriConfig.productName !== + 'Genarrative AI Game Creator App Run' || + appRunTauriConfig.identifier !== + 'world.genarrative.ai-game-creator.app-run' || + appRunTauriConfig.identifier === tauriConfig.identifier || + appRunTauriConfig.build?.devUrl !== 'http://127.0.0.1:3081/' || + appRunWindow?.label !== 'client' || + appRunWindow?.title !== 'AI 游戏创作 · App Run' +) { + throw new Error( + 'AI game creator app-run profile must keep an independent identity, title, and Vite port', + ); +} + if ( packageConfig.scripts?.['game-chat'] !== 'node scripts/start-tauri-dev.mjs --game-chat' diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 562e9baaf..df4d99980 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -8,17 +8,56 @@ import { fileURLToPath } from 'node:url'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = resolve(appRoot, '../..'); const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json'); -const viteHost = '127.0.0.1'; -const vitePort = 3080; +const devProfileName = process.env.GENARRATIVE_AGC_DEV_PROFILE || 'default'; + +function resolveDevStackProfile(name = 'default') { + switch (name) { + case 'default': + return { + name, + viteHost: '127.0.0.1', + vitePort: 3080, + apiPort: 8082, + bgfilterWorkerPort: 8083, + spacetimePort: 3101, + backendDatabase: 'genarrative-game-creator-dev', + backendSpacetimeDataDir: resolve( + repoRoot, + 'server-rs/.spacetimedb/ai-game-creator/data', + ), + }; + case 'app-run': + return { + name, + viteHost: '127.0.0.1', + vitePort: 3081, + apiPort: 8084, + bgfilterWorkerPort: 8085, + spacetimePort: 3103, + backendDatabase: 'genarrative-game-creator-app-run-dev', + backendSpacetimeDataDir: resolve( + repoRoot, + 'server-rs/.spacetimedb/ai-game-creator-app-run/data', + ), + }; + default: + throw new Error(`未知 AI 游戏创作开发 profile: ${name}`); + } +} + +const devProfile = resolveDevStackProfile(devProfileName); +const { + viteHost, + vitePort, + apiPort, + bgfilterWorkerPort, + spacetimePort, + backendDatabase, + backendSpacetimeDataDir, +} = devProfile; const viteUrl = `http://${viteHost}:${vitePort}/`; -const viteMarkerUrl = `${viteUrl}__agc_dev_server.json`; const defaultApiTarget = - process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082'; -const backendDatabase = 'genarrative-game-creator-dev'; -const backendSpacetimeDataDir = resolve( - repoRoot, - 'server-rs/.spacetimedb/ai-game-creator/data', -); + process.env.RUST_SERVER_TARGET || `http://127.0.0.1:${apiPort}`; const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const childLifecycles = new WeakMap(); @@ -72,6 +111,7 @@ function resolveBackendTargetsFromState( expectedDatabase = backendDatabase, expectedSpacetimeDataDir = backendSpacetimeDataDir, fallbackApiTarget = defaultApiTarget, + fallbackSpacetimeTarget = `http://127.0.0.1:${spacetimePort}`, } = {}, ) { const apiServer = state?.services?.['api-server']; @@ -100,7 +140,7 @@ function resolveBackendTargetsFromState( ? spacetime.url : requireAgcBackend ? '' - : 'http://127.0.0.1:3101'; + : fallbackSpacetimeTarget; return { apiUrl, spacetimeUrl, @@ -131,13 +171,24 @@ async function isBackendReady() { ); } -async function readExistingViteServer() { - return httpGetText(viteUrl); +function resolveViteProfileUrls(profile = devProfile) { + const profileViteUrl = `http://${profile.viteHost}:${profile.vitePort}/`; + return { + viteUrl: profileViteUrl, + viteMarkerUrl: `${profileViteUrl}__agc_dev_server.json`, + }; } -function isVitePortListening() { +async function readExistingViteServer(profile = devProfile) { + return httpGetText(resolveViteProfileUrls(profile).viteUrl); +} + +function isVitePortListening(profile = devProfile) { return new Promise((resolveRequest) => { - const socket = net.connect({ host: viteHost, port: vitePort }); + const socket = net.connect({ + host: profile.viteHost, + port: profile.vitePort, + }); socket.once('connect', () => { socket.destroy(); resolveRequest(true); @@ -160,8 +211,11 @@ function isAiGameCreatorServer(response) { ); } -async function readExistingViteMarker() { - const response = await httpGetText(viteMarkerUrl, 2000); +async function readExistingViteMarker(profile = devProfile) { + const response = await httpGetText( + resolveViteProfileUrls(profile).viteMarkerUrl, + 2000, + ); if (!response || response.statusCode !== 200) { return null; } @@ -173,15 +227,17 @@ async function readExistingViteMarker() { } async function preflightExistingVite({ - readServer = readExistingViteServer, - portListening = isVitePortListening, - readMarker = readExistingViteMarker, + profile = devProfile, + readServer = () => readExistingViteServer(profile), + portListening = () => isVitePortListening(profile), + readMarker = () => readExistingViteMarker(profile), } = {}) { + const { viteUrl: profileViteUrl } = resolveViteProfileUrls(profile); const existing = await readServer(); if (!existing) { if (await portListening()) { throw new Error( - `${viteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`, + `${profileViteUrl} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`, ); } return { status: 'available', apiTarget: '' }; @@ -189,7 +245,7 @@ async function preflightExistingVite({ if (!isAiGameCreatorServer(existing)) { throw new Error( - `${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`, + `${profileViteUrl} is already in use by another server. Stop it before starting Tauri dev.`, ); } @@ -202,7 +258,7 @@ async function preflightExistingVite({ : ''; const actualTarget = markerApiTarget || 'unknown'; throw new Error( - `${viteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`, + `${profileViteUrl} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`, ); } @@ -507,27 +563,50 @@ async function waitForBackendReady(backendChild, timeoutMs = 600_000) { throw new Error('等待配套后端和数据库启动超时'); } +function buildBackendStartArguments(profile = devProfile) { + return [ + '--prefix', + '../..', + 'run', + 'agc:backend', + '--', + '--database', + profile.backendDatabase, + '--spacetime-data-dir', + profile.backendSpacetimeDataDir, + '--api-port', + String(profile.apiPort), + '--bgfilter-worker-port', + String(profile.bgfilterWorkerPort), + '--spacetime-port', + String(profile.spacetimePort), + '--no-interactive', + ]; +} + +function buildViteStartArguments(profile = devProfile) { + return [ + '--prefix', + '../..', + 'exec', + 'vite', + '--', + '--config', + 'vite.config.ts', + '--host', + profile.viteHost, + '--port', + String(profile.vitePort), + '--strictPort', + ]; +} + async function ensureBackend({ onBackendChild = () => {}, checkBackendReady = isBackendReady, resolveTargets = readBackendTargets, spawnBackend = () => - spawnChild( - npm, - [ - '--prefix', - '../..', - 'run', - 'agc:backend', - '--', - '--database', - backendDatabase, - '--spacetime-data-dir', - backendSpacetimeDataDir, - '--no-interactive', - ], - { cwd: appRoot }, - ), + spawnChild(npm, buildBackendStartArguments(), { cwd: appRoot }), waitUntilReady = waitForBackendReady, } = {}) { if (await checkBackendReady()) { @@ -569,11 +648,7 @@ async function startVite(apiTarget) { ); } - return spawnChild( - npm, - ['--prefix', '../..', 'exec', 'vite', '--', '--config', 'vite.config.ts'], - { cwd: appRoot }, - ); + return spawnChild(npm, buildViteStartArguments(), { cwd: appRoot }); } async function main() { @@ -593,6 +668,9 @@ async function main() { } try { + console.log( + `[ai-game-creator-shell] dev profile ${devProfile.name}: vite=${viteUrl} api=${apiPort} spacetime=${spacetimePort}`, + ); await preflightExistingVite(); const backend = await ensureBackend({ onBackendChild(child) { @@ -650,6 +728,8 @@ function isDirectModuleExecution() { } export { + buildBackendStartArguments, + buildViteStartArguments, ensureBackend, formatChildFailure, isDirectModuleExecution, @@ -657,6 +737,7 @@ export { preflightExistingVite, readChildFailure, readLinuxProcessGroupAlive, + resolveDevStackProfile, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 5c6bba17e..6419a9f13 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { preflightExistingVite, + resolveDevStackProfile, spawnChild, stopChild, terminateChildTree, @@ -15,24 +16,43 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js'); function parseLauncherArguments(argv) { const args = [...argv]; + const appRun = args[0] === '--app-run'; + if (appRun) { + args.shift(); + } const gameChat = args[0] === '--game-chat'; if (gameChat) { args.shift(); } - return { gameChat, args }; + if (appRun && gameChat) { + throw new Error('app-run profile 不能与 game-chat 入口同时使用'); + } + return { appRun, gameChat, args }; } function buildTauriArguments(argv) { - const { gameChat, args } = parseLauncherArguments(argv); + const { appRun, gameChat, args } = parseLauncherArguments(argv); + if (appRun) { + return [ + 'dev', + '--config', + 'src-tauri/tauri.app-run-dev.conf.json', + ...args, + ]; + } if (gameChat) { return ['dev', '--', '--', '--game-chat', ...args]; } return ['dev', ...args]; } -function spawnTauriCli(argv) { +function spawnTauriCli(argv, { profileName = 'default' } = {}) { return spawnChild(process.execPath, [tauriCliPath, ...argv], { cwd: appRoot, + env: { + ...process.env, + GENARRATIVE_AGC_DEV_PROFILE: profileName, + }, shell: false, }); } @@ -46,10 +66,12 @@ async function runTauriDev( terminateTree = terminateChildTree, } = {}, ) { - await preflight(); + const { appRun } = parseLauncherArguments(argv); + const profile = resolveDevStackProfile(appRun ? 'app-run' : 'default'); + await preflight({ profile }); const tauriArguments = buildTauriArguments(argv); - const child = spawnCli(tauriArguments); + const child = spawnCli(tauriArguments, { profileName: profile.name }); let resolveShutdown; let shutdownSignal = ''; let repeatedSignal = false; diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.app-run-dev.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.app-run-dev.conf.json new file mode 100644 index 000000000..5471b636c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tauri.app-run-dev.conf.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Genarrative AI Game Creator App Run", + "identifier": "world.genarrative.ai-game-creator.app-run", + "build": { + "devUrl": "http://127.0.0.1:3081/" + }, + "app": { + "windows": [ + { + "label": "client", + "title": "AI 游戏创作 · App Run", + "url": "index.html", + "width": 1280, + "height": 800, + "minWidth": 1280, + "minHeight": 800 + } + ] + } +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 19e02ca38..dd1692763 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3740,9 +3740,9 @@ iframe.preview-frame { } .game-workbench-toolbar { - display: flex; + display: grid; + grid-template-columns: max-content minmax(0, 1fr); align-items: center; - justify-content: space-between; gap: 12px; min-height: 48px; padding: 8px 12px; @@ -3751,19 +3751,33 @@ iframe.preview-frame { } .game-workbench-tabs, -.game-workbench-view-actions { +.game-workbench-view-actions, +.game-resource-layout-switch { display: flex; align-items: center; - flex-wrap: wrap; gap: 7px; } .game-workbench-tabs { + flex: 0 0 auto; + flex-wrap: wrap; padding: 3px; border-radius: 999px; background: #f3ded3; } +.game-workbench-view-actions { + min-width: 0; + width: 100%; + flex-wrap: nowrap; + justify-content: flex-end; +} + +.game-resource-layout-switch { + flex: 0 0 auto; + flex-wrap: nowrap; +} + .game-workbench-tabs button, .game-workbench-view-actions button { display: inline-flex; @@ -3801,11 +3815,19 @@ iframe.preview-frame { .game-resource-reorder-status { min-width: 0; + flex: 1 1 auto; + max-width: min(240px, 28vw); + overflow: hidden; color: #9a725f; font-size: 10px; + text-overflow: ellipsis; white-space: nowrap; } +.game-resource-reorder-status:empty { + display: none; +} + .game-run-unavailable { margin: 0; padding: 7px 14px; @@ -3913,21 +3935,6 @@ iframe.preview-frame { opacity: 1; } -.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-cyclic, .game-resource-dependency-edge.is-cyclic path { stroke-dashoffset: 4; @@ -3938,11 +3945,6 @@ iframe.preview-frame { stroke-linejoin: round; } -.game-resource-dependency-marker--task path { - fill: #918b87; - stroke-linejoin: round; -} - .game-resource-section { position: relative; z-index: 1; @@ -5227,7 +5229,7 @@ iframe.preview-frame { .game-workbench-toolbar { align-items: stretch; - flex-direction: column; + grid-template-columns: minmax(0, 1fr); } .game-workbench-view-actions { @@ -5252,10 +5254,9 @@ iframe.preview-frame { min-height: 560px; } - .game-workbench-toolbar, - .game-workbench-view-actions { + .game-workbench-toolbar { align-items: stretch; - flex-direction: column; + grid-template-columns: minmax(0, 1fr); } .game-workbench-tabs { 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 38a51a13c..2549b9a50 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 @@ -20,7 +20,6 @@ import { import { type ProjectResourceGraph, type ProjectResourceReferenceEdge, - type ProjectResourceTaskFlow, } from './resourceDependencyGraphModel'; type Point = { @@ -50,22 +49,8 @@ export type ResourceDependencyOverlayHandle = { clearDragPreview: () => void; }; -type TaskFlowPathRefs = { - sourceBranches: Map; - targetBranches: Map; - trunk: SVGPathElement | null; -}; - -type TaskFlowSectionGeometry = NonNullable< - ReturnType -> & { - section: ProjectResourceCanvasSection; -}; - 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; const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [ @@ -103,28 +88,6 @@ function connectionPath(source: Point, target: Point) { }, ${target.x - direction * bend} ${target.y}, ${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 { return { x: rect.x + rect.width / 2, @@ -132,10 +95,6 @@ function rectCenter(rect: Rect): Point { }; } -function average(values: readonly number[]) { - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - function rectAnchor(rect: Rect, direction: 1 | -1): Point { return { x: direction === 1 ? rect.x + rect.width : rect.x, @@ -174,84 +133,6 @@ function referenceGeometry( }; } -function taskFlowGeometry( - flow: ProjectResourceTaskFlow, - rectByResourceId: RectLookup, -) { - const sourceRects = flow.sourceResourceIds.flatMap((resourceId) => { - const rect = rectByResourceId.get(resourceId); - return rect ? [{ resourceId, rect }] : []; - }); - const targetRects = flow.targetResourceIds.flatMap((resourceId) => { - const rect = rectByResourceId.get(resourceId); - return rect ? [{ resourceId, rect }] : []; - }); - if (sourceRects.length === 0 || targetRects.length === 0) { - return null; - } - const sourceCenterX = average( - sourceRects.map(({ rect }) => rectCenter(rect).x), - ); - const targetCenterX = average( - targetRects.map(({ rect }) => rectCenter(rect).x), - ); - const direction: 1 | -1 = targetCenterX >= sourceCenterX ? 1 : -1; - const sourceAnchors = sourceRects.map(({ resourceId, rect }) => ({ - resourceId, - point: rectAnchor(rect, direction), - })); - const targetAnchors = targetRects.map(({ resourceId, rect }) => ({ - resourceId, - point: rectAnchor(rect, direction === 1 ? -1 : 1), - })); - const sourceHub: Point = { - x: - (direction === 1 - ? Math.max(...sourceAnchors.map(({ point }) => point.x)) - : Math.min(...sourceAnchors.map(({ point }) => point.x))) + - direction * TASK_FLOW_HUB_GAP, - y: average(sourceAnchors.map(({ point }) => point.y)), - }; - const targetHub: Point = { - x: - (direction === 1 - ? Math.min(...targetAnchors.map(({ point }) => point.x)) - : Math.max(...targetAnchors.map(({ point }) => point.x))) - - direction * TASK_FLOW_HUB_GAP, - y: average(targetAnchors.map(({ point }) => point.y)), - }; - return { sourceAnchors, targetAnchors, sourceHub, targetHub }; -} - -function taskFlowSectionGeometries( - flow: ProjectResourceTaskFlow, - rectByResourceId: RectLookup, - sectionByResourceId: ReadonlyMap, -): TaskFlowSectionGeometry[] { - return RESOURCE_SECTIONS.flatMap((section) => { - const geometry = taskFlowGeometry( - { - ...flow, - sourceResourceIds: flow.sourceResourceIds.filter( - (resourceId) => sectionByResourceId.get(resourceId) === section, - ), - targetResourceIds: flow.targetResourceIds.filter( - (resourceId) => sectionByResourceId.get(resourceId) === section, - ), - }, - rectByResourceId, - ); - return geometry ? [{ ...geometry, section }] : []; - }); -} - -function taskFlowRenderKey( - flowId: string, - section: ProjectResourceCanvasSection, -) { - return `${flowId}\n${section}`; -} - export const ResourceDependencyOverlay = forwardRef< ResourceDependencyOverlayHandle, ResourceDependencyOverlayProps @@ -262,7 +143,6 @@ export const ResourceDependencyOverlay = forwardRef< const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, ''); const overlayRef = useRef(null); const referencePathRefs = useRef(new Map()); - const taskFlowPathRefs = useRef(new Map()); const activeDragPreviewRef = useRef<(Point & { resourceId: string }) | null>( null, ); @@ -349,128 +229,12 @@ export const ResourceDependencyOverlay = forwardRef< } return result; }, [graph.resourceIds, positions, sectionOrigins, visibleResourceIds]); - const sectionByResourceId = useMemo( - () => - new Map( - positions.map((position) => [position.resourceId, position.section]), - ), - [positions], - ); graphRef.current = graph; positionByResourceIdRef.current = new Map( positions.map((position) => [position.resourceId, position]), ); rectByResourceIdRef.current = rectByResourceId; - const taskFlowRenderEntries = useMemo( - () => - graph.taskFlows.flatMap((flow) => - taskFlowSectionGeometries( - flow, - rectByResourceId, - sectionByResourceId, - ).map((geometry) => ({ - flow, - geometry, - renderKey: taskFlowRenderKey(flow.id, geometry.section), - })), - ), - [graph.taskFlows, rectByResourceId, sectionByResourceId], - ); - - const renderTaskFlows = useMemo( - () => - taskFlowRenderEntries.map(({ flow, geometry, renderKey }) => { - const className = `game-resource-dependency-edge game-resource-dependency-edge--task${ - flow.cyclic ? ' is-cyclic' : '' - }`; - return ( - - {`任务流转:${flow.sourceTaskId} → ${flow.targetTaskId}${ - flow.cyclic ? '(检测到依赖环)' : '' - }`} - {geometry.sourceAnchors.map(({ resourceId, point }) => ( - { - let refs = taskFlowPathRefs.current.get(renderKey); - if (!refs) { - refs = { - sourceBranches: new Map(), - targetBranches: new Map(), - trunk: null, - }; - taskFlowPathRefs.current.set(renderKey, 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" - data-resource-id={resourceId} - d={taskFlowBranchPath(point, geometry.sourceHub)} - /> - ))} - { - let refs = taskFlowPathRefs.current.get(renderKey); - if (!refs) { - refs = { - sourceBranches: new Map(), - targetBranches: new Map(), - trunk: null, - }; - taskFlowPathRefs.current.set(renderKey, 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(renderKey); - if (!refs) { - refs = { - sourceBranches: new Map(), - targetBranches: new Map(), - trunk: null, - }; - taskFlowPathRefs.current.set(renderKey, 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" - data-resource-id={resourceId} - d={taskFlowBranchPath(geometry.targetHub, point)} - markerEnd={`url(#${markerPrefix}-task-flow-arrow)`} - /> - ))} - - ); - }), - [markerPrefix, taskFlowRenderEntries], - ); - const renderReferenceEdges = useMemo( () => graph.referenceEdges.map((edge) => { @@ -508,17 +272,6 @@ export const ResourceDependencyOverlay = forwardRef< [graph.referenceEdges, markerPrefix, rectByResourceId], ); - useLayoutEffect(() => { - const activeRenderKeys = new Set( - taskFlowRenderEntries.map((entry) => entry.renderKey), - ); - for (const renderKey of taskFlowPathRefs.current.keys()) { - if (!activeRenderKeys.has(renderKey)) { - taskFlowPathRefs.current.delete(renderKey); - } - } - }, [taskFlowRenderEntries]); - const updateAffectedGeometry = useCallback( ( affectedResourceIds: ReadonlySet, @@ -550,7 +303,6 @@ export const ResourceDependencyOverlay = forwardRef< index?.referenceEdgeIds.forEach((edgeId) => affectedEdgeIds.add(edgeId), ); - index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId)); } for (const edgeId of affectedEdgeIds) { const referenceEdge = currentGraph.referenceEdgeById.get(edgeId); @@ -560,49 +312,6 @@ export const ResourceDependencyOverlay = forwardRef< if (geometry && path) { path.setAttribute('d', geometry.path); } - continue; - } - const flow = currentGraph.taskFlowById.get(edgeId); - if (!flow) { - continue; - } - const sectionByResourceId = new Map( - Array.from( - positionByResourceIdRef.current.values(), - (position) => [position.resourceId, position.section] as const, - ), - ); - for (const geometry of taskFlowSectionGeometries( - flow, - rectLookup, - sectionByResourceId, - )) { - const paths = taskFlowPathRefs.current.get( - taskFlowRenderKey(flow.id, geometry.section), - ); - if (!paths) { - continue; - } - geometry.sourceAnchors.forEach(({ resourceId, point }) => { - paths.sourceBranches - .get(resourceId) - ?.setAttribute( - 'd', - taskFlowBranchPath(point, geometry.sourceHub), - ); - }); - paths.trunk?.setAttribute( - 'd', - connectionPath(geometry.sourceHub, geometry.targetHub), - ); - geometry.targetAnchors.forEach(({ resourceId, point }) => { - paths.targetBranches - .get(resourceId) - ?.setAttribute( - 'd', - taskFlowBranchPath(geometry.targetHub, point), - ); - }); } } }, @@ -654,22 +363,8 @@ export const ResourceDependencyOverlay = forwardRef< > - - - - {renderTaskFlows} {renderReferenceEdges} ); 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 c45132196..4a514df98 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 @@ -54,7 +54,10 @@ import { type LocalGamePreviewFrameHandle, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; -import { resourceCanvasSectionExtent } from './resourceCanvasLayoutModel'; +import { + resourceCanvasContentWidth, + resourceCanvasSectionExtent, +} from './resourceCanvasLayoutModel'; import { EMPTY_PROJECT_RESOURCE_GRAPH, normalizeProjectResourceGraph, @@ -809,6 +812,14 @@ export default function ProjectDevelopmentView({ ), [resourcePositionsByCategory], ); + const resourceCanvasWidth = useMemo( + () => + resourceCanvasContentWidth( + resourceLayout.positions, + sortMode === 'dependency' ? RESOURCE_DEPENDENCY_VISUAL_GUTTER : 0, + ), + [resourceLayout.positions, sortMode], + ); const focusedResource = resources.find((resource) => resource.id === focusedResourceId) ?? null; const focusedReplacementBindings = @@ -1630,17 +1641,8 @@ export default function ProjectDevelopmentView({
- {mode === 'resources' && !focusedResource ? ( + {mode === 'resources' ? ( <> - {resourceLayoutSaving ? '保存中' : resourceLayoutNotice} - + + +
) : mode === 'run' && currentRunnableVersion ? (