diff --git a/.changeset/blueprint-schema-system.md b/.changeset/blueprint-schema-system.md new file mode 100644 index 00000000..08fb91c2 --- /dev/null +++ b/.changeset/blueprint-schema-system.md @@ -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 diff --git a/docs/src/content/docs/en/modules/blueprint/custom-nodes.md b/docs/src/content/docs/en/modules/blueprint/custom-nodes.md index bd23d0ba..f01f0d21 100644 --- a/docs/src/content/docs/en/modules/blueprint/custom-nodes.md +++ b/docs/src/content/docs/en/modules/blueprint/custom-nodes.md @@ -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' }; - } -} -``` diff --git a/docs/src/content/docs/modules/blueprint/custom-nodes.md b/docs/src/content/docs/modules/blueprint/custom-nodes.md index 66000ec7..55622ff2 100644 --- a/docs/src/content/docs/modules/blueprint/custom-nodes.md +++ b/docs/src/content/docs/modules/blueprint/custom-nodes.md @@ -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' }; - } -} -``` diff --git a/packages/framework/blueprint/src/registry/BlueprintDecorators.ts b/packages/framework/blueprint/src/registry/BlueprintDecorators.ts index 4d62f64e..13da9b58 100644 --- a/packages/framework/blueprint/src/registry/BlueprintDecorators.ts +++ b/packages/framework/blueprint/src/registry/BlueprintDecorators.ts @@ -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; + 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(); -/** - * @zh 获取所有已注册的蓝图组件 - * @en Get all registered blueprint components - */ export function getRegisteredBlueprintComponents(): Map { 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 = { 'number': 'float', diff --git a/packages/framework/blueprint/src/registry/index.ts b/packages/framework/blueprint/src/registry/index.ts index 316538d6..e84fea65 100644 --- a/packages/framework/blueprint/src/registry/index.ts +++ b/packages/framework/blueprint/src/registry/index.ts @@ -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 diff --git a/packages/framework/blueprint/src/types/index.ts b/packages/framework/blueprint/src/types/index.ts index bf556945..b43165af 100644 --- a/packages/framework/blueprint/src/types/index.ts +++ b/packages/framework/blueprint/src/types/index.ts @@ -1,3 +1,5 @@ export * from './pins'; export * from './nodes'; export * from './blueprint'; +export * from './schema'; +export * from './path-utils'; diff --git a/packages/framework/blueprint/src/types/nodes.ts b/packages/framework/blueprint/src/types/nodes.ts index 8e441dd3..6e200473 100644 --- a/packages/framework/blueprint/src/types/nodes.ts +++ b/packages/framework/blueprint/src/types/nodes.ts @@ -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; } diff --git a/packages/framework/blueprint/src/types/path-utils.ts b/packages/framework/blueprint/src/types/path-utils.ts new file mode 100644 index 00000000..7e064b90 --- /dev/null +++ b/packages/framework/blueprint/src/types/path-utils.ts @@ -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)[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)[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)[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)[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)[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)[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 '[*]'; + } +} diff --git a/packages/framework/blueprint/src/types/schema.ts b/packages/framework/blueprint/src/types/schema.ts new file mode 100644 index 00000000..8274c281 --- /dev/null +++ b/packages/framework/blueprint/src/types/schema.ts @@ -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; + 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(); + + /** + * @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 = {}; + 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; + + // 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 { + 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>): PrimitiveSchema { + return { type: 'primitive', primitive: 'bool', ...options }; + }, + + int(options?: Partial>): PrimitiveSchema { + return { type: 'primitive', primitive: 'int', ...options }; + }, + + float(options?: Partial>): PrimitiveSchema { + return { type: 'primitive', primitive: 'float', ...options }; + }, + + string(options?: Partial>): PrimitiveSchema { + return { type: 'primitive', primitive: 'string', ...options }; + }, + + vector2(options?: Partial>): PrimitiveSchema { + return { type: 'primitive', primitive: 'vector2', ...options }; + }, + + vector3(options?: Partial>): PrimitiveSchema { + return { type: 'primitive', primitive: 'vector3', ...options }; + }, + + color(options?: Partial>): PrimitiveSchema { + return { type: 'primitive', primitive: 'color', ...options }; + }, + + entity(options?: Partial>): PrimitiveSchema { + return { type: 'primitive', primitive: 'entity', ...options }; + }, + + component(options?: Partial>): PrimitiveSchema { + return { type: 'primitive', primitive: 'component', ...options }; + }, + + object_ref(options?: Partial>): PrimitiveSchema { + return { type: 'primitive', primitive: 'object', ...options }; + }, + + any(options?: Partial>): PrimitiveSchema { + return { type: 'primitive', primitive: 'any', ...options }; + }, + + // Complex types + array( + items: PropertySchema, + options?: Partial> + ): ArraySchema { + return { type: 'array', items, ...options }; + }, + + object( + properties: Record, + options?: Partial> + ): ObjectSchema { + return { type: 'object', properties, ...options }; + }, + + enum( + options: EnumOption[], + extra?: Partial> + ): EnumSchema { + return { type: 'enum', options, ...extra }; + }, + + ref(id: string): RefSchema { + return { type: 'ref', ref: id }; + } +};