feat(blueprint): add Schema type system and @BlueprintArray decorator
- Add Schema fluent API for defining complex data types - Add @BlueprintArray decorator for array properties with itemSchema - Support primitive types: float, int, string, boolean, vector2, vector3 - Support composite types: object, array, enum, ref - Add path-utils for nested property access - Update documentation with examples and usage guide
This commit is contained in:
@@ -3,6 +3,164 @@ title: "Custom Nodes"
|
||||
description: "Creating custom blueprint nodes"
|
||||
---
|
||||
|
||||
## Blueprint Decorators
|
||||
|
||||
Use decorators to quickly expose ECS components as blueprint nodes.
|
||||
|
||||
### @BlueprintComponent
|
||||
|
||||
Mark a component class as blueprint-enabled:
|
||||
|
||||
```typescript
|
||||
import { BlueprintComponent, BlueprintProperty } from '@esengine/blueprint';
|
||||
|
||||
@BlueprintComponent({
|
||||
title: 'Player Controller',
|
||||
category: 'gameplay',
|
||||
color: '#4a90d9',
|
||||
description: 'Controls player movement and interaction'
|
||||
})
|
||||
class PlayerController extends Component {
|
||||
@BlueprintProperty({ displayName: 'Move Speed' })
|
||||
speed: number = 100;
|
||||
|
||||
@BlueprintProperty({ displayName: 'Jump Height' })
|
||||
jumpHeight: number = 200;
|
||||
}
|
||||
```
|
||||
|
||||
### @BlueprintProperty
|
||||
|
||||
Expose component properties as node inputs:
|
||||
|
||||
```typescript
|
||||
@BlueprintProperty({
|
||||
displayName: 'Health',
|
||||
description: 'Current health value',
|
||||
isInput: true,
|
||||
isOutput: true
|
||||
})
|
||||
health: number = 100;
|
||||
```
|
||||
|
||||
### @BlueprintArray
|
||||
|
||||
For array type properties, supports editing complex object arrays:
|
||||
|
||||
```typescript
|
||||
import { BlueprintArray, Schema } from '@esengine/blueprint';
|
||||
|
||||
interface Waypoint {
|
||||
position: { x: number; y: number };
|
||||
waitTime: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
@BlueprintComponent({
|
||||
title: 'Patrol Path',
|
||||
category: 'ai'
|
||||
})
|
||||
class PatrolPath extends Component {
|
||||
@BlueprintArray({
|
||||
displayName: 'Waypoints',
|
||||
description: 'Points along the patrol path',
|
||||
itemSchema: Schema.object({
|
||||
position: Schema.vector2({ defaultValue: { x: 0, y: 0 } }),
|
||||
waitTime: Schema.float({ min: 0, max: 10, defaultValue: 1.0 }),
|
||||
speed: Schema.float({ min: 0, max: 500, defaultValue: 100 })
|
||||
}),
|
||||
reorderable: true,
|
||||
exposeElementPorts: true,
|
||||
portNameTemplate: 'Waypoint {index1}'
|
||||
})
|
||||
waypoints: Waypoint[] = [];
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Type System
|
||||
|
||||
Schema defines type information for complex data structures, enabling the editor to automatically generate corresponding UI.
|
||||
|
||||
### Primitive Types
|
||||
|
||||
```typescript
|
||||
import { Schema } from '@esengine/blueprint';
|
||||
|
||||
// Number types
|
||||
Schema.float({ min: 0, max: 100, defaultValue: 50, step: 0.1 })
|
||||
Schema.int({ min: 0, max: 10, defaultValue: 5 })
|
||||
|
||||
// String
|
||||
Schema.string({ defaultValue: 'Hello', multiline: false, placeholder: 'Enter text...' })
|
||||
|
||||
// Boolean
|
||||
Schema.boolean({ defaultValue: true })
|
||||
|
||||
// Vectors
|
||||
Schema.vector2({ defaultValue: { x: 0, y: 0 } })
|
||||
Schema.vector3({ defaultValue: { x: 0, y: 0, z: 0 } })
|
||||
```
|
||||
|
||||
### Composite Types
|
||||
|
||||
```typescript
|
||||
// Object
|
||||
Schema.object({
|
||||
name: Schema.string({ defaultValue: '' }),
|
||||
health: Schema.float({ min: 0, max: 100 }),
|
||||
position: Schema.vector2()
|
||||
})
|
||||
|
||||
// Array
|
||||
Schema.array({
|
||||
items: Schema.float(),
|
||||
minItems: 0,
|
||||
maxItems: 10
|
||||
})
|
||||
|
||||
// Enum
|
||||
Schema.enum({
|
||||
options: ['idle', 'walk', 'run', 'jump'],
|
||||
defaultValue: 'idle'
|
||||
})
|
||||
|
||||
// Reference
|
||||
Schema.ref({ refType: 'entity' })
|
||||
Schema.ref({ refType: 'asset', assetType: 'texture' })
|
||||
```
|
||||
|
||||
### Complete Example
|
||||
|
||||
```typescript
|
||||
@BlueprintComponent({ title: 'Enemy Config', category: 'ai' })
|
||||
class EnemyConfig extends Component {
|
||||
@BlueprintArray({
|
||||
displayName: 'Attack Patterns',
|
||||
itemSchema: Schema.object({
|
||||
name: Schema.string({ defaultValue: 'Basic Attack' }),
|
||||
damage: Schema.float({ min: 0, max: 100, defaultValue: 10 }),
|
||||
cooldown: Schema.float({ min: 0, max: 10, defaultValue: 1 }),
|
||||
range: Schema.float({ min: 0, max: 500, defaultValue: 50 }),
|
||||
animation: Schema.string({ defaultValue: 'attack_01' })
|
||||
}),
|
||||
reorderable: true
|
||||
})
|
||||
attackPatterns: AttackPattern[] = [];
|
||||
|
||||
@BlueprintProperty({
|
||||
displayName: 'Patrol Area',
|
||||
schema: Schema.object({
|
||||
center: Schema.vector2(),
|
||||
radius: Schema.float({ min: 0, defaultValue: 100 })
|
||||
})
|
||||
})
|
||||
patrolArea: { center: { x: number; y: number }; radius: number } = {
|
||||
center: { x: 0, y: 0 },
|
||||
radius: 100
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Defining Node Template
|
||||
|
||||
```typescript
|
||||
@@ -33,16 +191,11 @@ import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, Execution
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
// Get input (using evaluateInput)
|
||||
const value = context.evaluateInput(node.id, 'value', 0) as number;
|
||||
|
||||
// Execute logic
|
||||
const result = value * 2;
|
||||
|
||||
// Return result
|
||||
return {
|
||||
outputs: { result },
|
||||
nextExec: 'exec' // Continue execution
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -64,19 +217,10 @@ NodeRegistry.instance.register(MyNodeTemplate, new MyNodeExecutor());
|
||||
```typescript
|
||||
import { NodeRegistry } from '@esengine/blueprint';
|
||||
|
||||
// Get singleton
|
||||
const registry = NodeRegistry.instance;
|
||||
|
||||
// Get all templates
|
||||
const allTemplates = registry.getAllTemplates();
|
||||
|
||||
// Get by category
|
||||
const mathNodes = registry.getTemplatesByCategory('math');
|
||||
|
||||
// Search nodes
|
||||
const results = registry.searchTemplates('add');
|
||||
|
||||
// Check existence
|
||||
if (registry.has('MyCustomNode')) { ... }
|
||||
```
|
||||
|
||||
@@ -89,7 +233,7 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
|
||||
type: 'GetDistance',
|
||||
title: 'Get Distance',
|
||||
category: 'math',
|
||||
isPure: true, // Mark as pure node
|
||||
isPure: true,
|
||||
inputs: [
|
||||
{ name: 'a', type: 'vector2', direction: 'input' },
|
||||
{ name: 'b', type: 'vector2', direction: 'input' }
|
||||
@@ -99,59 +243,3 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
## Example: ECS Component Operation Node
|
||||
|
||||
```typescript
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint';
|
||||
import { ExecutionContext, ExecutionResult } from '@esengine/blueprint';
|
||||
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
|
||||
|
||||
// Custom heal node
|
||||
const HealEntityTemplate: BlueprintNodeTemplate = {
|
||||
type: 'HealEntity',
|
||||
title: 'Heal Entity',
|
||||
category: 'gameplay',
|
||||
color: '#22aa22',
|
||||
description: 'Heal an entity with HealthComponent',
|
||||
keywords: ['heal', 'health', 'restore'],
|
||||
menuPath: ['Gameplay', 'Combat', 'Heal Entity'],
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'entity', type: 'entity', displayName: 'Target' },
|
||||
{ name: 'amount', type: 'float', displayName: 'Amount', defaultValue: 10 }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'newHealth', type: 'float', displayName: 'New Health' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(HealEntityTemplate)
|
||||
class HealEntityExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
|
||||
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
|
||||
|
||||
if (!entity || entity.isDestroyed) {
|
||||
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
// Get HealthComponent
|
||||
const health = entity.components.find(c =>
|
||||
(c.constructor as any).__componentName__ === 'Health'
|
||||
) as any;
|
||||
|
||||
if (health) {
|
||||
health.current = Math.min(health.current + amount, health.max);
|
||||
return {
|
||||
outputs: { newHealth: health.current },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
|
||||
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -3,6 +3,164 @@ title: "自定义节点"
|
||||
description: "创建自定义蓝图节点"
|
||||
---
|
||||
|
||||
## 蓝图装饰器
|
||||
|
||||
使用装饰器可以快速将 ECS 组件暴露为蓝图节点。
|
||||
|
||||
### @BlueprintComponent
|
||||
|
||||
将组件类标记为蓝图可用:
|
||||
|
||||
```typescript
|
||||
import { BlueprintComponent, BlueprintProperty } from '@esengine/blueprint';
|
||||
|
||||
@BlueprintComponent({
|
||||
title: '玩家控制器',
|
||||
category: 'gameplay',
|
||||
color: '#4a90d9',
|
||||
description: '控制玩家移动和交互'
|
||||
})
|
||||
class PlayerController extends Component {
|
||||
@BlueprintProperty({ displayName: '移动速度' })
|
||||
speed: number = 100;
|
||||
|
||||
@BlueprintProperty({ displayName: '跳跃高度' })
|
||||
jumpHeight: number = 200;
|
||||
}
|
||||
```
|
||||
|
||||
### @BlueprintProperty
|
||||
|
||||
将组件属性暴露为节点输入:
|
||||
|
||||
```typescript
|
||||
@BlueprintProperty({
|
||||
displayName: '生命值',
|
||||
description: '当前生命值',
|
||||
isInput: true,
|
||||
isOutput: true
|
||||
})
|
||||
health: number = 100;
|
||||
```
|
||||
|
||||
### @BlueprintArray
|
||||
|
||||
用于数组类型属性,支持复杂对象数组的编辑:
|
||||
|
||||
```typescript
|
||||
import { BlueprintArray, Schema } from '@esengine/blueprint';
|
||||
|
||||
interface Waypoint {
|
||||
position: { x: number; y: number };
|
||||
waitTime: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
@BlueprintComponent({
|
||||
title: '巡逻路径',
|
||||
category: 'ai'
|
||||
})
|
||||
class PatrolPath extends Component {
|
||||
@BlueprintArray({
|
||||
displayName: '路径点',
|
||||
description: '巡逻路径的各个点',
|
||||
itemSchema: Schema.object({
|
||||
position: Schema.vector2({ defaultValue: { x: 0, y: 0 } }),
|
||||
waitTime: Schema.float({ min: 0, max: 10, defaultValue: 1.0 }),
|
||||
speed: Schema.float({ min: 0, max: 500, defaultValue: 100 })
|
||||
}),
|
||||
reorderable: true,
|
||||
exposeElementPorts: true,
|
||||
portNameTemplate: '路径点 {index1}'
|
||||
})
|
||||
waypoints: Waypoint[] = [];
|
||||
}
|
||||
```
|
||||
|
||||
## Schema 类型系统
|
||||
|
||||
Schema 用于定义复杂数据结构的类型信息,支持编辑器自动生成对应的 UI。
|
||||
|
||||
### 基础类型
|
||||
|
||||
```typescript
|
||||
import { Schema } from '@esengine/blueprint';
|
||||
|
||||
// 数字类型
|
||||
Schema.float({ min: 0, max: 100, defaultValue: 50, step: 0.1 })
|
||||
Schema.int({ min: 0, max: 10, defaultValue: 5 })
|
||||
|
||||
// 字符串
|
||||
Schema.string({ defaultValue: 'Hello', multiline: false, placeholder: '输入文本...' })
|
||||
|
||||
// 布尔
|
||||
Schema.boolean({ defaultValue: true })
|
||||
|
||||
// 向量
|
||||
Schema.vector2({ defaultValue: { x: 0, y: 0 } })
|
||||
Schema.vector3({ defaultValue: { x: 0, y: 0, z: 0 } })
|
||||
```
|
||||
|
||||
### 复合类型
|
||||
|
||||
```typescript
|
||||
// 对象
|
||||
Schema.object({
|
||||
name: Schema.string({ defaultValue: '' }),
|
||||
health: Schema.float({ min: 0, max: 100 }),
|
||||
position: Schema.vector2()
|
||||
})
|
||||
|
||||
// 数组
|
||||
Schema.array({
|
||||
items: Schema.float(),
|
||||
minItems: 0,
|
||||
maxItems: 10
|
||||
})
|
||||
|
||||
// 枚举
|
||||
Schema.enum({
|
||||
options: ['idle', 'walk', 'run', 'jump'],
|
||||
defaultValue: 'idle'
|
||||
})
|
||||
|
||||
// 引用
|
||||
Schema.ref({ refType: 'entity' })
|
||||
Schema.ref({ refType: 'asset', assetType: 'texture' })
|
||||
```
|
||||
|
||||
### 完整示例
|
||||
|
||||
```typescript
|
||||
@BlueprintComponent({ title: '敌人配置', category: 'ai' })
|
||||
class EnemyConfig extends Component {
|
||||
@BlueprintArray({
|
||||
displayName: '攻击模式',
|
||||
itemSchema: Schema.object({
|
||||
name: Schema.string({ defaultValue: '普通攻击' }),
|
||||
damage: Schema.float({ min: 0, max: 100, defaultValue: 10 }),
|
||||
cooldown: Schema.float({ min: 0, max: 10, defaultValue: 1 }),
|
||||
range: Schema.float({ min: 0, max: 500, defaultValue: 50 }),
|
||||
animation: Schema.string({ defaultValue: 'attack_01' })
|
||||
}),
|
||||
reorderable: true
|
||||
})
|
||||
attackPatterns: AttackPattern[] = [];
|
||||
|
||||
@BlueprintProperty({
|
||||
displayName: '巡逻区域',
|
||||
schema: Schema.object({
|
||||
center: Schema.vector2(),
|
||||
radius: Schema.float({ min: 0, defaultValue: 100 })
|
||||
})
|
||||
})
|
||||
patrolArea: { center: { x: number; y: number }; radius: number } = {
|
||||
center: { x: 0, y: 0 },
|
||||
radius: 100
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 定义节点模板
|
||||
|
||||
```typescript
|
||||
@@ -33,16 +191,11 @@ import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, Execution
|
||||
@RegisterNode(MyNodeTemplate)
|
||||
class MyNodeExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
// 获取输入(使用 evaluateInput)
|
||||
const value = context.evaluateInput(node.id, 'value', 0) as number;
|
||||
|
||||
// 执行逻辑
|
||||
const result = value * 2;
|
||||
|
||||
// 返回结果
|
||||
return {
|
||||
outputs: { result },
|
||||
nextExec: 'exec' // 继续执行
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -64,19 +217,10 @@ NodeRegistry.instance.register(MyNodeTemplate, new MyNodeExecutor());
|
||||
```typescript
|
||||
import { NodeRegistry } from '@esengine/blueprint';
|
||||
|
||||
// 获取单例
|
||||
const registry = NodeRegistry.instance;
|
||||
|
||||
// 获取所有模板
|
||||
const allTemplates = registry.getAllTemplates();
|
||||
|
||||
// 按类别获取
|
||||
const mathNodes = registry.getTemplatesByCategory('math');
|
||||
|
||||
// 搜索节点
|
||||
const results = registry.searchTemplates('add');
|
||||
|
||||
// 检查是否存在
|
||||
if (registry.has('MyCustomNode')) { ... }
|
||||
```
|
||||
|
||||
@@ -89,7 +233,7 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
|
||||
type: 'GetDistance',
|
||||
title: 'Get Distance',
|
||||
category: 'math',
|
||||
isPure: true, // 标记为纯节点
|
||||
isPure: true,
|
||||
inputs: [
|
||||
{ name: 'a', type: 'vector2', direction: 'input' },
|
||||
{ name: 'b', type: 'vector2', direction: 'input' }
|
||||
@@ -99,59 +243,3 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
## 实际示例:ECS 组件操作节点
|
||||
|
||||
```typescript
|
||||
import type { Entity } from '@esengine/ecs-framework';
|
||||
import { BlueprintNodeTemplate, BlueprintNode } from '@esengine/blueprint';
|
||||
import { ExecutionContext, ExecutionResult } from '@esengine/blueprint';
|
||||
import { INodeExecutor, RegisterNode } from '@esengine/blueprint';
|
||||
|
||||
// 自定义治疗节点
|
||||
const HealEntityTemplate: BlueprintNodeTemplate = {
|
||||
type: 'HealEntity',
|
||||
title: 'Heal Entity',
|
||||
category: 'gameplay',
|
||||
color: '#22aa22',
|
||||
description: 'Heal an entity with HealthComponent',
|
||||
keywords: ['heal', 'health', 'restore'],
|
||||
menuPath: ['Gameplay', 'Combat', 'Heal Entity'],
|
||||
inputs: [
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'entity', type: 'entity', displayName: 'Target' },
|
||||
{ name: 'amount', type: 'float', displayName: 'Amount', defaultValue: 10 }
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'exec', type: 'exec', displayName: '' },
|
||||
{ name: 'newHealth', type: 'float', displayName: 'New Health' }
|
||||
]
|
||||
};
|
||||
|
||||
@RegisterNode(HealEntityTemplate)
|
||||
class HealEntityExecutor implements INodeExecutor {
|
||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||
const entity = context.evaluateInput(node.id, 'entity', context.entity) as Entity;
|
||||
const amount = context.evaluateInput(node.id, 'amount', 10) as number;
|
||||
|
||||
if (!entity || entity.isDestroyed) {
|
||||
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
|
||||
}
|
||||
|
||||
// 获取 HealthComponent
|
||||
const health = entity.components.find(c =>
|
||||
(c.constructor as any).__componentName__ === 'Health'
|
||||
) as any;
|
||||
|
||||
if (health) {
|
||||
health.current = Math.min(health.current + amount, health.max);
|
||||
return {
|
||||
outputs: { newHealth: health.current },
|
||||
nextExec: 'exec'
|
||||
};
|
||||
}
|
||||
|
||||
return { outputs: { newHealth: 0 }, nextExec: 'exec' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user