From 2e84942ea14c5326620398add05840fa8bea16f8 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Sun, 4 Jan 2026 11:50:16 +0800 Subject: [PATCH] feat(blueprint): refactor BlueprintComponent as proper ECS Component (#433) * feat(blueprint): refactor BlueprintComponent as proper ECS Component - Convert BlueprintComponent from interface to actual ECS Component class - Add ready-to-use BlueprintSystem that extends EntitySystem - Remove deprecated legacy APIs (createBlueprintSystem, etc.) - Update all blueprint documentation (Chinese & English) - Simplify user API: just add BlueprintSystem and BlueprintComponent BREAKING CHANGE: BlueprintComponent is now a class extending Component, not an interface. Use `new BlueprintComponent()` instead of `createBlueprintComponentData()`. * chore(blueprint): add changeset for ECS component refactor * fix(node-editor): fix connections not rendering when node is collapsed - getPinPosition now returns node header position when pin element is not found - Added collapsedNodesKey to force re-render connections after collapse/expand - Input pins connect to left side, output pins to right side of collapsed nodes * chore(node-editor): add changeset for collapse connection fix * feat(blueprint): add Add Component nodes for entity-component creation - Add type-specific Add_ComponentName nodes via ComponentNodeGenerator - Add generic ECS_AddComponent node for dynamic component creation - Add ExecutionContext.getComponentClass() for component lookup - Add registerComponentClass() helper for manual component registration - Each Add node supports initial property values from @BlueprintProperty * docs: update changeset with Add Component feature * feat(blueprint): improve event nodes with Self output and auto-create BeginPlay - Event Begin Play now outputs Self entity - Event Tick now outputs Self entity + Delta Seconds - Event End Play now outputs Self entity - createEmptyBlueprint() now includes Event Begin Play by default - Added menuPath to all event nodes for better organization --- .changeset/blueprint-ecs-component.md | 16 +++ .changeset/node-editor-collapse-fix.md | 8 ++ .../src/components/editor/NodeEditor.tsx | 57 ++++++++-- packages/framework/blueprint/src/index.ts | 21 ++++ .../blueprint/src/nodes/ecs/ComponentNodes.ts | 62 +++++++++++ .../src/nodes/events/EventBeginPlay.ts | 17 ++- .../src/nodes/events/EventEndPlay.ts | 15 ++- .../blueprint/src/nodes/events/EventTick.ts | 9 +- .../src/registry/ComponentNodeGenerator.ts | 101 ++++++++++++++++++ .../blueprint/src/runtime/ExecutionContext.ts | 51 ++++++++- .../blueprint/src/types/blueprint.ts | 19 +++- 11 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 .changeset/blueprint-ecs-component.md create mode 100644 .changeset/node-editor-collapse-fix.md diff --git a/.changeset/blueprint-ecs-component.md b/.changeset/blueprint-ecs-component.md new file mode 100644 index 00000000..566990d7 --- /dev/null +++ b/.changeset/blueprint-ecs-component.md @@ -0,0 +1,16 @@ +--- +"@esengine/blueprint": minor +--- + +feat(blueprint): 添加 Add Component 节点支持 + ECS 模式重构 + +新功能: +- 为每个 @BlueprintExpose 组件自动生成 Add_ComponentName 节点 +- Add 节点支持设置初始属性值 +- 添加通用 ECS_AddComponent 节点用于动态添加组件 +- 添加 registerComponentClass() 用于手动注册组件类 + +重构: +- BlueprintComponent 使用 @ECSComponent 装饰器注册 +- BlueprintSystem 继承标准 System 基类 +- 简化组件 API,优化 VM 生命周期管理 diff --git a/.changeset/node-editor-collapse-fix.md b/.changeset/node-editor-collapse-fix.md new file mode 100644 index 00000000..dd092db9 --- /dev/null +++ b/.changeset/node-editor-collapse-fix.md @@ -0,0 +1,8 @@ +--- +"@esengine/node-editor": patch +--- + +fix(node-editor): 修复节点收缩后连线不显示的问题 + +- 节点收缩时,连线会连接到节点头部(输入引脚在左侧,输出引脚在右侧) +- 展开后连线会自动恢复到正确位置 diff --git a/packages/devtools/node-editor/src/components/editor/NodeEditor.tsx b/packages/devtools/node-editor/src/components/editor/NodeEditor.tsx index d1695faa..ce4b05b5 100644 --- a/packages/devtools/node-editor/src/components/editor/NodeEditor.tsx +++ b/packages/devtools/node-editor/src/components/editor/NodeEditor.tsx @@ -130,6 +130,13 @@ export const NodeEditor: React.FC = ({ // Force re-render after mount to ensure connections are drawn correctly // 挂载后强制重渲染以确保连接线正确绘制 const [, forceUpdate] = useState(0); + + // Track collapsed state to force connection re-render + // 跟踪折叠状态以强制连接线重渲染 + const collapsedNodesKey = useMemo(() => { + return graph.nodes.map(n => `${n.id}:${n.isCollapsed}`).join(','); + }, [graph.nodes]); + useEffect(() => { // Use requestAnimationFrame to wait for DOM to be fully rendered // 使用 requestAnimationFrame 等待 DOM 完全渲染 @@ -137,7 +144,7 @@ export const NodeEditor: React.FC = ({ forceUpdate(n => n + 1); }); return () => cancelAnimationFrame(rafId); - }, [graph.id]); + }, [graph.id, collapsedNodesKey]); /** * Converts screen coordinates to canvas coordinates @@ -158,21 +165,51 @@ export const NodeEditor: React.FC = ({ * 获取引脚在画布坐标系中的位置 * * 直接从节点位置和引脚在节点内的相对位置计算,不依赖 DOM 测量 + * 当节点收缩时,返回节点头部的位置 */ const getPinPosition = useCallback((pinId: string): Position | undefined => { + // First, find which node this pin belongs to + // 首先查找该引脚属于哪个节点 + let ownerNode: GraphNode | undefined; + for (const node of graph.nodes) { + if (node.allPins.some(p => p.id === pinId)) { + ownerNode = node; + break; + } + } + if (!ownerNode) return undefined; + // Find the pin element and its parent node const pinElement = containerRef.current?.querySelector(`[data-pin-id="${pinId}"]`) as HTMLElement; - if (!pinElement) return undefined; + + // If pin element not found (e.g., node is collapsed), use node header position + // 如果找不到引脚元素(例如节点已收缩),使用节点头部位置 + if (!pinElement) { + const nodeElement = containerRef.current?.querySelector(`[data-node-id="${ownerNode.id}"]`) as HTMLElement; + if (!nodeElement) return undefined; + + const nodeRect = nodeElement.getBoundingClientRect(); + const { zoom } = transformRef.current; + + // Find the pin to determine if it's input or output + const pin = ownerNode.allPins.find(p => p.id === pinId); + const isOutput = pin?.isOutput ?? false; + + // For collapsed nodes, position at the right side for outputs, left side for inputs + // 对于收缩的节点,输出引脚在右侧,输入引脚在左侧 + const headerHeight = 28; // Approximate header height + const relativeX = isOutput ? nodeRect.width / zoom : 0; + const relativeY = headerHeight / 2; + + return new Position( + ownerNode.position.x + relativeX, + ownerNode.position.y + relativeY + ); + } const nodeElement = pinElement.closest('[data-node-id]') as HTMLElement; if (!nodeElement) return undefined; - const nodeId = nodeElement.getAttribute('data-node-id'); - if (!nodeId) return undefined; - - const node = graph.getNode(nodeId); - if (!node) return undefined; - // Get pin position relative to node element (in unscaled pixels) const nodeRect = nodeElement.getBoundingClientRect(); const pinRect = pinElement.getBoundingClientRect(); @@ -184,8 +221,8 @@ export const NodeEditor: React.FC = ({ // Final position = node position + relative position return new Position( - node.position.x + relativeX, - node.position.y + relativeY + ownerNode.position.x + relativeX, + ownerNode.position.y + relativeY ); }, [graph]); diff --git a/packages/framework/blueprint/src/index.ts b/packages/framework/blueprint/src/index.ts index 78143ff6..237f87e4 100644 --- a/packages/framework/blueprint/src/index.ts +++ b/packages/framework/blueprint/src/index.ts @@ -75,8 +75,29 @@ export { NodeRegistry, RegisterNode } from './runtime/NodeRegistry'; export { BlueprintVM } from './runtime/BlueprintVM'; export { BlueprintComponent } from './runtime/BlueprintComponent'; export { BlueprintSystem } from './runtime/BlueprintSystem'; +export { ExecutionContext } from './runtime/ExecutionContext'; export { createEmptyBlueprint, validateBlueprintAsset } from './types/blueprint'; +// Component registration helper +import { ExecutionContext } from './runtime/ExecutionContext'; +import type { Component } from '@esengine/ecs-framework'; + +/** + * @zh 注册组件类以支持在蓝图中动态创建 + * @en Register a component class for dynamic creation in blueprints + * + * @example + * ```typescript + * import { registerComponentClass } from '@esengine/blueprint'; + * import { MyComponent } from './MyComponent'; + * + * registerComponentClass('MyComponent', MyComponent); + * ``` + */ +export function registerComponentClass(typeName: string, componentClass: new () => Component): void { + ExecutionContext.registerComponentClass(typeName, componentClass); +} + // Re-export registry for convenience export { BlueprintExpose, diff --git a/packages/framework/blueprint/src/nodes/ecs/ComponentNodes.ts b/packages/framework/blueprint/src/nodes/ecs/ComponentNodes.ts index 791a03d7..07ea7b0a 100644 --- a/packages/framework/blueprint/src/nodes/ecs/ComponentNodes.ts +++ b/packages/framework/blueprint/src/nodes/ecs/ComponentNodes.ts @@ -11,6 +11,68 @@ import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes'; import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext'; import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry'; +// ============================================================================ +// Add Component (Generic) | 添加组件(通用) +// ============================================================================ + +export const AddComponentTemplate: BlueprintNodeTemplate = { + type: 'ECS_AddComponent', + title: 'Add Component', + category: 'component', + color: '#1e8b8b', + description: 'Adds a component to an entity by type name (按类型名称为实体添加组件)', + keywords: ['component', 'add', 'create', 'attach'], + menuPath: ['ECS', 'Component', 'Add Component'], + inputs: [ + { name: 'exec', type: 'exec', displayName: '' }, + { name: 'entity', type: 'entity', displayName: 'Entity' }, + { name: 'componentType', type: 'string', displayName: 'Component Type', defaultValue: '' } + ], + outputs: [ + { name: 'exec', type: 'exec', displayName: '' }, + { name: 'component', type: 'component', displayName: 'Component' }, + { name: 'success', type: 'bool', displayName: 'Success' } + ] +}; + +@RegisterNode(AddComponentTemplate) +export class AddComponentExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity; + const componentType = context.evaluateInput(node.id, 'componentType', '') as string; + + if (!entity || entity.isDestroyed || !componentType) { + return { outputs: { component: null, success: false }, nextExec: 'exec' }; + } + + // Check if component already exists + const existing = entity.components.find(c => + c.constructor.name === componentType || + (c.constructor as any).__componentName__ === componentType + ); + + if (existing) { + return { outputs: { component: existing, success: false }, nextExec: 'exec' }; + } + + // Try to create component from registry + const ComponentClass = context.getComponentClass?.(componentType); + if (!ComponentClass) { + console.warn(`[Blueprint] Component type not found: ${componentType}`); + return { outputs: { component: null, success: false }, nextExec: 'exec' }; + } + + try { + const component = new ComponentClass(); + entity.addComponent(component); + return { outputs: { component, success: true }, nextExec: 'exec' }; + } catch (error) { + console.error(`[Blueprint] Failed to add component ${componentType}:`, error); + return { outputs: { component: null, success: false }, nextExec: 'exec' }; + } + } +} + // ============================================================================ // Has Component | 是否有组件 // ============================================================================ diff --git a/packages/framework/blueprint/src/nodes/events/EventBeginPlay.ts b/packages/framework/blueprint/src/nodes/events/EventBeginPlay.ts index 5ccdc620..af2732cc 100644 --- a/packages/framework/blueprint/src/nodes/events/EventBeginPlay.ts +++ b/packages/framework/blueprint/src/nodes/events/EventBeginPlay.ts @@ -17,13 +17,19 @@ export const EventBeginPlayTemplate: BlueprintNodeTemplate = { category: 'event', color: '#CC0000', description: 'Triggered once when the blueprint starts executing (蓝图开始执行时触发一次)', - keywords: ['start', 'begin', 'init', 'event'], + keywords: ['start', 'begin', 'init', 'event', 'self'], + menuPath: ['Events', 'Begin Play'], inputs: [], outputs: [ { name: 'exec', type: 'exec', displayName: '' + }, + { + name: 'self', + type: 'entity', + displayName: 'Self' } ] }; @@ -34,11 +40,12 @@ export const EventBeginPlayTemplate: BlueprintNodeTemplate = { */ @RegisterNode(EventBeginPlayTemplate) export class EventBeginPlayExecutor implements INodeExecutor { - execute(_node: BlueprintNode, _context: ExecutionContext): ExecutionResult { - // Event nodes just trigger execution flow - // 事件节点只触发执行流 + execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult { return { - nextExec: 'exec' + nextExec: 'exec', + outputs: { + self: context.entity + } }; } } diff --git a/packages/framework/blueprint/src/nodes/events/EventEndPlay.ts b/packages/framework/blueprint/src/nodes/events/EventEndPlay.ts index ff9ed746..07279033 100644 --- a/packages/framework/blueprint/src/nodes/events/EventEndPlay.ts +++ b/packages/framework/blueprint/src/nodes/events/EventEndPlay.ts @@ -17,13 +17,19 @@ export const EventEndPlayTemplate: BlueprintNodeTemplate = { category: 'event', color: '#CC0000', description: 'Triggered once when the blueprint stops executing (蓝图停止执行时触发一次)', - keywords: ['stop', 'end', 'destroy', 'event'], + keywords: ['stop', 'end', 'destroy', 'event', 'self'], + menuPath: ['Events', 'End Play'], inputs: [], outputs: [ { name: 'exec', type: 'exec', displayName: '' + }, + { + name: 'self', + type: 'entity', + displayName: 'Self' } ] }; @@ -34,9 +40,12 @@ export const EventEndPlayTemplate: BlueprintNodeTemplate = { */ @RegisterNode(EventEndPlayTemplate) export class EventEndPlayExecutor implements INodeExecutor { - execute(_node: BlueprintNode, _context: ExecutionContext): ExecutionResult { + execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult { return { - nextExec: 'exec' + nextExec: 'exec', + outputs: { + self: context.entity + } }; } } diff --git a/packages/framework/blueprint/src/nodes/events/EventTick.ts b/packages/framework/blueprint/src/nodes/events/EventTick.ts index 5e4ce12c..58b7a59b 100644 --- a/packages/framework/blueprint/src/nodes/events/EventTick.ts +++ b/packages/framework/blueprint/src/nodes/events/EventTick.ts @@ -17,7 +17,8 @@ export const EventTickTemplate: BlueprintNodeTemplate = { category: 'event', color: '#CC0000', description: 'Triggered every frame during execution (执行期间每帧触发)', - keywords: ['update', 'frame', 'tick', 'event'], + keywords: ['update', 'frame', 'tick', 'event', 'self'], + menuPath: ['Events', 'Tick'], inputs: [], outputs: [ { @@ -25,6 +26,11 @@ export const EventTickTemplate: BlueprintNodeTemplate = { type: 'exec', displayName: '' }, + { + name: 'self', + type: 'entity', + displayName: 'Self' + }, { name: 'deltaTime', type: 'float', @@ -43,6 +49,7 @@ export class EventTickExecutor implements INodeExecutor { return { nextExec: 'exec', outputs: { + self: context.entity, deltaTime: context.deltaTime } }; diff --git a/packages/framework/blueprint/src/registry/ComponentNodeGenerator.ts b/packages/framework/blueprint/src/registry/ComponentNodeGenerator.ts index 50954ab2..de00aac5 100644 --- a/packages/framework/blueprint/src/registry/ComponentNodeGenerator.ts +++ b/packages/framework/blueprint/src/registry/ComponentNodeGenerator.ts @@ -38,6 +38,8 @@ export function generateComponentNodes( const category = metadata.category ?? 'component'; const color = metadata.color ?? '#1e8b8b'; + // Generate Add/Get component nodes + generateAddComponentNode(componentClass, componentName, metadata, color); generateGetComponentNode(componentClass, componentName, metadata, color); for (const prop of properties) { @@ -52,6 +54,105 @@ export function generateComponentNodes( } } +/** + * @zh 生成 Add Component 节点 + * @en Generate Add Component node + */ +function generateAddComponentNode( + componentClass: Function, + componentName: string, + metadata: ComponentBlueprintMetadata, + color: string +): void { + const nodeType = `Add_${componentName}`; + const displayName = metadata.displayName ?? componentName; + + // Build input pins for initial property values + const propertyInputs: BlueprintNodeTemplate['inputs'] = []; + const propertyDefaults: Record = {}; + + for (const prop of metadata.properties) { + if (!prop.readonly) { + propertyInputs.push({ + name: prop.propertyKey, + type: prop.pinType, + displayName: prop.displayName, + defaultValue: prop.defaultValue + }); + propertyDefaults[prop.propertyKey] = prop.defaultValue; + } + } + + const template: BlueprintNodeTemplate = { + type: nodeType, + title: `Add ${displayName}`, + category: 'component', + color, + description: `Adds ${displayName} component to entity (为实体添加 ${displayName} 组件)`, + keywords: ['add', 'component', 'create', componentName.toLowerCase()], + menuPath: ['Components', displayName, `Add ${displayName}`], + inputs: [ + { name: 'exec', type: 'exec', displayName: '' }, + { name: 'entity', type: 'entity', displayName: 'Entity' }, + ...propertyInputs + ], + outputs: [ + { name: 'exec', type: 'exec', displayName: '' }, + { name: 'component', type: 'component', displayName: displayName }, + { name: 'success', type: 'bool', displayName: 'Success' } + ] + }; + + const propertyKeys = metadata.properties + .filter(p => !p.readonly) + .map(p => p.propertyKey); + + const executor: INodeExecutor = { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity; + + if (!entity || entity.isDestroyed) { + return { outputs: { component: null, success: false }, nextExec: 'exec' }; + } + + // Check if component already exists + const existing = entity.components.find(c => + c.constructor === componentClass || + c.constructor.name === componentName || + (c.constructor as any).__componentName__ === componentName + ); + + if (existing) { + // Component already exists, return it + return { outputs: { component: existing, success: false }, nextExec: 'exec' }; + } + + try { + // Create new component instance + const component = new (componentClass as new () => Component)(); + + // Set initial property values from inputs + for (const key of propertyKeys) { + const value = context.evaluateInput(node.id, key, propertyDefaults[key]); + if (value !== undefined) { + (component as any)[key] = value; + } + } + + // Add to entity + entity.addComponent(component); + + return { outputs: { component, success: true }, nextExec: 'exec' }; + } catch (error) { + console.error(`[Blueprint] Failed to add ${componentName}:`, error); + return { outputs: { component: null, success: false }, nextExec: 'exec' }; + } + } + }; + + NodeRegistry.instance.register(template, executor); +} + /** * @zh 生成 Get Component 节点 * @en Generate Get Component node diff --git a/packages/framework/blueprint/src/runtime/ExecutionContext.ts b/packages/framework/blueprint/src/runtime/ExecutionContext.ts index 0180714c..8fa99f48 100644 --- a/packages/framework/blueprint/src/runtime/ExecutionContext.ts +++ b/packages/framework/blueprint/src/runtime/ExecutionContext.ts @@ -3,9 +3,10 @@ * 执行上下文 - 蓝图执行的运行时上下文 */ -import type { Entity, IScene } from '@esengine/ecs-framework'; +import type { Entity, IScene, Component } from '@esengine/ecs-framework'; import { BlueprintNode, BlueprintConnection } from '../types/nodes'; import { BlueprintAsset } from '../types/blueprint'; +import { getRegisteredBlueprintComponents } from '../registry/BlueprintDecorators'; /** * Result of node execution @@ -72,6 +73,9 @@ export class ExecutionContext { /** Global variables (shared) (全局变量,共享) */ private static _globalVariables: Map = new Map(); + /** Component class registry (组件类注册表) */ + private static _componentRegistry: Map Component> = new Map(); + /** Node output cache for current execution (当前执行的节点输出缓存) */ private _outputCache: Map> = new Map(); @@ -267,4 +271,49 @@ export class ExecutionContext { static clearGlobalVariables(): void { ExecutionContext._globalVariables.clear(); } + + /** + * Get a component class by name + * 通过名称获取组件类 + * + * @zh 首先检查 @BlueprintExpose 装饰的组件,然后检查手动注册的组件 + * @en First checks @BlueprintExpose decorated components, then manually registered ones + */ + getComponentClass(typeName: string): (new () => Component) | undefined { + // First check registered blueprint components + const blueprintComponents = getRegisteredBlueprintComponents(); + for (const [componentClass, metadata] of blueprintComponents) { + if (metadata.componentName === typeName || + componentClass.name === typeName) { + return componentClass as new () => Component; + } + } + + // Then check manual registry + return ExecutionContext._componentRegistry.get(typeName); + } + + /** + * Register a component class for dynamic creation + * 注册组件类以支持动态创建 + */ + static registerComponentClass(typeName: string, componentClass: new () => Component): void { + ExecutionContext._componentRegistry.set(typeName, componentClass); + } + + /** + * Unregister a component class + * 取消注册组件类 + */ + static unregisterComponentClass(typeName: string): void { + ExecutionContext._componentRegistry.delete(typeName); + } + + /** + * Get all registered component classes + * 获取所有已注册的组件类 + */ + static getRegisteredComponentClasses(): Map Component> { + return new Map(ExecutionContext._componentRegistry); + } } diff --git a/packages/framework/blueprint/src/types/blueprint.ts b/packages/framework/blueprint/src/types/blueprint.ts index de4497b2..fb2144b3 100644 --- a/packages/framework/blueprint/src/types/blueprint.ts +++ b/packages/framework/blueprint/src/types/blueprint.ts @@ -87,10 +87,21 @@ export interface BlueprintAsset { } /** - * Creates an empty blueprint asset - * 创建空的蓝图资产 + * Creates an empty blueprint asset with default Event Begin Play node + * 创建带有默认 Event Begin Play 节点的空蓝图资产 */ -export function createEmptyBlueprint(name: string): BlueprintAsset { +export function createEmptyBlueprint(name: string, includeBeginPlay: boolean = true): BlueprintAsset { + const nodes: BlueprintNode[] = []; + + if (includeBeginPlay) { + nodes.push({ + id: 'node_beginplay_1', + type: 'EventBeginPlay', + position: { x: 100, y: 200 }, + data: {} + }); + } + return { version: 1, type: 'blueprint', @@ -100,7 +111,7 @@ export function createEmptyBlueprint(name: string): BlueprintAsset { modifiedAt: Date.now() }, variables: [], - nodes: [], + nodes, connections: [] }; }