diff --git a/docs/public/js/blueprint-graph.js b/docs/public/js/blueprint-graph.js new file mode 100644 index 00000000..a349d3ae --- /dev/null +++ b/docs/public/js/blueprint-graph.js @@ -0,0 +1,462 @@ +/** + * Blueprint Graph Renderer + * Custom layout algorithm designed specifically for blueprint-style graphs + */ +(function() { + const PIN_COLORS = { + exec: '#ffffff', + entity: '#00a0e0', + component: '#7030c0', + float: '#7ecd32', + int: '#1cc4c4', + bool: '#8c0000', + string: '#e060e0', + any: '#707070' + }; + + const HEADER_CLASSES = { + event: 'event', + function: 'function', + pure: 'pure', + flow: 'flow', + math: 'math', + time: 'time', + debug: 'debug', + variable: 'variable' + }; + + const H_GAP = 50; // Horizontal gap between columns + const V_GAP = 25; // Vertical gap between nodes + const START_X = 20; + const START_Y = 20; + + function estimateNodeSize(node) { + const headerHeight = 28; // Match CSS: min-height 28px + const pinRowHeight = 22; // Match CSS: pin row ~22px + const bodyPadding = 12; // Top + bottom padding + + // Count all pins in body (each pin is its own row now) + const inputExecCount = node.inputs ? node.inputs.filter(p => p.type === 'exec').length : 0; + const inputDataCount = node.inputs ? node.inputs.filter(p => p.type !== 'exec').length : 0; + const outputExecCount = node.outputs ? node.outputs.filter(p => p.type === 'exec' && !p.inHeader).length : 0; + const outputDataCount = node.outputs ? node.outputs.filter(p => p.type !== 'exec' && !p.inHeader).length : 0; + const totalPins = inputExecCount + inputDataCount + outputExecCount + outputDataCount; + + // Calculate height: header + body padding + all pin rows + const bodyHeight = totalPins > 0 ? bodyPadding + (totalPins * pinRowHeight) : 0; + const height = headerHeight + bodyHeight; + + // Calculate width based on content + let maxLabelLen = node.title.length; + if (node.inputs) { + node.inputs.forEach(p => { + const len = (p.label || '').length + (p.value ? String(p.value).length + 3 : 0); + maxLabelLen = Math.max(maxLabelLen, len); + }); + } + if (node.outputs) { + node.outputs.forEach(p => { + maxLabelLen = Math.max(maxLabelLen, (p.label || '').length); + }); + } + + const width = Math.max(110, Math.min(170, maxLabelLen * 8 + 40)); + return { width, height }; + } + + /** + * Smart blueprint layout algorithm + * + * Uses weighted graph analysis: + * - All connections matter (exec has higher weight) + * - Topological sort for X ordering + * - Force-directed optimization for Y positions + */ + function autoLayout(graphData, maxWidth) { + const nodes = graphData.nodes; + const connections = graphData.connections; + + if (nodes.length === 0) return { positions: {}, sizes: {} }; + + // Calculate node sizes + const nodeSizes = {}; + nodes.forEach(n => { nodeSizes[n.id] = estimateNodeSize(n); }); + + // Build maps + const pinToNode = {}; + const nodeById = {}; + nodes.forEach(n => { + nodeById[n.id] = n; + (n.inputs || []).forEach(p => { pinToNode[p.id] = n.id; }); + (n.outputs || []).forEach(p => { pinToNode[p.id] = n.id; }); + }); + + // Build weighted adjacency: outgoing[nodeId] = [{to, weight}] + const outgoing = {}; + const incoming = {}; + nodes.forEach(n => { outgoing[n.id] = []; incoming[n.id] = []; }); + + connections.forEach(c => { + const from = pinToNode[c.from]; + const to = pinToNode[c.to]; + if (!from || !to || from === to) return; + + const weight = c.type === 'exec' ? 3 : 1; + outgoing[from].push({ to, weight }); + incoming[to].push({ from, weight }); + }); + + // Calculate node "depth" using weighted longest path + const nodeDepth = {}; + const visited = new Set(); + const inProcess = new Set(); + + function calcDepth(nodeId) { + if (visited.has(nodeId)) return nodeDepth[nodeId]; + if (inProcess.has(nodeId)) return 0; // Cycle detected + + inProcess.add(nodeId); + + let maxPrevDepth = -1; + incoming[nodeId].forEach(({ from, weight }) => { + const prevDepth = calcDepth(from); + maxPrevDepth = Math.max(maxPrevDepth, prevDepth); + }); + + inProcess.delete(nodeId); + visited.add(nodeId); + nodeDepth[nodeId] = maxPrevDepth + 1; + return nodeDepth[nodeId]; + } + + // Calculate depth for all nodes + nodes.forEach(n => calcDepth(n.id)); + + // Group nodes by depth (column) + const columnNodes = {}; + nodes.forEach(n => { + const depth = nodeDepth[n.id]; + if (!columnNodes[depth]) columnNodes[depth] = []; + columnNodes[depth].push(n.id); + }); + + // Sort columns + const sortedColumns = Object.keys(columnNodes).map(Number).sort((a, b) => a - b); + + // Calculate X positions + const columnX = {}; + let currentX = START_X; + sortedColumns.forEach(col => { + columnX[col] = currentX; + let maxW = 0; + columnNodes[col].forEach(id => { + maxW = Math.max(maxW, nodeSizes[id].width); + }); + currentX += maxW + H_GAP; + }); + + // Initialize Y positions - simple stacking first + const positions = {}; + sortedColumns.forEach(col => { + let y = START_Y; + columnNodes[col].forEach(id => { + positions[id] = { x: columnX[col], y }; + y += nodeSizes[id].height + V_GAP; + }); + }); + + // Force-directed optimization for Y positions (few iterations) + for (let iter = 0; iter < 5; iter++) { + const forces = {}; + nodes.forEach(n => { forces[n.id] = 0; }); + + // Calculate forces from connections + connections.forEach(c => { + const from = pinToNode[c.from]; + const to = pinToNode[c.to]; + if (!from || !to || from === to) return; + + const weight = c.type === 'exec' ? 2 : 1; + const fromY = positions[from].y + nodeSizes[from].height / 2; + const toY = positions[to].y + nodeSizes[to].height / 2; + const diff = toY - fromY; + + // Pull nodes toward each other + forces[from] += diff * 0.1 * weight; + forces[to] -= diff * 0.1 * weight; + }); + + // Apply forces + nodes.forEach(n => { + positions[n.id].y += forces[n.id]; + positions[n.id].y = Math.max(START_Y, positions[n.id].y); + }); + + // Resolve overlaps within columns + sortedColumns.forEach(col => { + const nodesInCol = columnNodes[col]; + nodesInCol.sort((a, b) => positions[a].y - positions[b].y); + + for (let i = 1; i < nodesInCol.length; i++) { + const prevId = nodesInCol[i - 1]; + const currId = nodesInCol[i]; + const minY = positions[prevId].y + nodeSizes[prevId].height + V_GAP; + if (positions[currId].y < minY) { + positions[currId].y = minY; + } + } + }); + } + + return { positions, sizes: nodeSizes }; + } + + function renderPinSvg(type, filled = true) { + const color = PIN_COLORS[type] || PIN_COLORS.any; + if (type === 'exec') { + return ``; + } + return ``; + } + + function renderNode(node, position, size) { + const isEvent = node.category === 'event'; + const headerClass = HEADER_CLASSES[node.category] || 'function'; + + let html = `
`; + html += `
`; + if (isEvent) html += ``; + html += `${node.title}`; + + const headerExec = node.outputs && node.outputs.find(p => p.type === 'exec' && p.inHeader); + if (headerExec) { + html += `${renderPinSvg('exec')}`; + } + html += `
`; + + // Separate exec and data pins (matching node-editor order) + const inputExecPins = (node.inputs || []).filter(p => p.type === 'exec'); + const inputDataPins = (node.inputs || []).filter(p => p.type !== 'exec'); + const outputExecPins = (node.outputs || []).filter(p => p.type === 'exec' && !p.inHeader); + const outputDataPins = (node.outputs || []).filter(p => p.type !== 'exec' && !p.inHeader); + + const hasBody = inputExecPins.length > 0 || inputDataPins.length > 0 || + outputDataPins.length > 0 || outputExecPins.length > 0; + + if (hasBody) { + html += `
`; + + // Input exec pins first + inputExecPins.forEach(pin => { + const filled = pin.connected !== false; + html += `
`; + html += `${renderPinSvg(pin.type, filled)}`; + html += `${pin.label || ''}`; + html += `
`; + }); + + // Input data pins + inputDataPins.forEach(pin => { + const filled = pin.connected !== false; + html += `
`; + html += `${renderPinSvg(pin.type, filled)}`; + html += `${pin.label || ''}`; + if (pin.value !== undefined) html += `${pin.value}`; + html += `
`; + }); + + // Output data pins (pin first, then label - CSS row-reverse will flip them) + outputDataPins.forEach(pin => { + html += `
`; + html += `${renderPinSvg(pin.type)}`; + html += `${pin.label || ''}`; + html += `
`; + }); + + // Output exec pins + outputExecPins.forEach(pin => { + html += `
`; + html += `${renderPinSvg(pin.type)}`; + html += `${pin.label || ''}`; + html += `
`; + }); + + html += `
`; + } + + html += `
`; + return html; + } + + /** + * Setup drag-to-scroll for graph container + * Works with native overflow:auto scrolling + */ + function setupDragScroll(container) { + let isDragging = false; + let startX = 0, startY = 0; + let scrollLeft = 0, scrollTop = 0; + + container.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + isDragging = true; + startX = e.pageX - container.offsetLeft; + startY = e.pageY - container.offsetTop; + scrollLeft = container.scrollLeft; + scrollTop = container.scrollTop; + container.style.cursor = 'grabbing'; + e.preventDefault(); + }); + + container.addEventListener('mousemove', (e) => { + if (!isDragging) return; + const x = e.pageX - container.offsetLeft; + const y = e.pageY - container.offsetTop; + container.scrollLeft = scrollLeft - (x - startX); + container.scrollTop = scrollTop - (y - startY); + }); + + container.addEventListener('mouseup', () => { + isDragging = false; + container.style.cursor = 'grab'; + }); + + container.addEventListener('mouseleave', () => { + isDragging = false; + container.style.cursor = 'grab'; + }); + } + + function renderConnections(container, graphData) { + const svg = container.querySelector('.bp-connections'); + if (!svg) return; + + const content = container.querySelector('.bp-graph-content') || container; + const graphRect = content.getBoundingClientRect(); + + graphData.connections.forEach(c => { + const fromPin = container.querySelector(`[data-pin="${c.from}"]`); + const toPin = container.querySelector(`[data-pin="${c.to}"]`); + if (!fromPin || !toPin) return; + + const fromRect = fromPin.getBoundingClientRect(); + const toRect = toPin.getBoundingClientRect(); + + const x1 = fromRect.left - graphRect.left + fromRect.width / 2; + const y1 = fromRect.top - graphRect.top + fromRect.height / 2; + const x2 = toRect.left - graphRect.left + toRect.width / 2; + const y2 = toRect.top - graphRect.top + toRect.height / 2; + + // Simple bezier curve + const dx = Math.abs(x2 - x1) * 0.5; + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`); + path.setAttribute('class', `bp-conn ${c.type || 'exec'}`); + svg.appendChild(path); + }); + } + + function initBlueprintGraphs() { + document.querySelectorAll('.bp-graph[data-graph]').forEach(container => { + try { + const graphData = JSON.parse(container.dataset.graph); + if (!graphData.nodes || graphData.nodes.length === 0) { + console.warn('Blueprint graph has no nodes'); + return; + } + + // Get container width for layout calculation + let containerWidth = container.parentElement?.offsetWidth || 0; + if (containerWidth < 200) { + containerWidth = 650; + } + + const { positions, sizes } = autoLayout(graphData, containerWidth - 30); + + let maxX = 0, maxY = 0; + graphData.nodes.forEach(n => { + const pos = positions[n.id]; + const size = sizes[n.id]; + if (pos && size) { + maxX = Math.max(maxX, pos.x + size.width); + maxY = Math.max(maxY, pos.y + size.height); + } + }); + + // Add generous padding to ensure all nodes visible + maxX += 80; + maxY += 80; + + // Set minimum height but allow natural expansion + const containerHeight = Math.max(maxY, 200); + container.style.minHeight = containerHeight + 'px'; + + let html = `
`; + html += ``; + graphData.nodes.forEach(n => { + if (positions[n.id] && sizes[n.id]) { + html += renderNode(n, positions[n.id], sizes[n.id]); + } + }); + html += `
`; + container.innerHTML = html; + + // Setup drag-to-scroll + setupDragScroll(container); + + requestAnimationFrame(() => renderConnections(container, graphData)); + } catch (e) { + console.error('Blueprint graph error:', e); + } + }); + + // Legacy format + document.querySelectorAll('.bp-graph:not([data-graph])').forEach(graph => { + const nodes = graph.querySelectorAll('.bp-node'); + let maxX = 0, maxY = 0; + nodes.forEach(node => { + const left = parseInt(node.style.left) || 0; + const top = parseInt(node.style.top) || 0; + const width = parseInt(node.style.width) || 150; + maxX = Math.max(maxX, left + width + 40); + maxY = Math.max(maxY, top + node.offsetHeight + 40); + }); + // Don't set fixed width - let CSS handle it + graph.style.minHeight = Math.max(maxY, 120) + 'px'; + + const svg = graph.querySelector('.bp-connections'); + if (!svg) return; + svg.setAttribute('width', maxX); + svg.setAttribute('height', Math.max(maxY, 120)); + + const conns = JSON.parse(graph.dataset.connections || '[]'); + const graphRect = graph.getBoundingClientRect(); + + conns.forEach(c => { + const fromPin = graph.querySelector(`[data-pin="${c.from}"]`); + const toPin = graph.querySelector(`[data-pin="${c.to}"]`); + if (!fromPin || !toPin) return; + + const fromRect = fromPin.getBoundingClientRect(); + const toRect = toPin.getBoundingClientRect(); + + const x1 = fromRect.left - graphRect.left + fromRect.width / 2; + const y1 = fromRect.top - graphRect.top + fromRect.height / 2; + const x2 = toRect.left - graphRect.left + toRect.width / 2; + const y2 = toRect.top - graphRect.top + toRect.height / 2; + + const dx = Math.abs(x2 - x1) * 0.5; + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`); + path.setAttribute('class', `bp-conn ${c.type || 'exec'}`); + svg.appendChild(path); + }); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initBlueprintGraphs); + } else { + initBlueprintGraphs(); + } +})(); diff --git a/docs/src/content/docs/en/modules/blueprint/editor-guide.md b/docs/src/content/docs/en/modules/blueprint/editor-guide.md index 856ca339..d6089de1 100644 --- a/docs/src/content/docs/en/modules/blueprint/editor-guide.md +++ b/docs/src/content/docs/en/modules/blueprint/editor-guide.md @@ -3,6 +3,8 @@ title: "Blueprint Editor User Guide" description: "Complete guide for using the Cocos Creator Blueprint Visual Scripting Editor" --- + + This guide covers how to use the Blueprint Visual Scripting Editor in Cocos Creator. ## Download & Installation @@ -345,50 +347,244 @@ Blueprints are saved as JSON, compatible with `@esengine/blueprint` runtime: Move entity every frame: -``` -[Event Tick] ─Exec─→ [Get Self] ─Entity─→ [Get Component: Transform] - │ - [Get Delta Time] ▼ - │ [Set Property: x] - │ │ - [Multiply] ◄──────────────┘ - │ - └─ Speed: 100 -``` +
+ +
+
+ + Event Tick + +
+
+
+ + Delta Time +
+
+
+
+
Multiply
+
+
+ + A +
+
+ + B (Speed) + 100 +
+
+ + Result +
+
+
+
+
Set Property
+
+
+ + Exec +
+
+ + Target +
+
+ + x +
+
+
+
### Example 2: Health System -Check death after taking damage: +Check death after taking damage. `Event OnDamage` is a custom event node that can be triggered from code via `vm.triggerCustomEvent('OnDamage', { damage: 50 })`: -``` -[On Damage Event] ─→ [Get Component: Health] ─→ [Get Property: current] - │ - ▼ - [Subtract] - │ - ▼ - [Set Property: current] - │ - ▼ - ┌─ True ─→ [Destroy Self] - [Branch]─┤ - └─ False ─→ (continue) - ▲ - │ - [Less Or Equal] - │ - current <= 0 -``` +
### Example 3: Delayed Spawning Spawn an enemy every 2 seconds: -``` -[Event BeginPlay] ─→ [Do N Times] ─Loop─→ [Delay: 2.0] ─→ [Create Entity: Enemy] - │ - └─ N: 10 -``` +
+ +
+
+ + Event BeginPlay + +
+
+
+
Do N Times
+
+
+ + Exec +
+
+ + N + 10 +
+
+ + Loop Body +
+
+ + Index +
+
+ + Completed +
+
+
+
+
Delay
+
+
+ + Exec +
+
+ + Duration + 2.0 +
+
+ + Done +
+
+
+
+
Create Entity
+
+
+ + Exec +
+
+ + Name + "Enemy" +
+
+ +
+
+ + Entity +
+
+
+
## Troubleshooting diff --git a/docs/src/content/docs/modules/blueprint/editor-guide.md b/docs/src/content/docs/modules/blueprint/editor-guide.md index 32ee2bc6..9ff713b3 100644 --- a/docs/src/content/docs/modules/blueprint/editor-guide.md +++ b/docs/src/content/docs/modules/blueprint/editor-guide.md @@ -3,6 +3,8 @@ title: "蓝图编辑器使用指南" description: "Cocos Creator 蓝图可视化脚本编辑器完整使用教程" --- + + 本指南介绍如何在 Cocos Creator 中使用蓝图可视化脚本编辑器。 ## 下载与安装 @@ -93,10 +95,37 @@ your-project/ | **Event EndPlay** | 蓝图停止时 | Exec | **示例:游戏开始时打印消息** -``` -[Event BeginPlay] ──Exec──→ [Print] - └─ Message: "游戏开始!" -``` + +
+ +
+
+ + Event BeginPlay + +
+
+
+ + Self +
+
+
+
+
Print
+
+
+ + Exec +
+
+ + Message + "游戏开始!" +
+
+
+
### 实体节点 (Entity) @@ -114,10 +143,52 @@ your-project/ | **Set Active** | 设置激活状态 | Exec, Entity, Active | Exec | **示例:创建新实体** -``` -[Event BeginPlay] ──→ [Create Entity] ──→ [Add Component] - └─ Name: "Bullet" └─ Type: Transform -``` + +
+ +
+
+ + Event BeginPlay + +
+
+
+
Create Entity
+
+
+ + Exec +
+
+ + Name + "Bullet" +
+
+ + Exec +
+
+ + Entity +
+
+
+
+
Add Transform
+
+
+ + Exec +
+
+ + Entity +
+
+
+
### 组件节点 (Component) @@ -132,11 +203,50 @@ your-project/ | **Get/Set Property** | 获取/设置组件属性 | **示例:修改 Transform 组件** -``` -[Get Self] ─Entity─→ [Get Component: Transform] ─Component─→ [Set Property] - ├─ Property: x - └─ Value: 100 -``` + +
+ +
+
Get Self
+
+
+ + Entity +
+
+
+
+
Get Component
+
+
+ + Entity +
+
+ + Transform +
+
+
+
+
Set Property
+
+
+ + Exec +
+
+ + Target +
+
+ + x + 100 +
+
+
+
### 流程控制节点 (Flow) @@ -146,42 +256,161 @@ your-project/ 条件判断,类似 if/else。 -``` - ┌─ True ──→ [DoSomething] -[Branch]─┤ - └─ False ─→ [DoOtherThing] -``` +
+ +
+
Branch
+
+
+ + Exec +
+
+ + Condition +
+
+ + True +
+
+ + False +
+
+
+
+
DoSomething
+
+
+ + Exec +
+
+
+
+
DoOtherThing
+
+
+ + Exec +
+
+
+
#### Sequence (序列) 按顺序执行多个分支。 -``` - ┌─ Then 0 ──→ [Step1] -[Sequence]─┼─ Then 1 ──→ [Step2] - └─ Then 2 ──→ [Step3] -``` +
+ +
+
Sequence
+
+
+ + Exec +
+
+ + Then 0 +
+
+ + Then 1 +
+
+ + Then 2 +
+
+
+
+
Step 1
+
+
+ + Exec +
+
+
+
+
Step 2
+
+
+ + Exec +
+
+
+
+
Step 3
+
+
+ + Exec +
+
+
+
#### For Loop (循环) 循环执行指定次数。 -``` -[For Loop] ─Loop Body─→ [每次迭代执行] - │ - └─ Completed ────→ [循环结束后执行] -``` - -| 输入 | 说明 | -|------|------| -| First Index | 起始索引 | -| Last Index | 结束索引 | - -| 输出 | 说明 | -|------|------| -| Loop Body | 每次迭代执行 | -| Index | 当前索引 | -| Completed | 循环结束后执行 | +
+ +
+
For Loop
+
+
+ + Exec +
+
+ + First Index + 0 +
+
+ + Last Index + 10 +
+
+ + Loop Body +
+
+ + Index +
+
+ + Completed +
+
+
+
+
每次迭代执行
+
+
+ + Exec +
+
+
+
+
循环结束后执行
+
+
+ + Exec +
+
+
+
#### For Each (遍历) @@ -212,10 +441,49 @@ your-project/ | **Get Time** | 获取运行总时间 | Number | **示例:延迟 2 秒后执行** -``` -[Event BeginPlay] ──→ [Delay] ──→ [Print] - └─ Duration: 2.0 └─ "2秒后执行" -``` + +
+ +
+
+ + Event BeginPlay + +
+
+
+
Delay
+
+
+ + Exec +
+
+ + Duration + 2.0 +
+
+ + Done +
+
+
+
+
Print
+
+
+ + Exec +
+
+ + Msg + "2秒后执行" +
+
+
+
### 数学节点 (Math) @@ -345,50 +613,244 @@ your-project/ 实现每帧移动实体: -``` -[Event Tick] ─Exec─→ [Get Self] ─Entity─→ [Get Component: Transform] - │ - [Get Delta Time] ▼ - │ [Set Property: x] - │ │ - [Multiply] ◄──────────────┘ - │ - └─ Speed: 100 -``` +
+ +
+
+ + Event Tick + +
+
+
+ + Delta Time +
+
+
+
+
Multiply
+
+
+ + A +
+
+ + B (Speed) + 100 +
+
+ + Result +
+
+
+
+
Set Property
+
+
+ + Exec +
+
+ + Target +
+
+ + x +
+
+
+
### 示例 2:生命值系统 -受伤后检查死亡: +受伤后检查死亡逻辑。`Event OnDamage` 是一个自定义事件节点,可以通过代码 `vm.triggerCustomEvent('OnDamage', { damage: 50 })` 触发: -``` -[On Damage Event] ─→ [Get Component: Health] ─→ [Get Property: current] - │ - ▼ - [Subtract] - │ - ▼ - [Set Property: current] - │ - ▼ - ┌─ True ─→ [Destroy Self] - [Branch]─┤ - └─ False ─→ (继续) - ▲ - │ - [Less Or Equal] - │ - current <= 0 -``` +
### 示例 3:延迟生成 每 2 秒生成一个敌人: -``` -[Event BeginPlay] ─→ [Do N Times] ─Loop─→ [Delay: 2.0] ─→ [Create Entity: Enemy] - │ - └─ N: 10 -``` +
+ +
+
+ + Event BeginPlay + +
+
+
+
Do N Times
+
+
+ + Exec +
+
+ + N + 10 +
+
+ + Loop Body +
+
+ + Index +
+
+ + Completed +
+
+
+
+
Delay
+
+
+ + Exec +
+
+ + Duration + 2.0 +
+
+ + Done +
+
+
+
+
Create Entity
+
+
+ + Exec +
+
+ + Name + "Enemy" +
+
+ +
+
+ + Entity +
+
+
+
## 常见问题 diff --git a/docs/src/styles/custom.css b/docs/src/styles/custom.css index 0c994a63..33416e10 100644 --- a/docs/src/styles/custom.css +++ b/docs/src/styles/custom.css @@ -236,3 +236,257 @@ nav.sidebar-content ul li a[aria-current="page"] { font-size: 2rem; } } + +/* ==================== Blueprint Node Visualization ==================== */ +/* Matches the actual node-editor component styles */ + +/* Graph Container - 使用固定像素坐标 */ +.bp-graph { + position: relative; + background: #141419; + background-image: + linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px); + background-size: 20px 20px; + border-radius: 8px; + margin: 1.5rem 0; + border: 1px solid #2a2a35; + overflow: auto; + cursor: grab; +} + +.bp-graph:active { + cursor: grabbing; +} + +/* Inner container for panning */ +.bp-graph-content { + position: relative; + transform-origin: 0 0; +} + +/* SVG Connections Layer */ +.bp-connections { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + overflow: visible; +} + +/* Node absolute positioning in graph */ +.bp-graph > .bp-node { + position: absolute; +} + +/* Connection paths */ +.bp-conn { + fill: none; + stroke-width: 2px; + stroke-linecap: round; +} + +.bp-conn.exec { stroke: #ffffff; stroke-width: 3px; } +.bp-conn.float { stroke: #7ecd32; } +.bp-conn.int { stroke: #1cc4c4; } +.bp-conn.bool { stroke: #8c0000; } +.bp-conn.string { stroke: #e060e0; } +.bp-conn.object { stroke: #00a0e0; } +.bp-conn.entity { stroke: #00a0e0; } +.bp-conn.component { stroke: #7030c0; } +.bp-conn.array { stroke: #7030c0; } +.bp-conn.any { stroke: #707070; } + +/* ==================== Node Container ==================== */ +.bp-node { + position: relative; + min-width: 140px; + background: rgba(12, 12, 16, 0.95); + border: 1px solid rgba(40, 40, 50, 0.8); + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); + font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 11px; + flex-shrink: 0; + box-sizing: border-box; +} + +/* ==================== Node Header ==================== */ +/* 与 node-editor 完全一致 */ +.bp-node-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + min-height: 28px; + border-radius: 5px 5px 0 0; + color: #fff; + font-size: 12px; + font-weight: 500; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); + position: relative; +} + +/* Header icon - diamond for events */ +.bp-node-header-icon { + width: 8px; + height: 8px; + flex-shrink: 0; +} + +.bp-node-header.event .bp-node-header-icon { + background: #fff; + transform: rotate(45deg); + border-radius: 1px; + box-shadow: 0 0 4px rgba(255, 255, 255, 0.5); +} + +/* Header title */ +.bp-node-header-title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Header exec pin (for event nodes) - at right edge */ +/* 与 node-editor 完全一致 */ +.bp-header-exec { + width: 12px; + height: 12px; + margin-left: auto; + flex-shrink: 0; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.bp-header-exec svg { + display: block; +} + +/* Category Colors */ +.bp-node-header.event { + background: linear-gradient(180deg, #b81c1c 0%, #6b1010 100%); +} + +.bp-node-header.function { + background: linear-gradient(180deg, #1b6eb5 0%, #0d3d66 100%); +} + +.bp-node-header.pure { + background: linear-gradient(180deg, #3d8b3d 0%, #1f5a1f 100%); +} + +.bp-node-header.flow { + background: linear-gradient(180deg, #4a4a4a 0%, #2a2a2a 100%); +} + +.bp-node-header.variable { + background: linear-gradient(180deg, #7b3d9b 0%, #4a1f66 100%); +} + +.bp-node-header.debug { + background: linear-gradient(180deg, #6a6a6a 0%, #3a3a3a 100%); +} + +.bp-node-header.time { + background: linear-gradient(180deg, #1cc4c4 0%, #0d7070 100%); +} + +.bp-node-header.math { + background: linear-gradient(180deg, #7ecd32 0%, #4a7a1e 100%); +} + +/* ==================== Node Body ==================== */ +/* 与 node-editor 完全一致 */ +.bp-node-body { + padding: 6px 0; + background: rgba(8, 8, 12, 0.95); + border-radius: 0 0 5px 5px; +} + +/* ==================== Pin Row ==================== */ +/* 与 node-editor 完全一致 */ +.bp-pin-row { + display: flex; + align-items: center; + gap: 8px; + min-height: 20px; + padding: 1px 0; + position: relative; + box-sizing: border-box; +} + +.bp-pin-row.input { + flex-direction: row; + justify-content: flex-start; + padding-left: 10px; + padding-right: 16px; +} + +.bp-pin-row.output { + flex-direction: row-reverse; + justify-content: flex-start; + padding-right: 10px; + padding-left: 16px; +} + +/* ==================== Pin (SVG-based) ==================== */ +.bp-pin { + width: 12px; + height: 12px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.bp-pin svg { + display: block; +} + +/* Pin Label */ +.bp-pin-label { + color: #b0b0b0; + font-size: 11px; + white-space: nowrap; + line-height: 1; +} + +.bp-pin-row.output .bp-pin-label { + text-align: right; +} + +/* Pin Value */ +.bp-pin-value { + color: #7ecd32; + font-size: 10px; + margin-left: auto; + font-family: 'Consolas', 'Monaco', monospace; +} + +.bp-pin-row.output .bp-pin-value { + margin-left: 0; + margin-right: auto; +} + +/* ==================== Legend ==================== */ +.bp-legend { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 10px; + background: rgba(20, 20, 25, 0.8); + border-radius: 6px; + margin: 1rem 0; +} + +.bp-legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: #a0a0a0; +}