Compare commits
7 Commits
@esengine/
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6aef13d93 | ||
|
|
470abb8750 | ||
|
|
2f95758911 | ||
|
|
2e9f36b656 | ||
|
|
c188a36f2b | ||
|
|
50681553b5 | ||
|
|
4e66bd8e2b |
@@ -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' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -13,7 +13,7 @@ This guide covers how to use the Blueprint Visual Scripting Editor in Cocos Crea
|
||||
|
||||
Download the latest version from GitHub Release (Free):
|
||||
|
||||
**[Download Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
|
||||
**[Download Cocos Node Editor v1.2.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.2.0)**
|
||||
|
||||
> QQ Group: **481923584** | Website: [esengine.cn](https://esengine.cn/)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "Visual scripting system deeply integrated with ECS framework"
|
||||
|
||||
Blueprint Editor Plugin for Cocos Creator (Free):
|
||||
|
||||
**[Download Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
|
||||
**[Download Cocos Node Editor v1.2.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.2.0)**
|
||||
|
||||
> QQ Group: **481923584** | Website: [esengine.cn](https://esengine.cn/)
|
||||
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -13,7 +13,7 @@ description: "Cocos Creator 蓝图可视化脚本编辑器完整使用教程"
|
||||
|
||||
从 GitHub Release 下载最新版本(免费):
|
||||
|
||||
**[下载 Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
|
||||
**[下载 Cocos Node Editor v1.2.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.2.0)**
|
||||
|
||||
> 技术交流 QQ 群:**481923584** | 官网:[esengine.cn](https://esengine.cn/)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "与 ECS 框架深度集成的可视化脚本系统"
|
||||
|
||||
Cocos Creator 蓝图编辑器插件(免费):
|
||||
|
||||
**[下载 Cocos Node Editor v1.1.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.1.0)**
|
||||
**[下载 Cocos Node Editor v1.2.0](https://github.com/esengine/esengine/releases/tag/cocos-node-editor-v1.2.0)**
|
||||
|
||||
> 技术交流 QQ 群:**481923584** | 官网:[esengine.cn](https://esengine.cn/)
|
||||
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @esengine/blueprint
|
||||
|
||||
## 4.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#447](https://github.com/esengine/esengine/pull/447) [`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4) Thanks [@esengine](https://github.com/esengine)! - 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
|
||||
|
||||
## 4.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/blueprint",
|
||||
"version": "4.4.0",
|
||||
"version": "4.5.0",
|
||||
"description": "Visual scripting system - works with any ECS framework (ESEngine, Cocos, Laya, etc.)",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './pins';
|
||||
export * from './nodes';
|
||||
export * from './blueprint';
|
||||
export * from './schema';
|
||||
export * from './path-utils';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
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 };
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,12 @@
|
||||
# @esengine/fsm
|
||||
|
||||
## 9.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4)]:
|
||||
- @esengine/blueprint@4.5.0
|
||||
|
||||
## 8.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/fsm",
|
||||
"version": "8.0.0",
|
||||
"version": "9.0.0",
|
||||
"description": "Finite State Machine for ECS Framework / ECS 框架的有限状态机",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @esengine/ecs-framework-math
|
||||
|
||||
## 2.10.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4)]:
|
||||
- @esengine/blueprint@4.5.0
|
||||
|
||||
## 2.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/ecs-framework-math",
|
||||
"version": "2.10.0",
|
||||
"version": "2.10.1",
|
||||
"description": "ECS框架2D数学库 - 提供向量、矩阵、几何形状和碰撞检测功能",
|
||||
"main": "bin/index.js",
|
||||
"types": "bin/index.d.ts",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @esengine/network
|
||||
|
||||
## 13.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4)]:
|
||||
- @esengine/blueprint@4.5.0
|
||||
- @esengine/ecs-framework-math@2.10.1
|
||||
|
||||
## 12.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/network",
|
||||
"version": "12.0.0",
|
||||
"version": "13.0.0",
|
||||
"description": "Network synchronization for multiplayer games",
|
||||
"esengine": {
|
||||
"plugin": true,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @esengine/pathfinding
|
||||
|
||||
## 12.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4)]:
|
||||
- @esengine/blueprint@4.5.0
|
||||
- @esengine/ecs-framework-math@2.10.1
|
||||
|
||||
## 11.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/pathfinding",
|
||||
"version": "11.0.0",
|
||||
"version": "12.0.0",
|
||||
"description": "寻路系统 | Pathfinding System - A*, Grid, NavMesh",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @esengine/procgen
|
||||
|
||||
## 9.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4)]:
|
||||
- @esengine/blueprint@4.5.0
|
||||
|
||||
## 8.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/procgen",
|
||||
"version": "8.0.0",
|
||||
"version": "9.0.0",
|
||||
"description": "Procedural generation tools for ECS Framework / ECS 框架的程序化生成工具",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @esengine/spatial
|
||||
|
||||
## 12.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4)]:
|
||||
- @esengine/blueprint@4.5.0
|
||||
- @esengine/ecs-framework-math@2.10.1
|
||||
|
||||
## 11.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/spatial",
|
||||
"version": "11.0.0",
|
||||
"version": "12.0.0",
|
||||
"description": "Spatial query and indexing system for ECS Framework / ECS 框架的空间查询和索引系统",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @esengine/timer
|
||||
|
||||
## 9.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`4e66bd8`](https://github.com/esengine/esengine/commit/4e66bd8e2be80b366a7723dcc48b99df0457aed4)]:
|
||||
- @esengine/blueprint@4.5.0
|
||||
|
||||
## 8.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/timer",
|
||||
"version": "8.0.0",
|
||||
"version": "9.0.0",
|
||||
"description": "Timer and cooldown system for ECS Framework / ECS 框架的定时器和冷却系统",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# @esengine/demos
|
||||
|
||||
## 1.0.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @esengine/fsm@9.0.0
|
||||
- @esengine/pathfinding@12.0.0
|
||||
- @esengine/procgen@9.0.0
|
||||
- @esengine/spatial@12.0.0
|
||||
- @esengine/timer@9.0.0
|
||||
|
||||
## 1.0.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/demos",
|
||||
"version": "1.0.17",
|
||||
"version": "1.0.18",
|
||||
"private": true,
|
||||
"description": "Demo tests for ESEngine modules documentation",
|
||||
"type": "module",
|
||||
|
||||
Reference in New Issue
Block a user