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
This commit is contained in:
YHH
2026-01-04 11:50:16 +08:00
committed by GitHub
parent d0057333a7
commit 2e84942ea1
11 changed files with 352 additions and 24 deletions

View File

@@ -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 生命周期管理

View File

@@ -0,0 +1,8 @@
---
"@esengine/node-editor": patch
---
fix(node-editor): 修复节点收缩后连线不显示的问题
- 节点收缩时,连线会连接到节点头部(输入引脚在左侧,输出引脚在右侧)
- 展开后连线会自动恢复到正确位置

View File

@@ -130,6 +130,13 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
// Force re-render after mount to ensure connections are drawn correctly // Force re-render after mount to ensure connections are drawn correctly
// 挂载后强制重渲染以确保连接线正确绘制 // 挂载后强制重渲染以确保连接线正确绘制
const [, forceUpdate] = useState(0); 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(() => { useEffect(() => {
// Use requestAnimationFrame to wait for DOM to be fully rendered // Use requestAnimationFrame to wait for DOM to be fully rendered
// 使用 requestAnimationFrame 等待 DOM 完全渲染 // 使用 requestAnimationFrame 等待 DOM 完全渲染
@@ -137,7 +144,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
forceUpdate(n => n + 1); forceUpdate(n => n + 1);
}); });
return () => cancelAnimationFrame(rafId); return () => cancelAnimationFrame(rafId);
}, [graph.id]); }, [graph.id, collapsedNodesKey]);
/** /**
* Converts screen coordinates to canvas coordinates * Converts screen coordinates to canvas coordinates
@@ -158,21 +165,51 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
* 获取引脚在画布坐标系中的位置 * 获取引脚在画布坐标系中的位置
* *
* 直接从节点位置和引脚在节点内的相对位置计算,不依赖 DOM 测量 * 直接从节点位置和引脚在节点内的相对位置计算,不依赖 DOM 测量
* 当节点收缩时,返回节点头部的位置
*/ */
const getPinPosition = useCallback((pinId: string): Position | undefined => { 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 // Find the pin element and its parent node
const pinElement = containerRef.current?.querySelector(`[data-pin-id="${pinId}"]`) as HTMLElement; 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; const nodeElement = pinElement.closest('[data-node-id]') as HTMLElement;
if (!nodeElement) return undefined; 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) // Get pin position relative to node element (in unscaled pixels)
const nodeRect = nodeElement.getBoundingClientRect(); const nodeRect = nodeElement.getBoundingClientRect();
const pinRect = pinElement.getBoundingClientRect(); const pinRect = pinElement.getBoundingClientRect();
@@ -184,8 +221,8 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
// Final position = node position + relative position // Final position = node position + relative position
return new Position( return new Position(
node.position.x + relativeX, ownerNode.position.x + relativeX,
node.position.y + relativeY ownerNode.position.y + relativeY
); );
}, [graph]); }, [graph]);

View File

@@ -75,8 +75,29 @@ export { NodeRegistry, RegisterNode } from './runtime/NodeRegistry';
export { BlueprintVM } from './runtime/BlueprintVM'; export { BlueprintVM } from './runtime/BlueprintVM';
export { BlueprintComponent } from './runtime/BlueprintComponent'; export { BlueprintComponent } from './runtime/BlueprintComponent';
export { BlueprintSystem } from './runtime/BlueprintSystem'; export { BlueprintSystem } from './runtime/BlueprintSystem';
export { ExecutionContext } from './runtime/ExecutionContext';
export { createEmptyBlueprint, validateBlueprintAsset } from './types/blueprint'; 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 // Re-export registry for convenience
export { export {
BlueprintExpose, BlueprintExpose,

View File

@@ -11,6 +11,68 @@ import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext'; import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry'; 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 | 是否有组件 // Has Component | 是否有组件
// ============================================================================ // ============================================================================

View File

@@ -17,13 +17,19 @@ export const EventBeginPlayTemplate: BlueprintNodeTemplate = {
category: 'event', category: 'event',
color: '#CC0000', color: '#CC0000',
description: 'Triggered once when the blueprint starts executing (蓝图开始执行时触发一次)', description: 'Triggered once when the blueprint starts executing (蓝图开始执行时触发一次)',
keywords: ['start', 'begin', 'init', 'event'], keywords: ['start', 'begin', 'init', 'event', 'self'],
menuPath: ['Events', 'Begin Play'],
inputs: [], inputs: [],
outputs: [ outputs: [
{ {
name: 'exec', name: 'exec',
type: 'exec', type: 'exec',
displayName: '' displayName: ''
},
{
name: 'self',
type: 'entity',
displayName: 'Self'
} }
] ]
}; };
@@ -34,11 +40,12 @@ export const EventBeginPlayTemplate: BlueprintNodeTemplate = {
*/ */
@RegisterNode(EventBeginPlayTemplate) @RegisterNode(EventBeginPlayTemplate)
export class EventBeginPlayExecutor implements INodeExecutor { export class EventBeginPlayExecutor implements INodeExecutor {
execute(_node: BlueprintNode, _context: ExecutionContext): ExecutionResult { execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult {
// Event nodes just trigger execution flow
// 事件节点只触发执行流
return { return {
nextExec: 'exec' nextExec: 'exec',
outputs: {
self: context.entity
}
}; };
} }
} }

View File

@@ -17,13 +17,19 @@ export const EventEndPlayTemplate: BlueprintNodeTemplate = {
category: 'event', category: 'event',
color: '#CC0000', color: '#CC0000',
description: 'Triggered once when the blueprint stops executing (蓝图停止执行时触发一次)', description: 'Triggered once when the blueprint stops executing (蓝图停止执行时触发一次)',
keywords: ['stop', 'end', 'destroy', 'event'], keywords: ['stop', 'end', 'destroy', 'event', 'self'],
menuPath: ['Events', 'End Play'],
inputs: [], inputs: [],
outputs: [ outputs: [
{ {
name: 'exec', name: 'exec',
type: 'exec', type: 'exec',
displayName: '' displayName: ''
},
{
name: 'self',
type: 'entity',
displayName: 'Self'
} }
] ]
}; };
@@ -34,9 +40,12 @@ export const EventEndPlayTemplate: BlueprintNodeTemplate = {
*/ */
@RegisterNode(EventEndPlayTemplate) @RegisterNode(EventEndPlayTemplate)
export class EventEndPlayExecutor implements INodeExecutor { export class EventEndPlayExecutor implements INodeExecutor {
execute(_node: BlueprintNode, _context: ExecutionContext): ExecutionResult { execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult {
return { return {
nextExec: 'exec' nextExec: 'exec',
outputs: {
self: context.entity
}
}; };
} }
} }

View File

@@ -17,7 +17,8 @@ export const EventTickTemplate: BlueprintNodeTemplate = {
category: 'event', category: 'event',
color: '#CC0000', color: '#CC0000',
description: 'Triggered every frame during execution (执行期间每帧触发)', description: 'Triggered every frame during execution (执行期间每帧触发)',
keywords: ['update', 'frame', 'tick', 'event'], keywords: ['update', 'frame', 'tick', 'event', 'self'],
menuPath: ['Events', 'Tick'],
inputs: [], inputs: [],
outputs: [ outputs: [
{ {
@@ -25,6 +26,11 @@ export const EventTickTemplate: BlueprintNodeTemplate = {
type: 'exec', type: 'exec',
displayName: '' displayName: ''
}, },
{
name: 'self',
type: 'entity',
displayName: 'Self'
},
{ {
name: 'deltaTime', name: 'deltaTime',
type: 'float', type: 'float',
@@ -43,6 +49,7 @@ export class EventTickExecutor implements INodeExecutor {
return { return {
nextExec: 'exec', nextExec: 'exec',
outputs: { outputs: {
self: context.entity,
deltaTime: context.deltaTime deltaTime: context.deltaTime
} }
}; };

View File

@@ -38,6 +38,8 @@ export function generateComponentNodes(
const category = metadata.category ?? 'component'; const category = metadata.category ?? 'component';
const color = metadata.color ?? '#1e8b8b'; const color = metadata.color ?? '#1e8b8b';
// Generate Add/Get component nodes
generateAddComponentNode(componentClass, componentName, metadata, color);
generateGetComponentNode(componentClass, componentName, metadata, color); generateGetComponentNode(componentClass, componentName, metadata, color);
for (const prop of properties) { 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<string, unknown> = {};
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 节点 * @zh 生成 Get Component 节点
* @en Generate Get Component node * @en Generate Get Component node

View File

@@ -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 { BlueprintNode, BlueprintConnection } from '../types/nodes';
import { BlueprintAsset } from '../types/blueprint'; import { BlueprintAsset } from '../types/blueprint';
import { getRegisteredBlueprintComponents } from '../registry/BlueprintDecorators';
/** /**
* Result of node execution * Result of node execution
@@ -72,6 +73,9 @@ export class ExecutionContext {
/** Global variables (shared) (全局变量,共享) */ /** Global variables (shared) (全局变量,共享) */
private static _globalVariables: Map<string, unknown> = new Map(); private static _globalVariables: Map<string, unknown> = new Map();
/** Component class registry (组件类注册表) */
private static _componentRegistry: Map<string, new () => Component> = new Map();
/** Node output cache for current execution (当前执行的节点输出缓存) */ /** Node output cache for current execution (当前执行的节点输出缓存) */
private _outputCache: Map<string, Record<string, unknown>> = new Map(); private _outputCache: Map<string, Record<string, unknown>> = new Map();
@@ -267,4 +271,49 @@ export class ExecutionContext {
static clearGlobalVariables(): void { static clearGlobalVariables(): void {
ExecutionContext._globalVariables.clear(); 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<string, new () => Component> {
return new Map(ExecutionContext._componentRegistry);
}
} }

View File

@@ -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 { return {
version: 1, version: 1,
type: 'blueprint', type: 'blueprint',
@@ -100,7 +111,7 @@ export function createEmptyBlueprint(name: string): BlueprintAsset {
modifiedAt: Date.now() modifiedAt: Date.now()
}, },
variables: [], variables: [],
nodes: [], nodes,
connections: [] connections: []
}; };
} }