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

@@ -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,

View File

@@ -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 | 是否有组件
// ============================================================================

View File

@@ -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
}
};
}
}

View File

@@ -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
}
};
}
}

View File

@@ -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
}
};

View File

@@ -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<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 节点
* @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 { 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<string, unknown> = new Map();
/** Component class registry (组件类注册表) */
private static _componentRegistry: Map<string, new () => Component> = new Map();
/** Node output cache for current execution (当前执行的节点输出缓存) */
private _outputCache: Map<string, Record<string, unknown>> = 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<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 {
version: 1,
type: 'blueprint',
@@ -100,7 +111,7 @@ export function createEmptyBlueprint(name: string): BlueprintAsset {
modifiedAt: Date.now()
},
variables: [],
nodes: [],
nodes,
connections: []
};
}