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:
18
.changeset/blueprint-schema-system.md
Normal file
18
.changeset/blueprint-schema-system.md
Normal 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
|
||||||
@@ -3,6 +3,164 @@ title: "Custom Nodes"
|
|||||||
description: "Creating custom blueprint 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
|
## Defining Node Template
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -33,16 +191,11 @@ import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, Execution
|
|||||||
@RegisterNode(MyNodeTemplate)
|
@RegisterNode(MyNodeTemplate)
|
||||||
class MyNodeExecutor implements INodeExecutor {
|
class MyNodeExecutor implements INodeExecutor {
|
||||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||||
// Get input (using evaluateInput)
|
|
||||||
const value = context.evaluateInput(node.id, 'value', 0) as number;
|
const value = context.evaluateInput(node.id, 'value', 0) as number;
|
||||||
|
|
||||||
// Execute logic
|
|
||||||
const result = value * 2;
|
const result = value * 2;
|
||||||
|
|
||||||
// Return result
|
|
||||||
return {
|
return {
|
||||||
outputs: { result },
|
outputs: { result },
|
||||||
nextExec: 'exec' // Continue execution
|
nextExec: 'exec'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,19 +217,10 @@ NodeRegistry.instance.register(MyNodeTemplate, new MyNodeExecutor());
|
|||||||
```typescript
|
```typescript
|
||||||
import { NodeRegistry } from '@esengine/blueprint';
|
import { NodeRegistry } from '@esengine/blueprint';
|
||||||
|
|
||||||
// Get singleton
|
|
||||||
const registry = NodeRegistry.instance;
|
const registry = NodeRegistry.instance;
|
||||||
|
|
||||||
// Get all templates
|
|
||||||
const allTemplates = registry.getAllTemplates();
|
const allTemplates = registry.getAllTemplates();
|
||||||
|
|
||||||
// Get by category
|
|
||||||
const mathNodes = registry.getTemplatesByCategory('math');
|
const mathNodes = registry.getTemplatesByCategory('math');
|
||||||
|
|
||||||
// Search nodes
|
|
||||||
const results = registry.searchTemplates('add');
|
const results = registry.searchTemplates('add');
|
||||||
|
|
||||||
// Check existence
|
|
||||||
if (registry.has('MyCustomNode')) { ... }
|
if (registry.has('MyCustomNode')) { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -89,7 +233,7 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
|
|||||||
type: 'GetDistance',
|
type: 'GetDistance',
|
||||||
title: 'Get Distance',
|
title: 'Get Distance',
|
||||||
category: 'math',
|
category: 'math',
|
||||||
isPure: true, // Mark as pure node
|
isPure: true,
|
||||||
inputs: [
|
inputs: [
|
||||||
{ name: 'a', type: 'vector2', direction: 'input' },
|
{ name: 'a', type: 'vector2', direction: 'input' },
|
||||||
{ name: 'b', 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: "创建自定义蓝图节点"
|
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
|
```typescript
|
||||||
@@ -33,16 +191,11 @@ import { INodeExecutor, RegisterNode, BlueprintNode, ExecutionContext, Execution
|
|||||||
@RegisterNode(MyNodeTemplate)
|
@RegisterNode(MyNodeTemplate)
|
||||||
class MyNodeExecutor implements INodeExecutor {
|
class MyNodeExecutor implements INodeExecutor {
|
||||||
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
|
||||||
// 获取输入(使用 evaluateInput)
|
|
||||||
const value = context.evaluateInput(node.id, 'value', 0) as number;
|
const value = context.evaluateInput(node.id, 'value', 0) as number;
|
||||||
|
|
||||||
// 执行逻辑
|
|
||||||
const result = value * 2;
|
const result = value * 2;
|
||||||
|
|
||||||
// 返回结果
|
|
||||||
return {
|
return {
|
||||||
outputs: { result },
|
outputs: { result },
|
||||||
nextExec: 'exec' // 继续执行
|
nextExec: 'exec'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,19 +217,10 @@ NodeRegistry.instance.register(MyNodeTemplate, new MyNodeExecutor());
|
|||||||
```typescript
|
```typescript
|
||||||
import { NodeRegistry } from '@esengine/blueprint';
|
import { NodeRegistry } from '@esengine/blueprint';
|
||||||
|
|
||||||
// 获取单例
|
|
||||||
const registry = NodeRegistry.instance;
|
const registry = NodeRegistry.instance;
|
||||||
|
|
||||||
// 获取所有模板
|
|
||||||
const allTemplates = registry.getAllTemplates();
|
const allTemplates = registry.getAllTemplates();
|
||||||
|
|
||||||
// 按类别获取
|
|
||||||
const mathNodes = registry.getTemplatesByCategory('math');
|
const mathNodes = registry.getTemplatesByCategory('math');
|
||||||
|
|
||||||
// 搜索节点
|
|
||||||
const results = registry.searchTemplates('add');
|
const results = registry.searchTemplates('add');
|
||||||
|
|
||||||
// 检查是否存在
|
|
||||||
if (registry.has('MyCustomNode')) { ... }
|
if (registry.has('MyCustomNode')) { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -89,7 +233,7 @@ const PureNodeTemplate: BlueprintNodeTemplate = {
|
|||||||
type: 'GetDistance',
|
type: 'GetDistance',
|
||||||
title: 'Get Distance',
|
title: 'Get Distance',
|
||||||
category: 'math',
|
category: 'math',
|
||||||
isPure: true, // 标记为纯节点
|
isPure: true,
|
||||||
inputs: [
|
inputs: [
|
||||||
{ name: 'a', type: 'vector2', direction: 'input' },
|
{ name: 'a', type: 'vector2', direction: 'input' },
|
||||||
{ name: 'b', 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' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,118 +1,75 @@
|
|||||||
/**
|
/**
|
||||||
* @zh 蓝图装饰器 - 用于标记可在蓝图中使用的组件、属性和方法
|
* @zh 蓝图装饰器 - 用于标记可在蓝图中使用的组件、属性和方法
|
||||||
* @en Blueprint Decorators - Mark components, properties and methods for blueprint use
|
* @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 { BlueprintPinType } from '../types/pins';
|
||||||
|
import type { PropertySchema, ArraySchema, ObjectSchema } from '../types/schema';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types | 类型定义
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* @zh 参数定义
|
|
||||||
* @en Parameter definition
|
|
||||||
*/
|
|
||||||
export interface BlueprintParamDef {
|
export interface BlueprintParamDef {
|
||||||
/** @zh 参数名称 @en Parameter name */
|
|
||||||
name: string;
|
name: string;
|
||||||
/** @zh 显示名称 @en Display name */
|
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
/** @zh 引脚类型 @en Pin type */
|
|
||||||
type?: BlueprintPinType;
|
type?: BlueprintPinType;
|
||||||
/** @zh 默认值 @en Default value */
|
|
||||||
defaultValue?: unknown;
|
defaultValue?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @zh 蓝图暴露选项
|
|
||||||
* @en Blueprint expose options
|
|
||||||
*/
|
|
||||||
export interface BlueprintExposeOptions {
|
export interface BlueprintExposeOptions {
|
||||||
/** @zh 组件显示名称 @en Component display name */
|
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
/** @zh 组件描述 @en Component description */
|
|
||||||
description?: string;
|
description?: string;
|
||||||
/** @zh 组件分类 @en Component category */
|
|
||||||
category?: string;
|
category?: string;
|
||||||
/** @zh 组件颜色 @en Component color */
|
|
||||||
color?: string;
|
color?: string;
|
||||||
/** @zh 组件图标 @en Component icon */
|
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @zh 蓝图属性选项
|
|
||||||
* @en Blueprint property options
|
|
||||||
*/
|
|
||||||
export interface BlueprintPropertyOptions {
|
export interface BlueprintPropertyOptions {
|
||||||
/** @zh 属性显示名称 @en Property display name */
|
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
/** @zh 属性描述 @en Property description */
|
|
||||||
description?: string;
|
description?: string;
|
||||||
/** @zh 引脚类型 @en Pin type */
|
|
||||||
type?: BlueprintPinType;
|
type?: BlueprintPinType;
|
||||||
/** @zh 是否只读(不生成 Set 节点)@en Readonly (no Set node generated) */
|
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
/** @zh 默认值 @en Default value */
|
|
||||||
defaultValue?: unknown;
|
defaultValue?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @zh 蓝图方法选项
|
|
||||||
* @en Blueprint method options
|
|
||||||
*/
|
|
||||||
export interface BlueprintMethodOptions {
|
export interface BlueprintMethodOptions {
|
||||||
/** @zh 方法显示名称 @en Method display name */
|
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
/** @zh 方法描述 @en Method description */
|
|
||||||
description?: string;
|
description?: string;
|
||||||
/** @zh 是否是纯函数(无副作用)@en Is pure function (no side effects) */
|
|
||||||
isPure?: boolean;
|
isPure?: boolean;
|
||||||
/** @zh 参数列表 @en Parameter list */
|
|
||||||
params?: BlueprintParamDef[];
|
params?: BlueprintParamDef[];
|
||||||
/** @zh 返回值类型 @en Return type */
|
|
||||||
returnType?: BlueprintPinType;
|
returnType?: BlueprintPinType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 属性元数据
|
* @zh 蓝图数组属性选项
|
||||||
* @en Property metadata
|
* @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 {
|
export interface PropertyMetadata {
|
||||||
propertyKey: string;
|
propertyKey: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -120,12 +77,12 @@ export interface PropertyMetadata {
|
|||||||
pinType: BlueprintPinType;
|
pinType: BlueprintPinType;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
defaultValue?: unknown;
|
defaultValue?: unknown;
|
||||||
|
schema?: PropertySchema;
|
||||||
|
isDynamicArray?: boolean;
|
||||||
|
exposeElementPorts?: boolean;
|
||||||
|
portNameTemplate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @zh 方法元数据
|
|
||||||
* @en Method metadata
|
|
||||||
*/
|
|
||||||
export interface MethodMetadata {
|
export interface MethodMetadata {
|
||||||
methodKey: string;
|
methodKey: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -135,10 +92,6 @@ export interface MethodMetadata {
|
|||||||
returnType: BlueprintPinType;
|
returnType: BlueprintPinType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @zh 组件蓝图元数据
|
|
||||||
* @en Component blueprint metadata
|
|
||||||
*/
|
|
||||||
export interface ComponentBlueprintMetadata extends BlueprintExposeOptions {
|
export interface ComponentBlueprintMetadata extends BlueprintExposeOptions {
|
||||||
componentName: string;
|
componentName: string;
|
||||||
properties: PropertyMetadata[];
|
properties: PropertyMetadata[];
|
||||||
@@ -146,41 +99,25 @@ export interface ComponentBlueprintMetadata extends BlueprintExposeOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Registry | 注册表
|
// Registry
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* @zh 已注册的蓝图组件
|
|
||||||
* @en Registered blueprint components
|
|
||||||
*/
|
|
||||||
const registeredComponents = new Map<Function, ComponentBlueprintMetadata>();
|
const registeredComponents = new Map<Function, ComponentBlueprintMetadata>();
|
||||||
|
|
||||||
/**
|
|
||||||
* @zh 获取所有已注册的蓝图组件
|
|
||||||
* @en Get all registered blueprint components
|
|
||||||
*/
|
|
||||||
export function getRegisteredBlueprintComponents(): Map<Function, ComponentBlueprintMetadata> {
|
export function getRegisteredBlueprintComponents(): Map<Function, ComponentBlueprintMetadata> {
|
||||||
return registeredComponents;
|
return registeredComponents;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @zh 获取组件的蓝图元数据
|
|
||||||
* @en Get blueprint metadata for a component
|
|
||||||
*/
|
|
||||||
export function getBlueprintMetadata(componentClass: Function): ComponentBlueprintMetadata | undefined {
|
export function getBlueprintMetadata(componentClass: Function): ComponentBlueprintMetadata | undefined {
|
||||||
return registeredComponents.get(componentClass);
|
return registeredComponents.get(componentClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @zh 清除所有注册的蓝图组件(用于测试)
|
|
||||||
* @en Clear all registered blueprint components (for testing)
|
|
||||||
*/
|
|
||||||
export function clearRegisteredComponents(): void {
|
export function clearRegisteredComponents(): void {
|
||||||
registeredComponents.clear();
|
registeredComponents.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Internal Helpers | 内部辅助函数
|
// Internal Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function getOrCreateMetadata(constructor: Function): ComponentBlueprintMetadata {
|
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 {
|
export function BlueprintExpose(options: BlueprintExposeOptions = {}): ClassDecorator {
|
||||||
return function (target: Function) {
|
return function (target: Function) {
|
||||||
const metadata = getOrCreateMetadata(target);
|
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 {
|
export function BlueprintProperty(options: BlueprintPropertyOptions = {}): PropertyDecorator {
|
||||||
return function (target: Object, propertyKey: string | symbol) {
|
return function (target: Object, propertyKey: string | symbol) {
|
||||||
const key = String(propertyKey);
|
const key = String(propertyKey);
|
||||||
@@ -257,25 +170,108 @@ export function BlueprintProperty(options: BlueprintPropertyOptions = {}): Prope
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 标记方法可在蓝图中调用
|
* @zh 标记属性为蓝图数组(支持动态增删、排序)
|
||||||
* @en Mark method as callable in blueprint
|
* @en Mark property as blueprint array (supports dynamic add/remove, reorder)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* @BlueprintMethod({
|
* @BlueprintArray({
|
||||||
* displayName: '攻击',
|
* displayName: '路径点',
|
||||||
* params: [
|
* itemSchema: Schema.object({
|
||||||
* { name: 'target', type: 'entity' },
|
* position: Schema.vector2(),
|
||||||
* { name: 'damage', type: 'float' }
|
* waitTime: Schema.float({ min: 0, defaultValue: 1.0 })
|
||||||
* ],
|
* }),
|
||||||
* returnType: 'bool'
|
* reorderable: true,
|
||||||
|
* exposeElementPorts: true,
|
||||||
|
* portNameTemplate: 'Point {index1}'
|
||||||
* })
|
* })
|
||||||
* attack(target: Entity, damage: number): boolean { }
|
* waypoints: Waypoint[] = [];
|
||||||
*
|
|
||||||
* @BlueprintMethod({ displayName: '获取速度', isPure: true, returnType: 'float' })
|
|
||||||
* getSpeed(): number { return this.speed; }
|
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
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 {
|
export function BlueprintMethod(options: BlueprintMethodOptions = {}): MethodDecorator {
|
||||||
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
|
return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) {
|
||||||
const key = String(propertyKey);
|
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 {
|
export function inferPinType(typeName: string): BlueprintPinType {
|
||||||
const typeMap: Record<string, BlueprintPinType> = {
|
const typeMap: Record<string, BlueprintPinType> = {
|
||||||
'number': 'float',
|
'number': 'float',
|
||||||
|
|||||||
@@ -1,43 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @zh 蓝图注册系统
|
* @zh 蓝图注册系统
|
||||||
* @en Blueprint Registry System
|
* @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 | 装饰器
|
// Decorators | 装饰器
|
||||||
@@ -45,6 +8,8 @@ export {
|
|||||||
BlueprintExpose,
|
BlueprintExpose,
|
||||||
BlueprintProperty,
|
BlueprintProperty,
|
||||||
BlueprintMethod,
|
BlueprintMethod,
|
||||||
|
BlueprintArray,
|
||||||
|
BlueprintObject,
|
||||||
getRegisteredBlueprintComponents,
|
getRegisteredBlueprintComponents,
|
||||||
getBlueprintMetadata,
|
getBlueprintMetadata,
|
||||||
clearRegisteredComponents,
|
clearRegisteredComponents,
|
||||||
@@ -56,6 +21,8 @@ export type {
|
|||||||
BlueprintExposeOptions,
|
BlueprintExposeOptions,
|
||||||
BlueprintPropertyOptions,
|
BlueprintPropertyOptions,
|
||||||
BlueprintMethodOptions,
|
BlueprintMethodOptions,
|
||||||
|
BlueprintArrayOptions,
|
||||||
|
BlueprintObjectOptions,
|
||||||
PropertyMetadata,
|
PropertyMetadata,
|
||||||
MethodMetadata,
|
MethodMetadata,
|
||||||
ComponentBlueprintMetadata
|
ComponentBlueprintMetadata
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export * from './pins';
|
export * from './pins';
|
||||||
export * from './nodes';
|
export * from './nodes';
|
||||||
export * from './blueprint';
|
export * from './blueprint';
|
||||||
|
export * from './schema';
|
||||||
|
export * from './path-utils';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { BlueprintPinDefinition } from './pins';
|
import { BlueprintPinDefinition } from './pins';
|
||||||
|
import { ObjectSchema } from './schema';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node category for visual styling and organization
|
* Node category for visual styling and organization
|
||||||
@@ -70,6 +71,23 @@ export interface BlueprintNodeTemplate {
|
|||||||
|
|
||||||
/** Node color for visual distinction (节点颜色用于视觉区分) */
|
/** Node color for visual distinction (节点颜色用于视觉区分) */
|
||||||
color?: string;
|
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
|
* Connection between two pins
|
||||||
* 两个引脚之间的连接
|
* 两个引脚之间的连接
|
||||||
|
*
|
||||||
|
* @zh 引脚路径支持数组索引,如 "waypoints[0].position"
|
||||||
|
* @en Pin paths support array indices, e.g., "waypoints[0].position"
|
||||||
*/
|
*/
|
||||||
export interface BlueprintConnection {
|
export interface BlueprintConnection {
|
||||||
/** Unique connection ID (唯一连接ID) */
|
/** Unique connection ID (唯一连接ID) */
|
||||||
@@ -104,13 +125,19 @@ export interface BlueprintConnection {
|
|||||||
/** Source node ID (源节点ID) */
|
/** Source node ID (源节点ID) */
|
||||||
fromNodeId: string;
|
fromNodeId: string;
|
||||||
|
|
||||||
/** Source pin name (源引脚名称) */
|
/**
|
||||||
|
* @zh 源引脚路径(支持数组索引如 "items[0].value")
|
||||||
|
* @en Source pin path (supports array indices like "items[0].value")
|
||||||
|
*/
|
||||||
fromPin: string;
|
fromPin: string;
|
||||||
|
|
||||||
/** Target node ID (目标节点ID) */
|
/** Target node ID (目标节点ID) */
|
||||||
toNodeId: string;
|
toNodeId: string;
|
||||||
|
|
||||||
/** Target pin name (目标引脚名称) */
|
/**
|
||||||
|
* @zh 目标引脚路径(支持数组索引如 "items[0].value")
|
||||||
|
* @en Target pin path (supports array indices like "items[0].value")
|
||||||
|
*/
|
||||||
toPin: string;
|
toPin: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
549
packages/framework/blueprint/src/types/path-utils.ts
Normal file
549
packages/framework/blueprint/src/types/path-utils.ts
Normal 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 '[*]';
|
||||||
|
}
|
||||||
|
}
|
||||||
611
packages/framework/blueprint/src/types/schema.ts
Normal file
611
packages/framework/blueprint/src/types/schema.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user