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:
@@ -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,
|
||||
|
||||
@@ -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 | 是否有组件
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user