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:
yhh
2026-01-06 18:19:08 +08:00
parent 7caa69a22e
commit 4e66bd8e2b
9 changed files with 1666 additions and 324 deletions

View File

@@ -0,0 +1,18 @@
---
"@esengine/blueprint": minor
---
feat(blueprint): add Schema type system and @BlueprintArray decorator
- Add `Schema` fluent API for defining complex data types:
- Primitive types: `Schema.float()`, `Schema.int()`, `Schema.string()`, `Schema.boolean()`, `Schema.vector2()`, `Schema.vector3()`
- Composite types: `Schema.object()`, `Schema.array()`, `Schema.enum()`, `Schema.ref()`
- Support for constraints: `min`, `max`, `step`, `defaultValue`, `placeholder`, etc.
- Add `@BlueprintArray` decorator for array properties:
- `itemSchema`: Define schema for array items using Schema API
- `reorderable`: Allow drag-and-drop reordering
- `exposeElementPorts`: Create individual ports for each array element
- `portNameTemplate`: Custom naming for element ports (e.g., "Waypoint {index1}")
- Update documentation with examples and usage guide

View File

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

View File

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

View File

@@ -1,118 +1,75 @@
/**
* @zh 蓝图装饰器 - 用于标记可在蓝图中使用的组件、属性和方法
* @en Blueprint Decorators - Mark components, properties and methods for blueprint use
*
* @example
* ```typescript
* import { BlueprintExpose, BlueprintProperty, BlueprintMethod } from '@esengine/blueprint';
*
* @ECSComponent('Health')
* @BlueprintExpose({ displayName: '生命值组件', category: 'gameplay' })
* export class HealthComponent extends Component {
*
* @BlueprintProperty({ displayName: '当前生命值', type: 'float' })
* current: number = 100;
*
* @BlueprintProperty({ displayName: '最大生命值', type: 'float', readonly: true })
* max: number = 100;
*
* @BlueprintMethod({
* displayName: '治疗',
* params: [{ name: 'amount', type: 'float' }]
* })
* heal(amount: number): void {
* this.current = Math.min(this.current + amount, this.max);
* }
*
* @BlueprintMethod({
* displayName: '受伤',
* params: [{ name: 'amount', type: 'float' }],
* returnType: 'bool'
* })
* takeDamage(amount: number): boolean {
* this.current -= amount;
* return this.current <= 0;
* }
* }
* ```
*/
import type { BlueprintPinType } from '../types/pins';
import type { PropertySchema, ArraySchema, ObjectSchema } from '../types/schema';
// ============================================================================
// Types | 类型定义
// Types
// ============================================================================
/**
* @zh 参数定义
* @en Parameter definition
*/
export interface BlueprintParamDef {
/** @zh 参数名称 @en Parameter name */
name: string;
/** @zh 显示名称 @en Display name */
displayName?: string;
/** @zh 引脚类型 @en Pin type */
type?: BlueprintPinType;
/** @zh 默认值 @en Default value */
defaultValue?: unknown;
}
/**
* @zh 蓝图暴露选项
* @en Blueprint expose options
*/
export interface BlueprintExposeOptions {
/** @zh 组件显示名称 @en Component display name */
displayName?: string;
/** @zh 组件描述 @en Component description */
description?: string;
/** @zh 组件分类 @en Component category */
category?: string;
/** @zh 组件颜色 @en Component color */
color?: string;
/** @zh 组件图标 @en Component icon */
icon?: string;
}
/**
* @zh 蓝图属性选项
* @en Blueprint property options
*/
export interface BlueprintPropertyOptions {
/** @zh 属性显示名称 @en Property display name */
displayName?: string;
/** @zh 属性描述 @en Property description */
description?: string;
/** @zh 引脚类型 @en Pin type */
type?: BlueprintPinType;
/** @zh 是否只读(不生成 Set 节点)@en Readonly (no Set node generated) */
readonly?: boolean;
/** @zh 默认值 @en Default value */
defaultValue?: unknown;
}
/**
* @zh 蓝图方法选项
* @en Blueprint method options
*/
export interface BlueprintMethodOptions {
/** @zh 方法显示名称 @en Method display name */
displayName?: string;
/** @zh 方法描述 @en Method description */
description?: string;
/** @zh 是否是纯函数(无副作用)@en Is pure function (no side effects) */
isPure?: boolean;
/** @zh 参数列表 @en Parameter list */
params?: BlueprintParamDef[];
/** @zh 返回值类型 @en Return type */
returnType?: BlueprintPinType;
}
/**
* @zh 属性元数据
* @en Property metadata
* @zh 蓝图数组属性选项
* @en Blueprint array property options
*/
export interface BlueprintArrayOptions {
displayName?: string;
description?: string;
itemSchema: PropertySchema;
reorderable?: boolean;
collapsible?: boolean;
minItems?: number;
maxItems?: number;
defaultValue?: unknown[];
itemLabel?: string;
exposeElementPorts?: boolean;
portNameTemplate?: string;
}
/**
* @zh 蓝图对象属性选项
* @en Blueprint object property options
*/
export interface BlueprintObjectOptions {
displayName?: string;
description?: string;
properties: Record<string, PropertySchema>;
collapsible?: boolean;
}
export interface PropertyMetadata {
propertyKey: string;
displayName: string;
@@ -120,12 +77,12 @@ export interface PropertyMetadata {
pinType: BlueprintPinType;
readonly: boolean;
defaultValue?: unknown;
schema?: PropertySchema;
isDynamicArray?: boolean;
exposeElementPorts?: boolean;
portNameTemplate?: string;
}
/**
* @zh 方法元数据
* @en Method metadata
*/
export interface MethodMetadata {
methodKey: string;
displayName: string;
@@ -135,10 +92,6 @@ export interface MethodMetadata {
returnType: BlueprintPinType;
}
/**
* @zh 组件蓝图元数据
* @en Component blueprint metadata
*/
export interface ComponentBlueprintMetadata extends BlueprintExposeOptions {
componentName: string;
properties: PropertyMetadata[];
@@ -146,41 +99,25 @@ export interface ComponentBlueprintMetadata extends BlueprintExposeOptions {
}
// ============================================================================
// Registry | 注册表
// Registry
// ============================================================================
/**
* @zh 已注册的蓝图组件
* @en Registered blueprint components
*/
const registeredComponents = new Map<Function, ComponentBlueprintMetadata>();
/**
* @zh 获取所有已注册的蓝图组件
* @en Get all registered blueprint components
*/
export function getRegisteredBlueprintComponents(): Map<Function, ComponentBlueprintMetadata> {
return registeredComponents;
}
/**
* @zh 获取组件的蓝图元数据
* @en Get blueprint metadata for a component
*/
export function getBlueprintMetadata(componentClass: Function): ComponentBlueprintMetadata | undefined {
return registeredComponents.get(componentClass);
}
/**
* @zh 清除所有注册的蓝图组件(用于测试)
* @en Clear all registered blueprint components (for testing)
*/
export function clearRegisteredComponents(): void {
registeredComponents.clear();
}
// ============================================================================
// Internal Helpers | 内部辅助函数
// Internal Helpers
// ============================================================================
function getOrCreateMetadata(constructor: Function): ComponentBlueprintMetadata {
@@ -197,20 +134,9 @@ function getOrCreateMetadata(constructor: Function): ComponentBlueprintMetadata
}
// ============================================================================
// Decorators | 装饰器
// Decorators
// ============================================================================
/**
* @zh 标记组件可在蓝图中使用
* @en Mark component as usable in blueprint
*
* @example
* ```typescript
* @ECSComponent('Player')
* @BlueprintExpose({ displayName: '玩家', category: 'gameplay' })
* export class PlayerComponent extends Component { }
* ```
*/
export function BlueprintExpose(options: BlueprintExposeOptions = {}): ClassDecorator {
return function (target: Function) {
const metadata = getOrCreateMetadata(target);
@@ -220,19 +146,6 @@ export function BlueprintExpose(options: BlueprintExposeOptions = {}): ClassDeco
};
}
/**
* @zh 标记属性可在蓝图中访问
* @en Mark property as accessible in blueprint
*
* @example
* ```typescript
* @BlueprintProperty({ displayName: '生命值', type: 'float' })
* health: number = 100;
*
* @BlueprintProperty({ displayName: '名称', type: 'string', readonly: true })
* name: string = 'Player';
* ```
*/
export function BlueprintProperty(options: BlueprintPropertyOptions = {}): PropertyDecorator {
return function (target: Object, propertyKey: string | symbol) {
const key = String(propertyKey);
@@ -257,25 +170,108 @@ export function BlueprintProperty(options: BlueprintPropertyOptions = {}): Prope
}
/**
* @zh 标记方法可在蓝图中调用
* @en Mark method as callable in blueprint
* @zh 标记属性为蓝图数组(支持动态增删、排序)
* @en Mark property as blueprint array (supports dynamic add/remove, reorder)
*
* @example
* ```typescript
* @BlueprintMethod({
* displayName: '攻击',
* params: [
* { name: 'target', type: 'entity' },
* { name: 'damage', type: 'float' }
* ],
* returnType: 'bool'
* @BlueprintArray({
* displayName: '路径点',
* itemSchema: Schema.object({
* position: Schema.vector2(),
* waitTime: Schema.float({ min: 0, defaultValue: 1.0 })
* }),
* reorderable: true,
* exposeElementPorts: true,
* portNameTemplate: 'Point {index1}'
* })
* attack(target: Entity, damage: number): boolean { }
*
* @BlueprintMethod({ displayName: '获取速度', isPure: true, returnType: 'float' })
* getSpeed(): number { return this.speed; }
* waypoints: Waypoint[] = [];
* ```
*/
export function BlueprintArray(options: BlueprintArrayOptions): PropertyDecorator {
return function (target: Object, propertyKey: string | symbol) {
const key = String(propertyKey);
const metadata = getOrCreateMetadata(target.constructor);
const arraySchema: ArraySchema = {
type: 'array',
items: options.itemSchema,
defaultValue: options.defaultValue,
minItems: options.minItems,
maxItems: options.maxItems,
reorderable: options.reorderable,
collapsible: options.collapsible,
itemLabel: options.itemLabel
};
const propMeta: PropertyMetadata = {
propertyKey: key,
displayName: options.displayName ?? key,
description: options.description,
pinType: 'array',
readonly: false,
defaultValue: options.defaultValue,
schema: arraySchema,
isDynamicArray: true,
exposeElementPorts: options.exposeElementPorts,
portNameTemplate: options.portNameTemplate
};
const existingIndex = metadata.properties.findIndex(p => p.propertyKey === key);
if (existingIndex >= 0) {
metadata.properties[existingIndex] = propMeta;
} else {
metadata.properties.push(propMeta);
}
};
}
/**
* @zh 标记属性为蓝图对象(支持嵌套结构)
* @en Mark property as blueprint object (supports nested structure)
*
* @example
* ```typescript
* @BlueprintObject({
* displayName: '变换',
* properties: {
* position: Schema.vector2(),
* rotation: Schema.float(),
* scale: Schema.vector2({ defaultValue: { x: 1, y: 1 } })
* }
* })
* transform: Transform;
* ```
*/
export function BlueprintObject(options: BlueprintObjectOptions): PropertyDecorator {
return function (target: Object, propertyKey: string | symbol) {
const key = String(propertyKey);
const metadata = getOrCreateMetadata(target.constructor);
const objectSchema: ObjectSchema = {
type: 'object',
properties: options.properties,
collapsible: options.collapsible
};
const propMeta: PropertyMetadata = {
propertyKey: key,
displayName: options.displayName ?? key,
description: options.description,
pinType: 'object',
readonly: false,
schema: objectSchema
};
const existingIndex = metadata.properties.findIndex(p => p.propertyKey === key);
if (existingIndex >= 0) {
metadata.properties[existingIndex] = propMeta;
} else {
metadata.properties.push(propMeta);
}
};
}
export function BlueprintMethod(options: BlueprintMethodOptions = {}): MethodDecorator {
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const key = String(propertyKey);
@@ -302,13 +298,9 @@ export function BlueprintMethod(options: BlueprintMethodOptions = {}): MethodDec
}
// ============================================================================
// Utility Functions | 工具函数
// Utility Functions
// ============================================================================
/**
* @zh 从 TypeScript 类型名推断蓝图引脚类型
* @en Infer blueprint pin type from TypeScript type name
*/
export function inferPinType(typeName: string): BlueprintPinType {
const typeMap: Record<string, BlueprintPinType> = {
'number': 'float',

View File

@@ -1,43 +1,6 @@
/**
* @zh 蓝图注册系统
* @en Blueprint Registry System
*
* @zh 提供组件自动节点生成功能,用户只需使用装饰器标记组件,
* 即可自动在蓝图编辑器中生成对应的 Get/Set/Call 节点
*
* @en Provides automatic node generation for components. Users only need to
* mark components with decorators, and corresponding Get/Set/Call nodes
* will be auto-generated in the blueprint editor
*
* @example
* ```typescript
* // 1. 定义组件时使用装饰器 | Define component with decorators
* @ECSComponent('Health')
* @BlueprintExpose({ displayName: '生命值', category: 'gameplay' })
* export class HealthComponent extends Component {
* @BlueprintProperty({ displayName: '当前生命值', type: 'float' })
* current: number = 100;
*
* @BlueprintMethod({
* displayName: '治疗',
* params: [{ name: 'amount', type: 'float' }]
* })
* heal(amount: number): void {
* this.current = Math.min(this.current + amount, 100);
* }
* }
*
* // 2. 初始化蓝图系统时注册 | Register when initializing blueprint system
* import { registerAllComponentNodes } from '@esengine/blueprint';
* registerAllComponentNodes();
*
* // 3. 现在蓝图编辑器中会出现以下节点:
* // Now these nodes appear in blueprint editor:
* // - Get Health获取组件
* // - Get 当前生命值(获取属性)
* // - Set 当前生命值(设置属性)
* // - 治疗(调用方法)
* ```
*/
// Decorators | 装饰器
@@ -45,6 +8,8 @@ export {
BlueprintExpose,
BlueprintProperty,
BlueprintMethod,
BlueprintArray,
BlueprintObject,
getRegisteredBlueprintComponents,
getBlueprintMetadata,
clearRegisteredComponents,
@@ -56,6 +21,8 @@ export type {
BlueprintExposeOptions,
BlueprintPropertyOptions,
BlueprintMethodOptions,
BlueprintArrayOptions,
BlueprintObjectOptions,
PropertyMetadata,
MethodMetadata,
ComponentBlueprintMetadata

View File

@@ -1,3 +1,5 @@
export * from './pins';
export * from './nodes';
export * from './blueprint';
export * from './schema';
export * from './path-utils';

View File

@@ -4,6 +4,7 @@
*/
import { BlueprintPinDefinition } from './pins';
import { ObjectSchema } from './schema';
/**
* Node category for visual styling and organization
@@ -70,6 +71,23 @@ export interface BlueprintNodeTemplate {
/** Node color for visual distinction (节点颜色用于视觉区分) */
color?: string;
// ========== Schema Support (Schema 支持) ==========
/**
* @zh 节点数据 Schema - 定义节点存储的数据结构
* @en Node data schema - defines the data structure stored in the node
*
* @zh 当定义了 schema 时,节点数据将按照 schema 结构存储和验证
* @en When schema is defined, node data will be stored and validated according to the schema structure
*/
schema?: ObjectSchema;
/**
* @zh 动态数组路径列表 - 指定哪些数组支持动态增删元素
* @en Dynamic array paths - specifies which arrays support dynamic add/remove elements
*/
dynamicArrayPaths?: string[];
}
/**
@@ -96,6 +114,9 @@ export interface BlueprintNode {
/**
* Connection between two pins
* 两个引脚之间的连接
*
* @zh 引脚路径支持数组索引,如 "waypoints[0].position"
* @en Pin paths support array indices, e.g., "waypoints[0].position"
*/
export interface BlueprintConnection {
/** Unique connection ID (唯一连接ID) */
@@ -104,13 +125,19 @@ export interface BlueprintConnection {
/** Source node ID (源节点ID) */
fromNodeId: string;
/** Source pin name (源引脚名称) */
/**
* @zh 源引脚路径(支持数组索引如 "items[0].value"
* @en Source pin path (supports array indices like "items[0].value")
*/
fromPin: string;
/** Target node ID (目标节点ID) */
toNodeId: string;
/** Target pin name (目标引脚名称) */
/**
* @zh 目标引脚路径(支持数组索引如 "items[0].value"
* @en Target pin path (supports array indices like "items[0].value")
*/
toPin: string;
}

View File

@@ -0,0 +1,549 @@
/**
* @zh 蓝图路径工具
* @en Blueprint Path Utilities
*
* @zh 用于解析和操作数据路径,支持数组索引和嵌套属性访问
* @en Used to parse and manipulate data paths, supports array indices and nested property access
*/
// ============================================================================
// Path Types
// ============================================================================
/**
* @zh 路径部分类型
* @en Path part type
*/
export type PathPartType = 'property' | 'index' | 'wildcard';
/**
* @zh 路径部分
* @en Path part
*/
export interface PathPart {
type: PathPartType;
/** Property name (for 'property' type) */
name?: string;
/** Array index (for 'index' type) */
index?: number;
}
/**
* @zh 端口地址 - 解析后的路径结构
* @en Port address - parsed path structure
*/
export interface PortAddress {
/** Base property name (基础属性名) */
baseName: string;
/** Array indices [0, 2] represents arr[0][2] (数组索引路径) */
indices: number[];
/** Nested property path ['x', 'y'] (嵌套属性路径) */
subPath: string[];
/** Original path string (原始路径字符串) */
original: string;
}
// ============================================================================
// Path Parsing
// ============================================================================
/**
* @zh 解析路径字符串为部分数组
* @en Parse path string to parts array
*
* @example
* parsePath("waypoints[0].position.x")
* // => [
* // { type: 'property', name: 'waypoints' },
* // { type: 'index', index: 0 },
* // { type: 'property', name: 'position' },
* // { type: 'property', name: 'x' }
* // ]
*/
export function parsePath(path: string): PathPart[] {
const parts: PathPart[] = [];
const regex = /([^.\[\]]+)|\[(\*|\d+)\]/g;
let match;
while ((match = regex.exec(path)) !== null) {
if (match[1]) {
// Property name
parts.push({ type: 'property', name: match[1] });
} else if (match[2] === '*') {
// Wildcard
parts.push({ type: 'wildcard' });
} else {
// Array index
parts.push({ type: 'index', index: parseInt(match[2], 10) });
}
}
return parts;
}
/**
* @zh 解析端口路径字符串为 PortAddress
* @en Parse port path string to PortAddress
*
* @example
* parsePortPath("waypoints[0].position.x")
* // => { baseName: "waypoints", indices: [0], subPath: ["position", "x"], original: "..." }
*/
export function parsePortPath(path: string): PortAddress {
const result: PortAddress = {
baseName: '',
indices: [],
subPath: [],
original: path
};
const parts = parsePath(path);
let foundFirstIndex = false;
let afterIndices = false;
for (const part of parts) {
if (part.type === 'property') {
if (!foundFirstIndex) {
if (result.baseName) {
// Multiple properties before index - treat as nested base
result.baseName += '.' + part.name;
} else {
result.baseName = part.name!;
}
} else {
afterIndices = true;
result.subPath.push(part.name!);
}
} else if (part.type === 'index') {
foundFirstIndex = true;
if (!afterIndices) {
result.indices.push(part.index!);
} else {
// Index after property - encode in subPath
result.subPath.push(`[${part.index}]`);
}
}
}
return result;
}
/**
* @zh 构建路径字符串
* @en Build path string from parts
*/
export function buildPath(parts: PathPart[]): string {
let path = '';
for (const part of parts) {
switch (part.type) {
case 'property':
if (path && !path.endsWith('[')) {
path += '.';
}
path += part.name;
break;
case 'index':
path += `[${part.index}]`;
break;
case 'wildcard':
path += '[*]';
break;
}
}
return path;
}
/**
* @zh 从 PortAddress 构建路径字符串
* @en Build path string from PortAddress
*/
export function buildPortPath(address: PortAddress): string {
let path = address.baseName;
for (const index of address.indices) {
path += `[${index}]`;
}
if (address.subPath.length > 0) {
for (const sub of address.subPath) {
if (sub.startsWith('[')) {
path += sub;
} else {
path += '.' + sub;
}
}
}
return path;
}
// ============================================================================
// Data Access
// ============================================================================
/**
* @zh 根据路径获取数据
* @en Get data by path
*
* @example
* const data = { waypoints: [{ position: { x: 10, y: 20 } }] };
* getByPath(data, "waypoints[0].position.x") // => 10
*/
export function getByPath(data: unknown, path: string): unknown {
if (!path) return data;
const parts = parsePath(path);
let current: unknown = data;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
switch (part.type) {
case 'property':
if (typeof current === 'object' && current !== null) {
current = (current as Record<string, unknown>)[part.name!];
} else {
return undefined;
}
break;
case 'index':
if (Array.isArray(current)) {
current = current[part.index!];
} else {
return undefined;
}
break;
case 'wildcard':
// Wildcard returns array of all values
if (Array.isArray(current)) {
return current;
}
return undefined;
}
}
return current;
}
/**
* @zh 根据路径设置数据
* @en Set data by path
*
* @example
* const data = { waypoints: [{ position: { x: 0, y: 0 } }] };
* setByPath(data, "waypoints[0].position.x", 100);
* // data.waypoints[0].position.x === 100
*/
export function setByPath(data: unknown, path: string, value: unknown): boolean {
if (!path) return false;
const parts = parsePath(path);
let current: unknown = data;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (current === null || current === undefined) {
return false;
}
switch (part.type) {
case 'property':
if (typeof current === 'object' && current !== null) {
current = (current as Record<string, unknown>)[part.name!];
} else {
return false;
}
break;
case 'index':
if (Array.isArray(current)) {
current = current[part.index!];
} else {
return false;
}
break;
case 'wildcard':
// Cannot set on wildcard
return false;
}
}
// Set the final value
const lastPart = parts[parts.length - 1];
if (current === null || current === undefined) {
return false;
}
switch (lastPart.type) {
case 'property':
if (typeof current === 'object' && current !== null) {
(current as Record<string, unknown>)[lastPart.name!] = value;
return true;
}
break;
case 'index':
if (Array.isArray(current)) {
current[lastPart.index!] = value;
return true;
}
break;
}
return false;
}
/**
* @zh 检查路径是否存在
* @en Check if path exists
*/
export function hasPath(data: unknown, path: string): boolean {
return getByPath(data, path) !== undefined;
}
/**
* @zh 删除路径上的数据
* @en Delete data at path
*/
export function deleteByPath(data: unknown, path: string): boolean {
if (!path) return false;
const parts = parsePath(path);
let current: unknown = data;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (current === null || current === undefined) {
return false;
}
switch (part.type) {
case 'property':
if (typeof current === 'object' && current !== null) {
current = (current as Record<string, unknown>)[part.name!];
} else {
return false;
}
break;
case 'index':
if (Array.isArray(current)) {
current = current[part.index!];
} else {
return false;
}
break;
default:
return false;
}
}
const lastPart = parts[parts.length - 1];
if (current === null || current === undefined) {
return false;
}
switch (lastPart.type) {
case 'property':
if (typeof current === 'object' && current !== null) {
delete (current as Record<string, unknown>)[lastPart.name!];
return true;
}
break;
case 'index':
if (Array.isArray(current)) {
current.splice(lastPart.index!, 1);
return true;
}
break;
}
return false;
}
// ============================================================================
// Array Operations
// ============================================================================
/**
* @zh 数组操作类型
* @en Array operation type
*/
export type ArrayOperation = 'insert' | 'remove' | 'move';
/**
* @zh 当数组元素变化时,更新路径中的索引
* @en Update indices in path when array elements change
*
* @param path - Original path (原始路径)
* @param arrayPath - Array base path (数组基础路径)
* @param operation - Operation type (操作类型)
* @param index - Target index (目标索引)
* @param toIndex - Move destination (移动目标,仅 move 操作)
* @returns Updated path or empty string if path becomes invalid (更新后的路径,如果路径失效则返回空字符串)
*/
export function updatePathOnArrayChange(
path: string,
arrayPath: string,
operation: ArrayOperation,
index: number,
toIndex?: number
): string {
// Check if path starts with arrayPath[
if (!path.startsWith(arrayPath + '[')) {
return path;
}
const address = parsePortPath(path);
if (address.indices.length === 0) {
return path;
}
const currentIndex = address.indices[0];
switch (operation) {
case 'insert':
if (currentIndex >= index) {
address.indices[0]++;
}
break;
case 'remove':
if (currentIndex === index) {
return ''; // Path becomes invalid
}
if (currentIndex > index) {
address.indices[0]--;
}
break;
case 'move':
if (toIndex !== undefined) {
if (currentIndex === index) {
address.indices[0] = toIndex;
} else if (index < toIndex) {
// Moving down
if (currentIndex > index && currentIndex <= toIndex) {
address.indices[0]--;
}
} else {
// Moving up
if (currentIndex >= toIndex && currentIndex < index) {
address.indices[0]++;
}
}
}
break;
}
return buildPortPath(address);
}
/**
* @zh 展开带通配符的路径
* @en Expand path with wildcards
*
* @example
* const data = { items: [{ x: 1 }, { x: 2 }, { x: 3 }] };
* expandWildcardPath("items[*].x", data)
* // => ["items[0].x", "items[1].x", "items[2].x"]
*/
export function expandWildcardPath(path: string, data: unknown): string[] {
const parts = parsePath(path);
const results: string[] = [];
function expand(currentParts: PathPart[], currentData: unknown, index: number): void {
if (index >= parts.length) {
results.push(buildPath(currentParts));
return;
}
const part = parts[index];
if (part.type === 'wildcard') {
if (Array.isArray(currentData)) {
for (let i = 0; i < currentData.length; i++) {
const newParts = [...currentParts, { type: 'index' as const, index: i }];
expand(newParts, currentData[i], index + 1);
}
}
} else {
const newParts = [...currentParts, part];
let nextData: unknown;
if (part.type === 'property' && typeof currentData === 'object' && currentData !== null) {
nextData = (currentData as Record<string, unknown>)[part.name!];
} else if (part.type === 'index' && Array.isArray(currentData)) {
nextData = currentData[part.index!];
}
expand(newParts, nextData, index + 1);
}
}
expand([], data, 0);
return results;
}
/**
* @zh 检查路径是否包含通配符
* @en Check if path contains wildcards
*/
export function hasWildcard(path: string): boolean {
return path.includes('[*]');
}
/**
* @zh 获取路径的父路径
* @en Get parent path
*
* @example
* getParentPath("items[0].position.x") // => "items[0].position"
* getParentPath("items[0]") // => "items"
* getParentPath("items") // => ""
*/
export function getParentPath(path: string): string {
const parts = parsePath(path);
if (parts.length <= 1) {
return '';
}
return buildPath(parts.slice(0, -1));
}
/**
* @zh 获取路径的最后一部分名称
* @en Get the last part name of path
*
* @example
* getPathLastName("items[0].position.x") // => "x"
* getPathLastName("items[0]") // => "[0]"
*/
export function getPathLastName(path: string): string {
const parts = parsePath(path);
if (parts.length === 0) {
return '';
}
const last = parts[parts.length - 1];
if (last.type === 'property') {
return last.name!;
} else if (last.type === 'index') {
return `[${last.index}]`;
} else {
return '[*]';
}
}

View File

@@ -0,0 +1,611 @@
/**
* @zh 蓝图属性 Schema 系统
* @en Blueprint Property Schema System
*
* @zh 提供递归类型定义,支持原始类型、数组、对象、枚举等复杂数据结构
* @en Provides recursive type definitions supporting primitives, arrays, objects, enums, etc.
*/
import { BlueprintPinType } from './pins';
// ============================================================================
// Schema Types
// ============================================================================
/**
* @zh 属性 Schema - 递归定义数据结构
* @en Property Schema - recursive data structure definition
*/
export type PropertySchema =
| PrimitiveSchema
| ArraySchema
| ObjectSchema
| EnumSchema
| RefSchema;
/**
* @zh 原始类型 Schema
* @en Primitive type schema
*/
export interface PrimitiveSchema {
type: 'primitive';
primitive: BlueprintPinType;
defaultValue?: unknown;
// Constraints | 约束
min?: number;
max?: number;
step?: number;
multiline?: boolean;
placeholder?: string;
}
/**
* @zh 数组类型 Schema
* @en Array type schema
*/
export interface ArraySchema {
type: 'array';
items: PropertySchema;
defaultValue?: unknown[];
// Constraints | 约束
minItems?: number;
maxItems?: number;
// UI Behavior | UI 行为
reorderable?: boolean;
collapsible?: boolean;
defaultCollapsed?: boolean;
itemLabel?: string;
}
/**
* @zh 对象类型 Schema
* @en Object type schema
*/
export interface ObjectSchema {
type: 'object';
properties: Record<string, PropertySchema>;
required?: string[];
// UI Behavior | UI 行为
collapsible?: boolean;
defaultCollapsed?: boolean;
displayName?: string;
}
/**
* @zh 枚举类型 Schema
* @en Enum type schema
*/
export interface EnumSchema {
type: 'enum';
options: EnumOption[];
defaultValue?: string | number;
}
/**
* @zh 枚举选项
* @en Enum option
*/
export interface EnumOption {
value: string | number;
label: string;
description?: string;
icon?: string;
}
/**
* @zh 引用类型 Schema
* @en Reference type schema
*
* @zh 引用 SchemaRegistry 中已注册的 Schema
* @en References a schema registered in SchemaRegistry
*/
export interface RefSchema {
type: 'ref';
ref: string;
}
// ============================================================================
// Schema Registry
// ============================================================================
/**
* @zh Schema 注册表
* @en Schema Registry
*
* @zh 用于注册和复用常用的 Schema 定义
* @en Used to register and reuse common Schema definitions
*/
export class SchemaRegistry {
private static schemas = new Map<string, PropertySchema>();
/**
* @zh 注册 Schema
* @en Register a schema
*/
static register(id: string, schema: PropertySchema): void {
this.schemas.set(id, schema);
}
/**
* @zh 获取 Schema
* @en Get a schema
*/
static get(id: string): PropertySchema | undefined {
return this.schemas.get(id);
}
/**
* @zh 解析引用,返回实际 Schema
* @en Resolve reference, return actual schema
*/
static resolve(schema: PropertySchema): PropertySchema {
if (schema.type === 'ref') {
const resolved = this.schemas.get(schema.ref);
if (!resolved) {
console.warn(`[SchemaRegistry] Schema not found: ${schema.ref}`);
return { type: 'primitive', primitive: 'any' };
}
return this.resolve(resolved);
}
return schema;
}
/**
* @zh 检查 Schema 是否已注册
* @en Check if schema is registered
*/
static has(id: string): boolean {
return this.schemas.has(id);
}
/**
* @zh 获取所有已注册的 Schema ID
* @en Get all registered schema IDs
*/
static keys(): string[] {
return Array.from(this.schemas.keys());
}
/**
* @zh 清空注册表
* @en Clear registry
*/
static clear(): void {
this.schemas.clear();
}
}
// ============================================================================
// Schema Utilities
// ============================================================================
/**
* @zh 获取 Schema 的默认值
* @en Get default value for a schema
*/
export function getSchemaDefaultValue(schema: PropertySchema): unknown {
const resolved = SchemaRegistry.resolve(schema);
switch (resolved.type) {
case 'primitive':
if (resolved.defaultValue !== undefined) return resolved.defaultValue;
return getPrimitiveDefaultValue(resolved.primitive);
case 'array':
if (resolved.defaultValue !== undefined) return [...resolved.defaultValue];
return [];
case 'object': {
const obj: Record<string, unknown> = {};
for (const [key, propSchema] of Object.entries(resolved.properties)) {
obj[key] = getSchemaDefaultValue(propSchema);
}
return obj;
}
case 'enum':
if (resolved.defaultValue !== undefined) return resolved.defaultValue;
return resolved.options[0]?.value;
default:
return undefined;
}
}
/**
* @zh 获取原始类型的默认值
* @en Get default value for primitive type
*/
export function getPrimitiveDefaultValue(primitive: BlueprintPinType): unknown {
switch (primitive) {
case 'bool': return false;
case 'int': return 0;
case 'float': return 0.0;
case 'string': return '';
case 'vector2': return { x: 0, y: 0 };
case 'vector3': return { x: 0, y: 0, z: 0 };
case 'color': return { r: 255, g: 255, b: 255, a: 255 };
case 'entity': return null;
case 'component': return null;
case 'object': return null;
case 'array': return [];
case 'any': return null;
case 'exec': return undefined;
default: return null;
}
}
/**
* @zh 根据 Schema 获取对应的 PinType
* @en Get corresponding PinType from Schema
*/
export function schemaToPinType(schema: PropertySchema): BlueprintPinType {
const resolved = SchemaRegistry.resolve(schema);
switch (resolved.type) {
case 'primitive':
return resolved.primitive;
case 'array':
return 'array';
case 'object':
return 'object';
case 'enum':
return typeof resolved.options[0]?.value === 'number' ? 'int' : 'string';
default:
return 'any';
}
}
/**
* @zh 验证数据是否符合 Schema
* @en Validate data against schema
*/
export function validateSchema(
schema: PropertySchema,
data: unknown,
path: string = ''
): ValidationResult {
const resolved = SchemaRegistry.resolve(schema);
const errors: ValidationError[] = [];
switch (resolved.type) {
case 'primitive':
validatePrimitive(resolved, data, path, errors);
break;
case 'array':
validateArray(resolved, data, path, errors);
break;
case 'object':
validateObject(resolved, data, path, errors);
break;
case 'enum':
validateEnum(resolved, data, path, errors);
break;
}
return {
valid: errors.length === 0,
errors
};
}
/**
* @zh 验证结果
* @en Validation result
*/
export interface ValidationResult {
valid: boolean;
errors: ValidationError[];
}
/**
* @zh 验证错误
* @en Validation error
*/
export interface ValidationError {
path: string;
message: string;
expected?: string;
received?: string;
}
function validatePrimitive(
schema: PrimitiveSchema,
data: unknown,
path: string,
errors: ValidationError[]
): void {
if (data === null || data === undefined) {
return; // Allow null/undefined for optional fields
}
const expectedType = getPrimitiveJsType(schema.primitive);
const actualType = typeof data;
if (expectedType === 'object') {
if (typeof data !== 'object') {
errors.push({
path,
message: `Expected ${schema.primitive}, got ${actualType}`,
expected: schema.primitive,
received: actualType
});
}
} else if (expectedType !== 'any' && actualType !== expectedType) {
errors.push({
path,
message: `Expected ${expectedType}, got ${actualType}`,
expected: expectedType,
received: actualType
});
}
// Numeric constraints
if ((schema.primitive === 'int' || schema.primitive === 'float') && typeof data === 'number') {
if (schema.min !== undefined && data < schema.min) {
errors.push({
path,
message: `Value ${data} is less than minimum ${schema.min}`
});
}
if (schema.max !== undefined && data > schema.max) {
errors.push({
path,
message: `Value ${data} is greater than maximum ${schema.max}`
});
}
}
}
function validateArray(
schema: ArraySchema,
data: unknown,
path: string,
errors: ValidationError[]
): void {
if (!Array.isArray(data)) {
errors.push({
path,
message: `Expected array, got ${typeof data}`,
expected: 'array',
received: typeof data
});
return;
}
if (schema.minItems !== undefined && data.length < schema.minItems) {
errors.push({
path,
message: `Array has ${data.length} items, minimum is ${schema.minItems}`
});
}
if (schema.maxItems !== undefined && data.length > schema.maxItems) {
errors.push({
path,
message: `Array has ${data.length} items, maximum is ${schema.maxItems}`
});
}
// Validate each item
for (let i = 0; i < data.length; i++) {
const itemResult = validateSchema(schema.items, data[i], `${path}[${i}]`);
errors.push(...itemResult.errors);
}
}
function validateObject(
schema: ObjectSchema,
data: unknown,
path: string,
errors: ValidationError[]
): void {
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
errors.push({
path,
message: `Expected object, got ${Array.isArray(data) ? 'array' : typeof data}`,
expected: 'object',
received: Array.isArray(data) ? 'array' : typeof data
});
return;
}
const obj = data as Record<string, unknown>;
// Check required fields
if (schema.required) {
for (const key of schema.required) {
if (!(key in obj)) {
errors.push({
path: path ? `${path}.${key}` : key,
message: `Missing required field: ${key}`
});
}
}
}
// Validate each property
for (const [key, propSchema] of Object.entries(schema.properties)) {
if (key in obj) {
const propPath = path ? `${path}.${key}` : key;
const propResult = validateSchema(propSchema, obj[key], propPath);
errors.push(...propResult.errors);
}
}
}
function validateEnum(
schema: EnumSchema,
data: unknown,
path: string,
errors: ValidationError[]
): void {
if (data === null || data === undefined) {
return;
}
const validValues = schema.options.map(o => o.value);
if (!validValues.includes(data as string | number)) {
errors.push({
path,
message: `Invalid enum value: ${data}`,
expected: validValues.join(' | '),
received: String(data)
});
}
}
function getPrimitiveJsType(primitive: BlueprintPinType): string {
switch (primitive) {
case 'bool': return 'boolean';
case 'int':
case 'float': return 'number';
case 'string': return 'string';
case 'vector2':
case 'vector3':
case 'color':
case 'entity':
case 'component':
case 'object':
case 'array': return 'object';
case 'any': return 'any';
case 'exec': return 'undefined';
default: return 'any';
}
}
/**
* @zh 深度克隆 Schema
* @en Deep clone schema
*/
export function cloneSchema(schema: PropertySchema): PropertySchema {
return JSON.parse(JSON.stringify(schema));
}
/**
* @zh 合并两个 ObjectSchema
* @en Merge two ObjectSchemas
*/
export function mergeObjectSchemas(
base: ObjectSchema,
override: Partial<ObjectSchema>
): ObjectSchema {
return {
...base,
...override,
properties: {
...base.properties,
...(override.properties || {})
},
required: [
...(base.required || []),
...(override.required || [])
]
};
}
// ============================================================================
// Schema Builder (Fluent API)
// ============================================================================
/**
* @zh Schema 构建器
* @en Schema Builder
*
* @example
* ```typescript
* const waypointSchema = Schema.object({
* position: Schema.vector2(),
* waitTime: Schema.float({ min: 0, defaultValue: 1.0 }),
* action: Schema.enum([
* { value: 'idle', label: 'Idle' },
* { value: 'patrol', label: 'Patrol' }
* ])
* });
*
* const pathSchema = Schema.array(waypointSchema, {
* minItems: 2,
* reorderable: true,
* itemLabel: 'Point {index1}'
* });
* ```
*/
export const Schema = {
// Primitives
bool(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'bool', ...options };
},
int(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'int', ...options };
},
float(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'float', ...options };
},
string(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'string', ...options };
},
vector2(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'vector2', ...options };
},
vector3(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'vector3', ...options };
},
color(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'color', ...options };
},
entity(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'entity', ...options };
},
component(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'component', ...options };
},
object_ref(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'object', ...options };
},
any(options?: Partial<Omit<PrimitiveSchema, 'type' | 'primitive'>>): PrimitiveSchema {
return { type: 'primitive', primitive: 'any', ...options };
},
// Complex types
array(
items: PropertySchema,
options?: Partial<Omit<ArraySchema, 'type' | 'items'>>
): ArraySchema {
return { type: 'array', items, ...options };
},
object(
properties: Record<string, PropertySchema>,
options?: Partial<Omit<ObjectSchema, 'type' | 'properties'>>
): ObjectSchema {
return { type: 'object', properties, ...options };
},
enum(
options: EnumOption[],
extra?: Partial<Omit<EnumSchema, 'type' | 'options'>>
): EnumSchema {
return { type: 'enum', options, ...extra };
},
ref(id: string): RefSchema {
return { type: 'ref', ref: id };
}
};