diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d473b16..66de7352 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,8 @@ jobs: cd ../behavior-tree && pnpm run build cd ../tilemap && pnpm run build cd ../physics-rapier2d && pnpm run build + cd ../node-editor && pnpm run build + cd ../blueprint && pnpm run build - name: Build ecs-engine-bindgen run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d3a1a6d..1ec6801f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,10 @@ on: - core - behavior-tree - editor-core + - node-editor + - blueprint + - tilemap + - physics-rapier2d version_type: description: '版本更新类型' required: true @@ -57,11 +61,17 @@ jobs: run: pnpm install - name: Build core package (if needed) - if: ${{ github.event.inputs.package == 'behavior-tree' || github.event.inputs.package == 'editor-core' }} + if: ${{ github.event.inputs.package != 'core' && github.event.inputs.package != 'node-editor' }} run: | cd packages/core pnpm run build + - name: Build node-editor package (if needed for blueprint) + if: ${{ github.event.inputs.package == 'blueprint' }} + run: | + cd packages/node-editor + pnpm run build + # - name: Run tests # run: | # cd packages/${{ github.event.inputs.package }} diff --git a/packages/asset-system/src/core/AssetManager.ts b/packages/asset-system/src/core/AssetManager.ts index 7fe72d94..5b2a113e 100644 --- a/packages/asset-system/src/core/AssetManager.ts +++ b/packages/asset-system/src/core/AssetManager.ts @@ -135,7 +135,19 @@ export class AssetManager implements IAssetManager { } // 创建加载器 / Create loader - const loader = this._loaderFactory.createLoader(metadata.type); + let loader = this._loaderFactory.createLoader(metadata.type); + + // 如果没有找到 loader 且类型是 Custom,尝试重新解析类型 + // If no loader found and type is Custom, try to re-resolve the type + if (!loader && metadata.type === AssetType.Custom) { + const newType = this.resolveAssetType(metadata.path); + if (newType !== AssetType.Custom) { + // 更新 metadata 类型 / Update metadata type + this._database.updateAsset(guid, { type: newType }); + loader = this._loaderFactory.createLoader(newType); + } + } + if (!loader) { throw AssetLoadError.unsupportedType(guid, metadata.type); } @@ -238,17 +250,7 @@ export class AssetManager implements IAssetManager { let metadata = this._database.getMetadataByPath(path); if (!metadata) { // 动态创建元数据 / Create metadata dynamically - const fileExt = path.substring(path.lastIndexOf('.')).toLowerCase(); - let assetType = AssetType.Custom; - - // 根据文件扩展名确定资产类型 / Determine asset type by file extension - if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(fileExt)) { - assetType = AssetType.Texture; - } else if (['.json'].includes(fileExt)) { - assetType = AssetType.Json; - } else if (['.txt', '.md', '.xml', '.yaml'].includes(fileExt)) { - assetType = AssetType.Text; - } + const assetType = this.resolveAssetType(path); // 生成唯一GUID / Generate unique GUID const dynamicGuid = `dynamic_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; @@ -271,15 +273,59 @@ export class AssetManager implements IAssetManager { this._database.addAsset(metadata); this._pathToGuid.set(path, metadata.guid); } else { + // 如果之前缓存的类型是 Custom,检查是否现在有注册的 loader 可以处理 + // If previously cached as Custom, check if a registered loader can now handle it + if (metadata.type === AssetType.Custom) { + const newType = this.resolveAssetType(path); + if (newType !== AssetType.Custom) { + metadata.type = newType; + } + } this._pathToGuid.set(path, metadata.guid); } return this.loadAsset(metadata.guid, options); } + // 同样检查已缓存的资产,如果类型是 Custom 但现在有 loader 可以处理 + // Also check cached assets, if type is Custom but now a loader can handle it + const entry = this._assets.get(guid); + if (entry && entry.metadata.type === AssetType.Custom) { + const newType = this.resolveAssetType(path); + if (newType !== AssetType.Custom) { + entry.metadata.type = newType; + } + } + return this.loadAsset(guid, options); } + /** + * Resolve asset type from path + * 从路径解析资产类型 + */ + private resolveAssetType(path: string): AssetType { + // 首先尝试从已注册的加载器获取资产类型 / First try to get asset type from registered loaders + const loaderType = (this._loaderFactory as AssetLoaderFactory).getAssetTypeByPath(path); + if (loaderType !== null) { + return loaderType; + } + + // 如果没有找到匹配的加载器,使用默认的扩展名映射 / Fallback to default extension mapping + const fileExt = path.substring(path.lastIndexOf('.')).toLowerCase(); + + // 默认支持的基础类型 / Default supported basic types + if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(fileExt)) { + return AssetType.Texture; + } else if (['.json'].includes(fileExt)) { + return AssetType.Json; + } else if (['.txt', '.md', '.xml', '.yaml'].includes(fileExt)) { + return AssetType.Text; + } + + return AssetType.Custom; + } + /** * Load multiple assets * 批量加载资产 diff --git a/packages/asset-system/src/interfaces/IAssetLoader.ts b/packages/asset-system/src/interfaces/IAssetLoader.ts index 0c9257db..9bb44b7d 100644 --- a/packages/asset-system/src/interfaces/IAssetLoader.ts +++ b/packages/asset-system/src/interfaces/IAssetLoader.ts @@ -73,6 +73,18 @@ export interface IAssetLoaderFactory { * 检查类型是否有加载器 */ hasLoader(type: AssetType): boolean; + + /** + * Get asset type by file extension + * 根据文件扩展名获取资产类型 + */ + getAssetTypeByExtension(extension: string): AssetType | null; + + /** + * Get asset type by file path + * 根据文件路径获取资产类型 + */ + getAssetTypeByPath(path: string): AssetType | null; } /** diff --git a/packages/asset-system/src/loaders/AssetLoaderFactory.ts b/packages/asset-system/src/loaders/AssetLoaderFactory.ts index 01cdb1ee..a2be2092 100644 --- a/packages/asset-system/src/loaders/AssetLoaderFactory.ts +++ b/packages/asset-system/src/loaders/AssetLoaderFactory.ts @@ -72,6 +72,57 @@ export class AssetLoaderFactory implements IAssetLoaderFactory { return this._loaders.has(type); } + /** + * Get asset type by file extension + * 根据文件扩展名获取资产类型 + * + * @param extension - File extension including dot (e.g., '.btree', '.png') + * @returns Asset type if a loader supports this extension, null otherwise + */ + getAssetTypeByExtension(extension: string): AssetType | null { + const ext = extension.toLowerCase(); + for (const [type, loader] of this._loaders) { + if (loader.supportedExtensions.some(e => e.toLowerCase() === ext)) { + return type; + } + } + return null; + } + + /** + * Get asset type by file path + * 根据文件路径获取资产类型 + * + * Checks for compound extensions (like .tilemap.json) first, then simple extensions + * + * @param path - File path + * @returns Asset type if a loader supports this file, null otherwise + */ + getAssetTypeByPath(path: string): AssetType | null { + const lowerPath = path.toLowerCase(); + + // First check compound extensions (e.g., .tilemap.json) + for (const [type, loader] of this._loaders) { + for (const ext of loader.supportedExtensions) { + if (ext.includes('.') && ext.split('.').length > 2) { + // This is a compound extension like .tilemap.json + if (lowerPath.endsWith(ext.toLowerCase())) { + return type; + } + } + } + } + + // Then check simple extensions + const lastDot = path.lastIndexOf('.'); + if (lastDot !== -1) { + const ext = path.substring(lastDot).toLowerCase(); + return this.getAssetTypeByExtension(ext); + } + + return null; + } + /** * Get all registered loaders * 获取所有注册的加载器 diff --git a/packages/asset-system/src/types/AssetTypes.ts b/packages/asset-system/src/types/AssetTypes.ts index d6ef7e9f..b0402fa6 100644 --- a/packages/asset-system/src/types/AssetTypes.ts +++ b/packages/asset-system/src/types/AssetTypes.ts @@ -33,45 +33,50 @@ export enum AssetState { } /** - * Asset types supported by the system - * 系统支持的资产类型 + * Asset type - string based for extensibility + * 资产类型 - 使用字符串以支持插件扩展 + * + * Plugins can define their own asset types by using custom strings. + * Built-in types are provided as constants below. + * 插件可以通过使用自定义字符串定义自己的资产类型。 + * 内置类型作为常量提供如下。 */ -export enum AssetType { +export type AssetType = string; + +/** + * Built-in asset types provided by asset-system + * asset-system 提供的内置资产类型 + */ +export const AssetType = { /** 纹理 */ - Texture = 'texture', + Texture: 'texture', /** 网格 */ - Mesh = 'mesh', + Mesh: 'mesh', /** 材质 */ - Material = 'material', + Material: 'material', /** 着色器 */ - Shader = 'shader', + Shader: 'shader', /** 音频 */ - Audio = 'audio', + Audio: 'audio', /** 字体 */ - Font = 'font', + Font: 'font', /** 预制体 */ - Prefab = 'prefab', + Prefab: 'prefab', /** 场景 */ - Scene = 'scene', + Scene: 'scene', /** 脚本 */ - Script = 'script', + Script: 'script', /** 动画片段 */ - AnimationClip = 'animation', - /** 行为树 */ - BehaviorTree = 'behaviortree', - /** 瓦片地图 */ - Tilemap = 'tilemap', - /** 瓦片集 */ - Tileset = 'tileset', + AnimationClip: 'animation', /** JSON数据 */ - Json = 'json', + Json: 'json', /** 文本 */ - Text = 'text', + Text: 'text', /** 二进制 */ - Binary = 'binary', + Binary: 'binary', /** 自定义 */ - Custom = 'custom' -} + Custom: 'custom' +} as const; /** * Platform variants for assets diff --git a/packages/asset-system/tsconfig.json b/packages/asset-system/tsconfig.json index f1be6f3f..a9ef8feb 100644 --- a/packages/asset-system/tsconfig.json +++ b/packages/asset-system/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2020", "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], + "composite": true, "declaration": true, "declarationMap": true, "outDir": "./dist", diff --git a/packages/behavior-tree/package.json b/packages/behavior-tree/package.json index e24163e1..4b5f4baf 100644 --- a/packages/behavior-tree/package.json +++ b/packages/behavior-tree/package.json @@ -45,6 +45,7 @@ "peerDependencies": { "@esengine/ecs-framework": ">=2.0.0", "@esengine/ecs-components": "workspace:*", + "@esengine/asset-system": "workspace:*", "@esengine/editor-runtime": "workspace:*", "lucide-react": "^0.545.0", "react": "^18.3.1", @@ -54,6 +55,9 @@ "@esengine/ecs-components": { "optional": true }, + "@esengine/asset-system": { + "optional": true + }, "@esengine/editor-runtime": { "optional": true }, diff --git a/packages/behavior-tree/src/BehaviorTreeRuntimeModule.ts b/packages/behavior-tree/src/BehaviorTreeRuntimeModule.ts index 1fb45fba..593b1e9c 100644 --- a/packages/behavior-tree/src/BehaviorTreeRuntimeModule.ts +++ b/packages/behavior-tree/src/BehaviorTreeRuntimeModule.ts @@ -6,17 +6,22 @@ import type { IScene, ServiceContainer } from '@esengine/ecs-framework'; import { ComponentRegistry, Core } from '@esengine/ecs-framework'; import type { IRuntimeModuleLoader, SystemContext } from '@esengine/ecs-components'; +import type { AssetManager } from '@esengine/asset-system'; import { BehaviorTreeRuntimeComponent } from './execution/BehaviorTreeRuntimeComponent'; import { BehaviorTreeExecutionSystem } from './execution/BehaviorTreeExecutionSystem'; import { BehaviorTreeAssetManager } from './execution/BehaviorTreeAssetManager'; import { GlobalBlackboardService } from './Services/GlobalBlackboardService'; +import { BehaviorTreeLoader } from './loaders/BehaviorTreeLoader'; +import { BehaviorTreeAssetType } from './index'; /** * Behavior Tree Runtime Module * 行为树运行时模块 */ export class BehaviorTreeRuntimeModule implements IRuntimeModuleLoader { + private _loaderRegistered = false; + registerComponents(registry: typeof ComponentRegistry): void { registry.register(BehaviorTreeRuntimeComponent); } @@ -31,8 +36,28 @@ export class BehaviorTreeRuntimeModule implements IRuntimeModuleLoader { } createSystems(scene: IScene, context: SystemContext): void { + // 注册行为树加载器到 AssetManager + // Register behavior tree loader to AssetManager + const assetManager = context.assetManager as AssetManager | undefined; + console.log('[BehaviorTreeRuntimeModule] createSystems called, assetManager:', assetManager ? 'exists' : 'null'); + + if (!this._loaderRegistered && assetManager) { + assetManager.registerLoader(BehaviorTreeAssetType, new BehaviorTreeLoader()); + this._loaderRegistered = true; + console.log('[BehaviorTreeRuntimeModule] Registered BehaviorTreeLoader for type:', BehaviorTreeAssetType); + } + const behaviorTreeSystem = new BehaviorTreeExecutionSystem(Core); + // 设置 AssetManager 引用 + // Set AssetManager reference + if (assetManager) { + behaviorTreeSystem.setAssetManager(assetManager); + console.log('[BehaviorTreeRuntimeModule] Set assetManager on behaviorTreeSystem'); + } else { + console.warn('[BehaviorTreeRuntimeModule] assetManager is null, cannot set on behaviorTreeSystem'); + } + if (context.isEditor) { behaviorTreeSystem.enabled = false; } diff --git a/packages/behavior-tree/src/Serialization/EditorToBehaviorTreeDataConverter.ts b/packages/behavior-tree/src/Serialization/EditorToBehaviorTreeDataConverter.ts index 550f4778..13a006b7 100644 --- a/packages/behavior-tree/src/Serialization/EditorToBehaviorTreeDataConverter.ts +++ b/packages/behavior-tree/src/Serialization/EditorToBehaviorTreeDataConverter.ts @@ -15,6 +15,17 @@ interface EditorNode { children?: string[]; } +/** + * 编辑器连接数据接口 + */ +interface EditorConnection { + from: string; + to: string; + connectionType: 'node' | 'property'; + fromProperty?: string; + toProperty?: string; +} + /** * 编辑器行为树数据接口 */ @@ -27,6 +38,7 @@ interface EditorBehaviorTreeData { modifiedAt?: string; }; nodes: EditorNode[]; + connections?: EditorConnection[]; blackboard?: Record; } @@ -57,10 +69,18 @@ export class EditorToBehaviorTreeDataConverter { throw new Error('Behavior tree must have a root node'); } - // 转换所有节点 + // 构建属性绑定映射:nodeId -> { propertyName -> blackboardKey } + const propertyBindingsMap = this.buildPropertyBindingsMap(editorData); + + // 转换所有节点(过滤掉不可执行的节点,如黑板变量节点) const nodesMap = new Map(); for (const editorNode of editorData.nodes) { - const behaviorNodeData = this.convertNode(editorNode); + // 跳过黑板变量节点,它们只用于编辑器的可视化绑定 + if (this.isNonExecutableNode(editorNode)) { + continue; + } + const propertyBindings = propertyBindingsMap.get(editorNode.id); + const behaviorNodeData = this.convertNode(editorNode, propertyBindings); nodesMap.set(behaviorNodeData.id, behaviorNodeData); } @@ -79,19 +99,81 @@ export class EditorToBehaviorTreeDataConverter { } /** - * 转换单个节点 + * 从连接数据构建属性绑定映射 + * 处理 connectionType === 'property' 的连接,将黑板变量节点连接到目标节点的属性 */ - private static convertNode(editorNode: EditorNode): BehaviorNodeData { + private static buildPropertyBindingsMap( + editorData: EditorBehaviorTreeData + ): Map> { + const bindingsMap = new Map>(); + + if (!editorData.connections) { + return bindingsMap; + } + + // 构建节点 ID 到变量名的映射(用于黑板变量节点) + const nodeToVariableMap = new Map(); + for (const node of editorData.nodes) { + if (node.data['nodeType'] === 'blackboard-variable' && node.data['variableName']) { + nodeToVariableMap.set(node.id, node.data['variableName']); + } + } + + // 处理属性连接 + for (const conn of editorData.connections) { + if (conn.connectionType === 'property' && conn.toProperty) { + const variableName = nodeToVariableMap.get(conn.from); + if (variableName) { + // 获取或创建目标节点的绑定记录 + let bindings = bindingsMap.get(conn.to); + if (!bindings) { + bindings = {}; + bindingsMap.set(conn.to, bindings); + } + // 将属性绑定到黑板变量 + bindings[conn.toProperty] = variableName; + } + } + } + + return bindingsMap; + } + + /** + * 转换单个节点 + * @param editorNode 编辑器节点数据 + * @param propertyBindings 从连接中提取的属性绑定(可选) + */ + private static convertNode( + editorNode: EditorNode, + propertyBindings?: Record + ): BehaviorNodeData { const nodeType = this.mapNodeType(editorNode.template.type); const config = this.extractConfig(editorNode.data); - const bindings = this.extractBindings(editorNode.data); + // 从节点数据中提取绑定 + const dataBindings = this.extractBindings(editorNode.data); + // 合并连接绑定和数据绑定(连接绑定优先) + const bindings = { ...dataBindings, ...propertyBindings }; const abortType = this.extractAbortType(editorNode.data); + // 获取 implementationType:优先从 template.className,其次从 data 中的类型字段 + let implementationType: string | undefined = editorNode.template.className; + if (!implementationType) { + // 尝试从 data 中提取类型 + implementationType = this.extractImplementationType(editorNode.data, nodeType); + } + + if (!implementationType) { + console.warn(`[EditorToBehaviorTreeDataConverter] Node ${editorNode.id} has no implementationType, using fallback`); + // 根据节点类型使用默认实现 + implementationType = this.getDefaultImplementationType(nodeType); + } + return { id: editorNode.id, - name: editorNode.template.displayName || editorNode.template.className, + name: editorNode.template.displayName || editorNode.template.className || implementationType, nodeType, - implementationType: editorNode.template.className, + implementationType, children: editorNode.children || [], config, ...(Object.keys(bindings).length > 0 && { bindings }), @@ -99,6 +181,64 @@ export class EditorToBehaviorTreeDataConverter { }; } + /** + * 检查是否为不可执行的节点(如黑板变量节点) + * 这些节点只在编辑器中使用,不参与运行时执行 + */ + private static isNonExecutableNode(editorNode: EditorNode): boolean { + const nodeType = editorNode.data['nodeType']; + // 黑板变量节点不需要执行,只用于可视化绑定 + return nodeType === 'blackboard-variable'; + } + + /** + * 从节点数据中提取实现类型 + * + * 优先级: + * 1. template.className(标准方式) + * 2. data 中的类型字段(compositeType, actionType 等) + * 3. 特殊节点类型的默认值(如 Root) + */ + private static extractImplementationType(data: Record, nodeType: NodeType): string | undefined { + // 节点类型到数据字段的映射 + const typeFieldMap: Record = { + [NodeType.Composite]: 'compositeType', + [NodeType.Decorator]: 'decoratorType', + [NodeType.Action]: 'actionType', + [NodeType.Condition]: 'conditionType', + [NodeType.Root]: '', // Root 没有对应的数据字段 + }; + + const field = typeFieldMap[nodeType]; + if (field && data[field]) { + return data[field]; + } + + // Root 节点的特殊处理 + if (nodeType === NodeType.Root) { + return 'Root'; + } + + return undefined; + } + + /** + * 获取节点类型的默认实现 + * 当无法确定具体实现类型时使用 + */ + private static getDefaultImplementationType(nodeType: NodeType): string { + // 节点类型到默认实现的映射 + const defaultImplementations: Record = { + [NodeType.Root]: 'Root', + [NodeType.Composite]: 'Sequence', + [NodeType.Decorator]: 'Inverter', + [NodeType.Action]: 'Wait', + [NodeType.Condition]: 'AlwaysTrue', + }; + + return defaultImplementations[nodeType] || 'Unknown'; + } + /** * 映射节点类型 */ diff --git a/packages/behavior-tree/src/editor/BehaviorTreePlugin.ts b/packages/behavior-tree/src/editor/BehaviorTreePlugin.ts index 96384ca9..eedbd84b 100644 --- a/packages/behavior-tree/src/editor/BehaviorTreePlugin.ts +++ b/packages/behavior-tree/src/editor/BehaviorTreePlugin.ts @@ -1,22 +1,9 @@ /** - * Behavior Tree Unified Plugin - * 行为树统一插件 + * Behavior Tree Plugin Descriptor + * 行为树插件描述符 */ -import type { IScene, ServiceContainer } from '@esengine/ecs-framework'; -import { ComponentRegistry, Core } from '@esengine/ecs-framework'; -import type { - IPluginLoader, - IRuntimeModuleLoader, - PluginDescriptor, - SystemContext -} from '@esengine/editor-runtime'; - -// Runtime imports -import { BehaviorTreeRuntimeComponent } from '../execution/BehaviorTreeRuntimeComponent'; -import { BehaviorTreeExecutionSystem } from '../execution/BehaviorTreeExecutionSystem'; -import { BehaviorTreeAssetManager } from '../execution/BehaviorTreeAssetManager'; -import { GlobalBlackboardService } from '../Services/GlobalBlackboardService'; +import type { PluginDescriptor } from '@esengine/editor-runtime'; /** * 插件描述符 @@ -49,50 +36,3 @@ export const descriptor: PluginDescriptor = { ], icon: 'GitBranch' }; - -/** - * Behavior Tree Runtime Module - * 行为树运行时模块 - */ -export class BehaviorTreeRuntimeModule implements IRuntimeModuleLoader { - registerComponents(registry: typeof ComponentRegistry): void { - registry.register(BehaviorTreeRuntimeComponent); - } - - registerServices(services: ServiceContainer): void { - if (!services.isRegistered(GlobalBlackboardService)) { - services.registerSingleton(GlobalBlackboardService); - } - if (!services.isRegistered(BehaviorTreeAssetManager)) { - services.registerSingleton(BehaviorTreeAssetManager); - } - } - - createSystems(scene: IScene, context: SystemContext): void { - const behaviorTreeSystem = new BehaviorTreeExecutionSystem(Core); - - // 编辑器模式下默认禁用 - if (context.isEditor) { - behaviorTreeSystem.enabled = false; - } - - scene.addSystem(behaviorTreeSystem); - - // 保存引用 - context.behaviorTreeSystem = behaviorTreeSystem; - } -} - -/** - * Behavior Tree Plugin Loader - * 行为树插件加载器 - * - * 注意:editorModule 在 ./index.ts 中通过 createBehaviorTreePlugin() 设置 - */ -export const BehaviorTreePlugin: IPluginLoader = { - descriptor, - runtimeModule: new BehaviorTreeRuntimeModule(), - // editorModule 将在 index.ts 中设置 -}; - -export default BehaviorTreePlugin; diff --git a/packages/behavior-tree/src/editor/components/BehaviorTreeEditor.tsx b/packages/behavior-tree/src/editor/components/BehaviorTreeEditor.tsx index 1619c3a4..3c3d5679 100644 --- a/packages/behavior-tree/src/editor/components/BehaviorTreeEditor.tsx +++ b/packages/behavior-tree/src/editor/components/BehaviorTreeEditor.tsx @@ -462,8 +462,13 @@ export const BehaviorTreeEditor: React.FC = ({ handleNodeMouseUp(); }; - const getPortPosition = (nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') => - getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType, draggingNodeId, dragDelta, selectedNodeIds); + // 使用 useCallback 包装 getPortPosition,确保在 canvasScale/canvasOffset 变化时更新 + // Use useCallback to wrap getPortPosition to ensure updates when canvasScale/canvasOffset changes + const getPortPosition = useCallback( + (nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') => + getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType, draggingNodeId, dragDelta, selectedNodeIds), + [canvasOffset, canvasScale, nodes, draggingNodeId, dragDelta, selectedNodeIds] + ); stopExecutionRef.current = handleStop; diff --git a/packages/behavior-tree/src/editor/components/connections/ConnectionLayer.tsx b/packages/behavior-tree/src/editor/components/connections/ConnectionLayer.tsx index cabc0caf..457de9d9 100644 --- a/packages/behavior-tree/src/editor/components/connections/ConnectionLayer.tsx +++ b/packages/behavior-tree/src/editor/components/connections/ConnectionLayer.tsx @@ -11,6 +11,9 @@ interface ConnectionLayerProps { getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null; onConnectionClick?: (e: React.MouseEvent, fromId: string, toId: string) => void; onConnectionContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void; + /** 用于强制刷新连线(当 canvasScale 等变化时) */ + /** Used to force refresh connections (when canvasScale etc. changes) */ + refreshKey?: number; } export const ConnectionLayer: React.FC = ({ connections, diff --git a/packages/behavior-tree/src/editor/components/connections/ConnectionRenderer.tsx b/packages/behavior-tree/src/editor/components/connections/ConnectionRenderer.tsx index 0b6245f9..bfde6195 100644 --- a/packages/behavior-tree/src/editor/components/connections/ConnectionRenderer.tsx +++ b/packages/behavior-tree/src/editor/components/connections/ConnectionRenderer.tsx @@ -1,4 +1,4 @@ -import { React, useMemo } from '@esengine/editor-runtime'; +import { React } from '@esengine/editor-runtime'; import { ConnectionViewData } from '../../types'; import { Node } from '../../domain/models/Node'; @@ -20,45 +20,40 @@ const ConnectionRendererComponent: React.FC = ({ }) => { const { connection, isSelected } = connectionData; - const pathData = useMemo(() => { - let fromPos, toPos; + // 直接计算路径数据,不使用 useMemo + // getPortPosition 使用节点数据直接计算,不依赖缩放状态 + let fromPos, toPos; - if (connection.connectionType === 'property') { - // 属性连接:使用 fromProperty 和 toProperty - fromPos = getPortPosition(connection.from, connection.fromProperty); - toPos = getPortPosition(connection.to, connection.toProperty); - } else { - // 节点连接:使用输出和输入端口 - fromPos = getPortPosition(connection.from, undefined, 'output'); - toPos = getPortPosition(connection.to, undefined, 'input'); - } + if (connection.connectionType === 'property') { + fromPos = getPortPosition(connection.from, connection.fromProperty); + toPos = getPortPosition(connection.to, connection.toProperty); + } else { + fromPos = getPortPosition(connection.from, undefined, 'output'); + toPos = getPortPosition(connection.to, undefined, 'input'); + } - if (!fromPos || !toPos) { - return null; - } + if (!fromPos || !toPos) { + return null; + } - const x1 = fromPos.x; - const y1 = fromPos.y; - const x2 = toPos.x; - const y2 = toPos.y; + const x1 = fromPos.x; + const y1 = fromPos.y; + const x2 = toPos.x; + const y2 = toPos.y; - let pathD: string; + let pathD: string; - if (connection.connectionType === 'property') { - const controlX1 = x1 + (x2 - x1) * 0.5; - const controlX2 = x1 + (x2 - x1) * 0.5; - pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`; - } else { - const controlY = y1 + (y2 - y1) * 0.5; - pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`; - } + if (connection.connectionType === 'property') { + const controlX1 = x1 + (x2 - x1) * 0.5; + const controlX2 = x1 + (x2 - x1) * 0.5; + pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`; + } else { + const controlY = y1 + (y2 - y1) * 0.5; + pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`; + } - return { - path: pathD, - midX: (x1 + x2) / 2, - midY: (y1 + y2) / 2 - }; - }, [connection, fromNode, toNode, getPortPosition]); + const midX = (x1 + x2) / 2; + const midY = (y1 + y2) / 2; const isPropertyConnection = connection.connectionType === 'property'; @@ -69,11 +64,6 @@ const ConnectionRendererComponent: React.FC = ({ const gradientId = `gradient-${connection.from}-${connection.to}`; - if (!pathData) { - return null; - } - - const pathD = pathData.path; const endPosMatch = pathD.match(/C [0-9.-]+ [0-9.-]+, [0-9.-]+ [0-9.-]+, ([0-9.-]+) ([0-9.-]+)/); const endX = endPosMatch ? parseFloat(endPosMatch[1]) : 0; const endY = endPosMatch ? parseFloat(endPosMatch[2]) : 0; @@ -106,14 +96,14 @@ const ConnectionRendererComponent: React.FC = ({ = ({ /> = ({ {isSelected && ( <> void> = []; async install(services: ServiceContainer): Promise { this.services = services; @@ -69,10 +75,102 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader { // 注册节点检视器 this.registerInspectorProviders(services); + // 注册资产创建消息映射 + this.registerAssetCreationMappings(services); + + // 订阅创建资产消息 + this.subscribeToMessages(services); + logger.info('BehaviorTree editor module installed'); } + private registerAssetCreationMappings(services: ServiceContainer): void { + try { + const fileActionRegistry = services.resolve(FileActionRegistry); + if (fileActionRegistry) { + fileActionRegistry.registerAssetCreationMapping({ + extension: '.btree', + createMessage: 'behavior-tree:create-asset' + }); + } + } catch (error) { + logger.warn('FileActionRegistry not available:', error); + } + } + + private subscribeToMessages(services: ServiceContainer): void { + try { + const messageHub = services.resolve(IMessageHub); + if (messageHub) { + const unsubscribe = messageHub.subscribe('behavior-tree:create-asset', async (payload: { + entityId?: string; + onChange?: (value: string | null) => void; + }) => { + await this.handleCreateBehaviorTreeAsset(services, payload); + }); + this.unsubscribers.push(unsubscribe); + } + } catch (error) { + logger.warn('MessageHub not available:', error); + } + } + + private async handleCreateBehaviorTreeAsset( + services: ServiceContainer, + payload: { entityId?: string; onChange?: (value: string | null) => void } + ): Promise { + try { + const dialog = services.resolve(IDialogService); + const fileSystem = services.resolve(IFileSystemService); + const messageHub = services.resolve(IMessageHub); + + if (!dialog || !fileSystem) { + logger.error('Dialog or FileSystem service not available'); + return; + } + + const filePath = await dialog.saveDialog({ + title: 'Create Behavior Tree Asset', + filters: [{ name: 'Behavior Tree', extensions: ['btree'] }], + defaultPath: 'new-behavior-tree.btree' + }); + + if (!filePath) { + return; + } + + // 获取默认行为树内容 + const templates = this.getFileCreationTemplates(); + const btreeTemplate = templates.find(t => t.extension === 'btree'); + const content = btreeTemplate + ? await btreeTemplate.getContent(filePath.split(/[\\/]/).pop() || 'new-behavior-tree.btree') + : '{}'; + + await fileSystem.writeFile(filePath, content); + + if (payload.onChange) { + payload.onChange(filePath); + } + + // 打开行为树编辑器 + if (messageHub) { + messageHub.publish('dynamic-panel:open', { + panelId: 'behavior-tree-editor', + title: `Behavior Tree - ${filePath.split(/[\\/]/).pop()}` + }); + } + + logger.info('Created behavior tree asset:', filePath); + } catch (error) { + logger.error('Failed to create behavior tree asset:', error); + } + } + async uninstall(): Promise { + // 清理订阅 + this.unsubscribers.forEach(unsub => unsub()); + this.unsubscribers = []; + if (this.services) { this.services.unregister(FileSystemService); this.services.unregister(BehaviorTreeService); @@ -155,7 +253,7 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader { label: 'Behavior Tree', extension: 'btree', icon: 'GitBranch', - create: async (filePath: string) => { + getContent: (fileName: string) => { const rootNode = createRootNode(); const rootNodeData = { id: rootNode.id, @@ -170,16 +268,13 @@ export class BehaviorTreeEditorModule implements IEditorModuleLoader { }; const emptyTree = { - name: filePath.replace(/.*[/\\]/, '').replace('.btree', ''), + name: fileName.replace('.btree', ''), nodes: [rootNodeData], connections: [], variables: {} }; - const content = JSON.stringify(emptyTree, null, 2); - // Write using Tauri FS API - const { writeTextFile } = await import('@tauri-apps/plugin-fs'); - await writeTextFile(filePath, content); + return JSON.stringify(emptyTree, null, 2); } }]; } diff --git a/packages/behavior-tree/src/editor/utils/portUtils.ts b/packages/behavior-tree/src/editor/utils/portUtils.ts index eed51fea..fb7b0060 100644 --- a/packages/behavior-tree/src/editor/utils/portUtils.ts +++ b/packages/behavior-tree/src/editor/utils/portUtils.ts @@ -3,9 +3,14 @@ import { Node as BehaviorTreeNode } from '../domain/models/Node'; const logger = createLogger('portUtils'); +// 端口偏移常量(与 CSS 保持一致) +const NODE_PORT_OFFSET = 8; // top: -8px / bottom: -8px + /** * 获取端口在画布世界坐标系中的位置 - * 直接从 DOM 元素获取实际渲染位置,避免硬编码和手动计算 + * + * 由于 SVG 和节点都在同一个 transform 容器内,直接使用节点的世界坐标计算。 + * 这种方式不受缩放影响,因为不依赖 getBoundingClientRect。 */ export function getPortPosition( canvasRef: RefObject, @@ -20,41 +25,85 @@ export function getPortPosition( selectedNodeIds?: string[] ): { x: number; y: number } | null { const canvas = canvasRef.current; - if (!canvas) return null; - - const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId); - if (!node) return null; - - // 构造端口选择器 - let portSelector: string; - - if (propertyName) { - // 属性端口:使用 data-property 属性定位 - portSelector = `[data-node-id="${nodeId}"][data-property="${propertyName}"]`; - } else { - // 节点端口:使用 data-port-type 属性定位 - const portTypeAttr = portType === 'input' ? 'node-input' : 'node-output'; - portSelector = `[data-node-id="${nodeId}"][data-port-type="${portTypeAttr}"]`; - } - - const portElement = canvas.querySelector(portSelector) as HTMLElement; - if (!portElement) { - logger.warn(`Port not found: ${portSelector}`); + if (!canvas) { return null; } - // 获取端口和画布的屏幕矩形 - const portRect = portElement.getBoundingClientRect(); - const canvasRect = canvas.getBoundingClientRect(); + const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId); + if (!node) { + return null; + } - // 计算端口中心相对于画布的屏幕坐标 - const screenX = portRect.left + portRect.width / 2 - canvasRect.left; - const screenY = portRect.top + portRect.height / 2 - canvasRect.top; + // 获取节点 DOM 元素来获取尺寸 + const nodeElement = canvas.querySelector(`[data-node-id="${nodeId}"].bt-node`) as HTMLElement; + if (!nodeElement) { + return null; + } - // 转换为世界坐标 - // 屏幕坐标到世界坐标的转换:world = (screen - offset) / scale - const worldX = (screenX - canvasOffset.x) / canvasScale; - const worldY = (screenY - canvasOffset.y) / canvasScale; + // 使用 offsetWidth/offsetHeight 获取未缩放的原始尺寸 + const nodeWidth = nodeElement.offsetWidth; + const nodeHeight = nodeElement.offsetHeight; - return { x: worldX, y: worldY }; + // 节点世界坐标(考虑拖拽偏移) + let nodeX = node.position.x; + let nodeY = node.position.y; + + if (draggingNodeId && dragDelta) { + const isBeingDragged = draggingNodeId === nodeId || + (selectedNodeIds && selectedNodeIds.includes(nodeId) && selectedNodeIds.includes(draggingNodeId)); + if (isBeingDragged) { + nodeX += dragDelta.dx; + nodeY += dragDelta.dy; + } + } + + // 节点使用 transform: translate(-50%, -50%) 居中,所以 (nodeX, nodeY) 是视觉中心 + + if (propertyName) { + // 属性端口:需要找到端口在节点内的相对位置 + const portElement = nodeElement.querySelector(`[data-property="${propertyName}"]`) as HTMLElement; + if (!portElement) { + return null; + } + + // 使用 offsetLeft/offsetTop 获取相对于 offsetParent 的位置 + // 需要累加到节点元素 + let offsetX = 0; + let offsetY = 0; + let el: HTMLElement | null = portElement; + + while (el && el !== nodeElement) { + offsetX += el.offsetLeft; + offsetY += el.offsetTop; + el = el.offsetParent as HTMLElement | null; + } + + // 端口中心相对于节点左上角的偏移 + const portCenterX = offsetX + portElement.offsetWidth / 2; + const portCenterY = offsetY + portElement.offsetHeight / 2; + + // 节点左上角世界坐标 + const nodeLeft = nodeX - nodeWidth / 2; + const nodeTop = nodeY - nodeHeight / 2; + + return { + x: nodeLeft + portCenterX, + y: nodeTop + portCenterY + }; + } else { + // 节点端口(输入/输出) + if (portType === 'input') { + // 输入端口在顶部中央 + return { + x: nodeX, + y: nodeY - nodeHeight / 2 - NODE_PORT_OFFSET + }; + } else { + // 输出端口在底部中央 + return { + x: nodeX, + y: nodeY + nodeHeight / 2 + NODE_PORT_OFFSET + }; + } + } } diff --git a/packages/behavior-tree/src/execution/BehaviorTreeExecutionSystem.ts b/packages/behavior-tree/src/execution/BehaviorTreeExecutionSystem.ts index 7969dda4..50b89905 100644 --- a/packages/behavior-tree/src/execution/BehaviorTreeExecutionSystem.ts +++ b/packages/behavior-tree/src/execution/BehaviorTreeExecutionSystem.ts @@ -1,4 +1,5 @@ import { EntitySystem, Matcher, Entity, Time, Core, ECSSystem } from '@esengine/ecs-framework'; +import type { AssetManager } from '@esengine/asset-system'; import { BehaviorTreeRuntimeComponent } from './BehaviorTreeRuntimeComponent'; import { BehaviorTreeAssetManager } from './BehaviorTreeAssetManager'; import { NodeExecutorRegistry, NodeExecutionContext } from './NodeExecutor'; @@ -14,10 +15,13 @@ import './Executors'; */ @ECSSystem('BehaviorTreeExecution') export class BehaviorTreeExecutionSystem extends EntitySystem { - private assetManager: BehaviorTreeAssetManager | null = null; + private btAssetManager: BehaviorTreeAssetManager | null = null; private executorRegistry: NodeExecutorRegistry; private coreInstance: typeof Core | null = null; + /** 引用 asset-system 的 AssetManager(由 BehaviorTreeRuntimeModule 设置) */ + private _assetManager: AssetManager | null = null; + constructor(coreInstance?: typeof Core) { super(Matcher.empty().all(BehaviorTreeRuntimeComponent)); this.coreInstance = coreInstance || null; @@ -25,12 +29,102 @@ export class BehaviorTreeExecutionSystem extends EntitySystem { this.registerBuiltInExecutors(); } - private getAssetManager(): BehaviorTreeAssetManager { - if (!this.assetManager) { - const core = this.coreInstance || Core; - this.assetManager = core.services.resolve(BehaviorTreeAssetManager); + /** + * 设置 AssetManager 引用 + * Set AssetManager reference + */ + setAssetManager(assetManager: AssetManager | null): void { + this._assetManager = assetManager; + } + + /** + * 启动所有 autoStart 的行为树(用于预览模式) + * Start all autoStart behavior trees (for preview mode) + * + * 由于编辑器模式下系统默认禁用,实体添加时 onAdded 不会处理自动启动。 + * 预览开始时需要手动调用此方法来启动所有需要自动启动的行为树。 + */ + startAllAutoStartTrees(): void { + if (!this.scene) { + this.logger.warn('Scene not available, cannot start auto-start trees'); + return; } - return this.assetManager; + + const entities = this.scene.entities.findEntitiesWithComponent(BehaviorTreeRuntimeComponent); + for (const entity of entities) { + const runtime = entity.getComponent(BehaviorTreeRuntimeComponent); + if (runtime && runtime.autoStart && runtime.treeAssetId && !runtime.isRunning) { + this.ensureAssetLoaded(runtime.treeAssetId).then(() => { + if (runtime && runtime.autoStart && !runtime.isRunning) { + runtime.start(); + this.logger.debug(`Auto-started behavior tree for entity: ${entity.name}`); + } + }).catch(e => { + this.logger.error(`Failed to load behavior tree for entity ${entity.name}:`, e); + }); + } + } + } + + /** + * 当实体添加到系统时,处理自动启动 + * Handle auto-start when entity is added to system + */ + protected override onAdded(entity: Entity): void { + // 只有在系统启用时才自动启动 + // Only auto-start when system is enabled + if (!this.enabled) return; + + const runtime = entity.getComponent(BehaviorTreeRuntimeComponent); + if (runtime && runtime.autoStart && runtime.treeAssetId && !runtime.isRunning) { + // 先尝试加载资产(如果是文件路径) + this.ensureAssetLoaded(runtime.treeAssetId).then(() => { + // 检查实体是否仍然有效 + if (runtime && runtime.autoStart && !runtime.isRunning) { + runtime.start(); + this.logger.debug(`Auto-started behavior tree for entity: ${entity.name}`); + } + }).catch(e => { + this.logger.error(`Failed to load behavior tree for entity ${entity.name}:`, e); + }); + } + } + + /** + * 确保行为树资产已加载 + * Ensure behavior tree asset is loaded + */ + private async ensureAssetLoaded(assetIdOrPath: string): Promise { + const btAssetManager = this.getBTAssetManager(); + + // 如果资产已存在,直接返回 + if (btAssetManager.hasAsset(assetIdOrPath)) { + return; + } + + // 使用 AssetManager 加载(必须通过 setAssetManager 设置) + // Use AssetManager (must be set via setAssetManager) + if (!this._assetManager) { + this.logger.warn(`AssetManager not set, cannot load: ${assetIdOrPath}`); + return; + } + + try { + const result = await this._assetManager.loadAssetByPath(assetIdOrPath); + if (result && result.asset) { + this.logger.debug(`Behavior tree loaded via AssetManager: ${assetIdOrPath}`); + } + } catch (e) { + this.logger.warn(`Failed to load via AssetManager: ${assetIdOrPath}`, e); + } + } + + private getBTAssetManager(): BehaviorTreeAssetManager { + if (!this.btAssetManager) { + const core = this.coreInstance || Core; + this.btAssetManager = core.services.resolve(BehaviorTreeAssetManager); + } + return this.btAssetManager; } /** @@ -64,7 +158,7 @@ export class BehaviorTreeExecutionSystem extends EntitySystem { continue; } - const treeData = this.getAssetManager().getAsset(runtime.treeAssetId); + const treeData = this.getBTAssetManager().getAsset(runtime.treeAssetId); if (!treeData) { this.logger.warn(`未找到行为树资产: ${runtime.treeAssetId}`); continue; @@ -76,6 +170,12 @@ export class BehaviorTreeExecutionSystem extends EntitySystem { runtime.needsReset = false; } + // 初始化黑板变量(如果行为树定义了默认值) + // Initialize blackboard variables from tree definition + if (treeData.blackboardVariables && treeData.blackboardVariables.size > 0) { + runtime.initializeBlackboard(treeData.blackboardVariables); + } + this.executeTree(entity, runtime, treeData); } } diff --git a/packages/behavior-tree/src/execution/Executors/RootExecutor.ts b/packages/behavior-tree/src/execution/Executors/RootExecutor.ts new file mode 100644 index 00000000..3d16bec6 --- /dev/null +++ b/packages/behavior-tree/src/execution/Executors/RootExecutor.ts @@ -0,0 +1,37 @@ +import { TaskStatus, NodeType } from '../../Types/TaskStatus'; +import { INodeExecutor, NodeExecutionContext } from '../NodeExecutor'; +import { NodeExecutorMetadata } from '../NodeMetadata'; + +/** + * 根节点执行器 + * + * 行为树的入口节点,执行其唯一的子节点 + */ +@NodeExecutorMetadata({ + implementationType: 'Root', + nodeType: NodeType.Root, + displayName: '根节点', + description: '行为树的入口节点', + category: 'Root', + childrenConstraints: { + min: 1, + max: 1 + } +}) +export class RootExecutor implements INodeExecutor { + execute(context: NodeExecutionContext): TaskStatus { + const { nodeData } = context; + + // 根节点必须有且仅有一个子节点 + if (!nodeData.children || nodeData.children.length === 0) { + return TaskStatus.Failure; + } + + const childId = nodeData.children[0]!; + return context.executeChild(childId); + } + + reset(_context: NodeExecutionContext): void { + // 根节点没有需要重置的状态 + } +} diff --git a/packages/behavior-tree/src/execution/Executors/index.ts b/packages/behavior-tree/src/execution/Executors/index.ts index 88b21d47..e8781fe9 100644 --- a/packages/behavior-tree/src/execution/Executors/index.ts +++ b/packages/behavior-tree/src/execution/Executors/index.ts @@ -1,3 +1,4 @@ +export { RootExecutor } from './RootExecutor'; export { SequenceExecutor } from './SequenceExecutor'; export { SelectorExecutor } from './SelectorExecutor'; export { ParallelExecutor } from './ParallelExecutor'; diff --git a/packages/behavior-tree/src/index.ts b/packages/behavior-tree/src/index.ts index 36697633..dbc2177d 100644 --- a/packages/behavior-tree/src/index.ts +++ b/packages/behavior-tree/src/index.ts @@ -7,6 +7,10 @@ * @packageDocumentation */ +// Asset type constant for behavior tree +// 行为树资产类型常量 +export const BehaviorTreeAssetType = 'behaviortree' as const; + // Types export * from './Types/TaskStatus'; diff --git a/packages/behavior-tree/src/loaders/BehaviorTreeLoader.ts b/packages/behavior-tree/src/loaders/BehaviorTreeLoader.ts new file mode 100644 index 00000000..ee6f2f45 --- /dev/null +++ b/packages/behavior-tree/src/loaders/BehaviorTreeLoader.ts @@ -0,0 +1,110 @@ +/** + * Behavior Tree Asset Loader + * 行为树资产加载器 + * + * 实现 IAssetLoader 接口,用于通过 AssetManager 加载行为树文件 + */ + +import type { + IAssetLoader, + IAssetMetadata, + IAssetLoadOptions, + IAssetLoadResult +} from '@esengine/asset-system'; +import { Core } from '@esengine/ecs-framework'; +import { BehaviorTreeData } from '../execution/BehaviorTreeData'; +import { BehaviorTreeAssetManager } from '../execution/BehaviorTreeAssetManager'; +import { EditorToBehaviorTreeDataConverter } from '../Serialization/EditorToBehaviorTreeDataConverter'; +import { BehaviorTreeAssetType } from '../index'; + +/** + * 行为树资产接口 + */ +export interface IBehaviorTreeAsset { + /** 行为树数据 */ + data: BehaviorTreeData; + /** 文件路径 */ + path: string; +} + +/** + * 行为树加载器 + * Behavior tree loader implementing IAssetLoader interface + */ +export class BehaviorTreeLoader implements IAssetLoader { + readonly supportedType = BehaviorTreeAssetType; + readonly supportedExtensions = ['.btree']; + + /** + * 加载行为树资产 + * Load behavior tree asset + */ + async load( + path: string, + metadata: IAssetMetadata, + _options?: IAssetLoadOptions + ): Promise> { + // 获取文件系统服务 + const IFileSystemServiceKey = Symbol.for('IFileSystemService'); + const fileSystem = Core.services.tryResolve(IFileSystemServiceKey) as IFileSystem | null; + + if (!fileSystem) { + throw new Error('FileSystem service not available'); + } + + // 读取文件内容 + const content = await fileSystem.readFile(path); + + // 转换为运行时数据 + const treeData = EditorToBehaviorTreeDataConverter.fromEditorJSON(content); + + // 使用文件路径作为 ID + treeData.id = path; + + // 注册到 BehaviorTreeAssetManager(保持兼容性) + const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager); + if (btAssetManager) { + btAssetManager.loadAsset(treeData); + } + + const asset: IBehaviorTreeAsset = { + data: treeData, + path + }; + + return { + asset, + handle: 0, // 由 AssetManager 分配 + metadata, + loadTime: 0 + }; + } + + /** + * 检查是否可以加载 + * Check if can load this asset + */ + canLoad(path: string, _metadata: IAssetMetadata): boolean { + return path.endsWith('.btree'); + } + + /** + * 释放资产 + * Dispose asset + */ + dispose(asset: IBehaviorTreeAsset): void { + // 从 BehaviorTreeAssetManager 卸载 + const btAssetManager = Core.services.tryResolve(BehaviorTreeAssetManager); + if (btAssetManager && asset.data) { + btAssetManager.unloadAsset(asset.data.id); + } + } +} + +/** + * 文件系统接口(简化版,仅用于类型) + */ +interface IFileSystem { + readFile(path: string): Promise; + exists(path: string): Promise; +} diff --git a/packages/behavior-tree/vite.config.ts b/packages/behavior-tree/vite.config.ts index e12d85a4..24ab88ae 100644 --- a/packages/behavior-tree/vite.config.ts +++ b/packages/behavior-tree/vite.config.ts @@ -3,51 +3,65 @@ import { resolve } from 'path'; import dts from 'vite-plugin-dts'; import react from '@vitejs/plugin-react'; -// 自定义插件:将 CSS 内联到 JS 中 -function inlineCSS(): any { +/** + * 自定义插件:将 CSS 转换为自执行的样式注入代码 + * Custom plugin: Convert CSS to self-executing style injection code + * + * 当用户写 `import './styles.css'` 时,这个插件会: + * 1. 在构建时将 CSS 内容转换为 JS 代码 + * 2. JS 代码在模块导入时自动执行,将样式注入到 DOM + * 3. 使用唯一 ID 防止重复注入 + */ +function escapeUnsafeChars(str: string): string { + const charMap: Record = { + '<': '\\u003C', + '>': '\\u003E', + '/': '\\u002F', + '\\': '\\\\', + '\u2028': '\\u2028', + '\u2029': '\\u2029' + }; + return str.replace(/[<>\\/\u2028\u2029]/g, (x) => charMap[x] || x); +} + +function injectCSSPlugin(): unknown { + const cssIdMap = new Map(); + let cssCounter = 0; + return { - name: 'inline-css', + name: 'inject-css-plugin', enforce: 'post' as const, - // 在生成 bundle 时注入 CSS - generateBundle(_options: any, bundle: any) { + generateBundle(_options: unknown, bundle: Record) { const bundleKeys = Object.keys(bundle); - // 找到 CSS 文件 - const cssFile = bundleKeys.find(key => key.endsWith('.css')); - if (!cssFile || !bundle[cssFile]) { - return; + // 找到所有 CSS 文件 + const cssFiles = bundleKeys.filter(key => key.endsWith('.css')); + + for (const cssFile of cssFiles) { + const cssChunk = bundle[cssFile]; + if (!cssChunk || !cssChunk.source) continue; + + const cssContent = cssChunk.source; + const styleId = `esengine-behavior-tree-style-${cssCounter++}`; + cssIdMap.set(cssFile, styleId); + + // 生成样式注入代码 + const injectCode = `(function(){if(typeof document!=='undefined'){var s=document.createElement('style');s.id='${styleId}';if(!document.getElementById(s.id)){s.textContent=${escapeUnsafeChars(JSON.stringify(cssContent))};document.head.appendChild(s);}}})();`; + + // 注入到 editor/index.js 或共享 chunk + for (const jsKey of bundleKeys) { + if (!jsKey.endsWith('.js')) continue; + const jsChunk = bundle[jsKey]; + if (!jsChunk || jsChunk.type !== 'chunk' || !jsChunk.code) continue; + + if (jsKey === 'editor/index.js' || jsKey.match(/^index-[^/]+\.js$/)) { + jsChunk.code = injectCode + '\n' + jsChunk.code; + } + } + + // 删除独立的 CSS 文件 + delete bundle[cssFile]; } - - const cssContent = bundle[cssFile].source; - if (!cssContent) return; - - // 找到包含编辑器代码的主要 JS 文件 - // 优先查找 editor/index.js,然后是带 hash 的 index-*.js - const mainJsFile = bundleKeys.find(key => - (key === 'editor/index.js' || key.includes('index-')) && - key.endsWith('.js') && - bundle[key].type === 'chunk' && - bundle[key].code - ); - - if (mainJsFile && bundle[mainJsFile]) { - const injectCode = ` -(function() { - if (typeof document !== 'undefined') { - var style = document.createElement('style'); - style.id = 'esengine-behavior-tree-styles'; - if (!document.getElementById(style.id)) { - style.textContent = ${JSON.stringify(cssContent)}; - document.head.appendChild(style); - } - } -})(); -`; - bundle[mainJsFile].code = injectCode + bundle[mainJsFile].code; - } - - // 删除独立的 CSS 文件(已内联) - delete bundle[cssFile]; } }; } @@ -60,7 +74,7 @@ export default defineConfig({ outDir: 'dist', rollupTypes: false }), - inlineCSS() + injectCSSPlugin() ], esbuild: { jsx: 'automatic', diff --git a/packages/blueprint/package.json b/packages/blueprint/package.json new file mode 100644 index 00000000..9ff7b437 --- /dev/null +++ b/packages/blueprint/package.json @@ -0,0 +1,85 @@ +{ + "name": "@esengine/blueprint", + "version": "1.0.0", + "description": "Visual scripting system for ECS Framework", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./editor": { + "types": "./dist/editor/index.d.ts", + "import": "./dist/editor/index.js" + }, + "./plugin.json": "./plugin.json" + }, + "files": [ + "dist", + "plugin.json" + ], + "scripts": { + "clean": "rimraf dist tsconfig.tsbuildinfo", + "build": "vite build", + "build:watch": "vite build --watch", + "type-check": "tsc --noEmit" + }, + "keywords": [ + "ecs", + "blueprint", + "visual-scripting", + "game-engine", + "node-editor" + ], + "author": "yhh", + "license": "MIT", + "peerDependencies": { + "@esengine/ecs-framework": ">=2.0.0", + "@esengine/editor-runtime": "workspace:*", + "@esengine/node-editor": "workspace:*", + "lucide-react": "^0.545.0", + "react": "^18.3.1", + "zustand": "^4.5.2" + }, + "peerDependenciesMeta": { + "@esengine/editor-runtime": { + "optional": true + }, + "@esengine/node-editor": { + "optional": true + }, + "lucide-react": { + "optional": true + }, + "react": { + "optional": true + }, + "zustand": { + "optional": true + } + }, + "devDependencies": { + "@types/node": "^20.19.17", + "@types/react": "^18.3.12", + "@vitejs/plugin-react": "^4.7.0", + "rimraf": "^5.0.0", + "typescript": "^5.8.3", + "vite": "^6.0.7", + "vite-plugin-dts": "^3.7.0" + }, + "dependencies": { + "tslib": "^2.8.1" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "https://github.com/esengine/ecs-framework.git", + "directory": "packages/blueprint" + } +} diff --git a/packages/blueprint/plugin.json b/packages/blueprint/plugin.json new file mode 100644 index 00000000..ad3991d5 --- /dev/null +++ b/packages/blueprint/plugin.json @@ -0,0 +1,30 @@ +{ + "id": "@esengine/blueprint", + "name": "Blueprint System", + "version": "1.0.0", + "description": "Visual scripting system for creating game logic without code", + "category": "scripting", + "loadingPhase": "default", + "enabledByDefault": true, + "canContainContent": true, + "isEnginePlugin": false, + "modules": [ + { + "name": "BlueprintRuntime", + "type": "runtime", + "entry": "./src/runtime.ts" + }, + { + "name": "BlueprintEditor", + "type": "editor", + "entry": "./src/editor/index.ts" + } + ], + "dependencies": [ + { + "id": "@esengine/core", + "version": ">=1.0.0" + } + ], + "icon": "Workflow" +} diff --git a/packages/blueprint/src/editor/BlueprintPlugin.ts b/packages/blueprint/src/editor/BlueprintPlugin.ts new file mode 100644 index 00000000..8add4a77 --- /dev/null +++ b/packages/blueprint/src/editor/BlueprintPlugin.ts @@ -0,0 +1,180 @@ +/** + * Blueprint Editor Plugin - Integrates blueprint editor with the editor + * 蓝图编辑器插件 - 将蓝图编辑器与编辑器集成 + */ + +import { + type ServiceContainer, + type IPluginLoader, + type IEditorModuleLoader, + type PluginDescriptor, + type PanelDescriptor, + type MenuItemDescriptor, + type FileActionHandler, + type FileCreationTemplate, + PanelPosition, + FileSystem, + createLogger, + MessageHub, + IMessageHub +} from '@esengine/editor-runtime'; +import { BlueprintEditorPanel } from './components/BlueprintEditorPanel'; +import { useBlueprintEditorStore } from './stores/blueprintEditorStore'; +import { createEmptyBlueprint, validateBlueprintAsset } from '../types/blueprint'; + +const logger = createLogger('BlueprintEditorModule'); + +/** + * Blueprint 编辑器模块 + * Blueprint editor module + */ +class BlueprintEditorModule implements IEditorModuleLoader { + private services?: ServiceContainer; + + async install(services: ServiceContainer): Promise { + this.services = services; + logger.info('Blueprint editor module installed'); + } + + async uninstall(): Promise { + logger.info('Blueprint editor module uninstalled'); + } + + getPanels(): PanelDescriptor[] { + return [ + { + id: 'panel-blueprint-editor', + title: 'Blueprint Editor', + position: PanelPosition.Center, + defaultSize: 800, + resizable: true, + closable: true, + icon: 'Workflow', + order: 20, + isDynamic: true, + component: BlueprintEditorPanel + } + ]; + } + + getMenuItems(): MenuItemDescriptor[] { + return [ + { + id: 'blueprint-new', + label: 'New Blueprint', + parentId: 'file', + shortcut: 'Ctrl+Shift+B', + execute: () => { + useBlueprintEditorStore.getState().createNewBlueprint('New Blueprint'); + } + }, + { + id: 'view-blueprint-editor', + label: 'Blueprint Editor', + parentId: 'view', + shortcut: 'Ctrl+B' + } + ]; + } + + getFileActionHandlers(): FileActionHandler[] { + const services = this.services; + return [ + { + extensions: ['bp'], + onDoubleClick: async (filePath: string) => { + try { + // 使用 FileSystem API 读取文件 + const content = await FileSystem.readTextFile(filePath); + const data = JSON.parse(content); + + if (validateBlueprintAsset(data)) { + useBlueprintEditorStore.getState().loadBlueprint(data, filePath); + logger.info('Loaded blueprint:', filePath); + + // 打开蓝图编辑器面板 + if (services) { + const messageHub = services.resolve(IMessageHub); + if (messageHub) { + const fileName = filePath.split(/[\\/]/).pop() || 'Blueprint'; + messageHub.publish('dynamic-panel:open', { + panelId: 'panel-blueprint-editor', + title: `Blueprint - ${fileName}` + }); + } + } + } else { + logger.error('Invalid blueprint file:', filePath); + } + } catch (error) { + logger.error('Failed to load blueprint:', error); + } + } + } + ]; + } + + getFileCreationTemplates(): FileCreationTemplate[] { + return [ + { + id: 'create-blueprint', + label: 'Blueprint', + extension: 'bp', + icon: 'Workflow', + category: 'scripting', + getContent: (fileName: string) => { + const name = fileName.replace('.bp', ''); + const blueprint = createEmptyBlueprint(name); + return JSON.stringify(blueprint, null, 2); + } + } + ]; + } + + async onEditorReady(): Promise { + logger.info('Editor ready'); + } + + async onProjectOpen(_projectPath: string): Promise { + logger.info('Project opened'); + } + + async onProjectClose(): Promise { + useBlueprintEditorStore.getState().createNewBlueprint('New Blueprint'); + logger.info('Project closed'); + } +} + +/** + * Plugin descriptor + * 插件描述符 + */ +const descriptor: PluginDescriptor = { + id: '@esengine/blueprint', + name: 'Blueprint Visual Scripting', + version: '1.0.0', + description: 'Visual scripting system for creating game logic without code', + category: 'scripting', + icon: 'Workflow', + enabledByDefault: true, + canContainContent: true, + isEnginePlugin: true, + isCore: false, + modules: [ + { + name: 'BlueprintEditor', + type: 'editor', + loadingPhase: 'default', + panels: ['panel-blueprint-editor'] + } + ] +}; + +/** + * Blueprint Plugin Export + * 蓝图插件导出 + */ +export const BlueprintPlugin: IPluginLoader = { + descriptor, + editorModule: new BlueprintEditorModule() +}; diff --git a/packages/blueprint/src/editor/components/BlueprintCanvas.tsx b/packages/blueprint/src/editor/components/BlueprintCanvas.tsx new file mode 100644 index 00000000..96b0e517 --- /dev/null +++ b/packages/blueprint/src/editor/components/BlueprintCanvas.tsx @@ -0,0 +1,383 @@ +/** + * Blueprint Canvas - Main canvas for editing blueprints using NodeEditor + * 蓝图画布 - 使用 NodeEditor 编辑蓝图的主画布 + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { + NodeEditor, + Graph, + GraphNode, + Position, + Connection, + NodeContextMenu, + ConfirmDialog, + type NodeTemplate, + type NodeCategory, + type PinCategory +} from '@esengine/node-editor'; +import { useBlueprintEditorStore } from '../stores/blueprintEditorStore'; +import { NodeRegistry } from '../../runtime/NodeRegistry'; +import type { BlueprintNode, BlueprintConnection, BlueprintNodeTemplate } from '../../types/nodes'; +import type { BlueprintPinDefinition } from '../../types/pins'; + +interface ContextMenuState { + isOpen: boolean; + screenPosition: { x: number; y: number }; + canvasPosition: Position; +} + +interface DeleteDialogState { + isOpen: boolean; + nodeId: string; + nodeTitle: string; +} + +/** + * Map blueprint pin type to node-editor PinCategory + */ +function mapPinCategory(type: string): PinCategory { + switch (type) { + case 'exec': + return 'exec'; + case 'boolean': + case 'bool': + return 'bool'; + case 'integer': + case 'int': + return 'int'; + case 'float': + case 'number': + return 'float'; + case 'string': + return 'string'; + case 'vector2': + return 'vector2'; + case 'vector3': + return 'vector3'; + case 'vector4': + return 'vector4'; + case 'color': + return 'color'; + case 'object': + case 'reference': + return 'object'; + case 'array': + return 'array'; + case 'struct': + return 'struct'; + case 'enum': + return 'enum'; + default: + return 'any'; + } +} + +/** + * Map blueprint category to node-editor NodeCategory + */ +function mapNodeCategory(category?: string): NodeCategory { + switch (category) { + case 'event': + return 'event'; + case 'function': + return 'function'; + case 'pure': + return 'pure'; + case 'flow': + return 'flow'; + case 'variable': + return 'variable'; + case 'literal': + return 'literal'; + case 'comment': + return 'comment'; + default: + return 'function'; + } +} + +/** + * Convert blueprint node template to node-editor template + */ +function convertNodeTemplate(bpTemplate: BlueprintNodeTemplate): NodeTemplate { + return { + id: bpTemplate.type, + title: bpTemplate.title, + category: mapNodeCategory(bpTemplate.category), + icon: bpTemplate.icon, + inputPins: bpTemplate.inputs.map((p: BlueprintPinDefinition) => ({ + name: p.name, + displayName: p.displayName || p.name, + category: mapPinCategory(p.type), + defaultValue: p.defaultValue + })), + outputPins: bpTemplate.outputs.map((p: BlueprintPinDefinition) => ({ + name: p.name, + displayName: p.displayName || p.name, + category: mapPinCategory(p.type) + })) + }; +} + +/** + * Convert blueprint node to graph node + */ +function convertToGraphNode(node: BlueprintNode): GraphNode | null { + const bpTemplate = NodeRegistry.instance.getTemplate(node.type); + if (!bpTemplate) return null; + + const template = convertNodeTemplate(bpTemplate); + return new GraphNode( + node.id, + template, + new Position(node.position.x, node.position.y), + node.data + ); +} + +/** + * Convert blueprint connection to graph connection + */ +function convertToGraphConnection( + conn: BlueprintConnection, + nodes: BlueprintNode[], + graphNodes: GraphNode[] +): Connection | null { + const fromNode = nodes.find(n => n.id === conn.fromNodeId); + const toNode = nodes.find(n => n.id === conn.toNodeId); + if (!fromNode || !toNode) return null; + + const fromTemplate = NodeRegistry.instance.getTemplate(fromNode.type); + if (!fromTemplate) return null; + + const fromPin = fromTemplate.outputs.find(p => p.name === conn.fromPin); + if (!fromPin) return null; + + // Find graph nodes to get the actual pin IDs + const fromGraphNode = graphNodes.find(n => n.id === conn.fromNodeId); + const toGraphNode = graphNodes.find(n => n.id === conn.toNodeId); + if (!fromGraphNode || !toGraphNode) return null; + + // Find pins by name + const fromGraphPin = fromGraphNode.outputPins.find(p => p.name === conn.fromPin); + const toGraphPin = toGraphNode.inputPins.find(p => p.name === conn.toPin); + if (!fromGraphPin || !toGraphPin) return null; + + return new Connection( + conn.id, + conn.fromNodeId, + fromGraphPin.id, + conn.toNodeId, + toGraphPin.id, + mapPinCategory(fromPin.type) + ); +} + +/** + * Blueprint Canvas Component using NodeEditor + */ +export const BlueprintCanvas: React.FC = () => { + const { + blueprint, + selectedNodeIds, + selectNodes, + updateNodePosition, + addNode, + addConnection, + removeNode, + removeConnection + } = useBlueprintEditorStore(); + + const [selectedConnections, setSelectedConnections] = useState>(new Set()); + const [contextMenu, setContextMenu] = useState({ + isOpen: false, + screenPosition: { x: 0, y: 0 }, + canvasPosition: new Position(0, 0) + }); + const [deleteDialog, setDeleteDialog] = useState({ + isOpen: false, + nodeId: '', + nodeTitle: '' + }); + + // Convert blueprint to Graph + const graph = useMemo(() => { + if (!blueprint) return Graph.empty('blueprint', 'Blueprint'); + + const graphNodes: GraphNode[] = []; + for (const node of blueprint.nodes) { + const graphNode = convertToGraphNode(node); + if (graphNode) { + graphNodes.push(graphNode); + } + } + + const graphConnections: Connection[] = []; + for (const conn of blueprint.connections) { + const graphConn = convertToGraphConnection(conn, blueprint.nodes, graphNodes); + if (graphConn) { + graphConnections.push(graphConn); + } + } + + return new Graph('blueprint', blueprint.metadata.name, graphNodes, graphConnections); + }, [blueprint]); + + // Handle graph changes + const handleGraphChange = useCallback((newGraph: Graph) => { + if (!blueprint) return; + + // Update node positions + for (const graphNode of newGraph.nodes) { + const oldNode = blueprint.nodes.find(n => n.id === graphNode.id); + if (oldNode) { + if (oldNode.position.x !== graphNode.position.x || oldNode.position.y !== graphNode.position.y) { + updateNodePosition(graphNode.id, graphNode.position.x, graphNode.position.y); + } + } + } + + // Handle new connections + for (const graphConn of newGraph.connections) { + const exists = blueprint.connections.some(c => c.id === graphConn.id); + if (!exists) { + // Extract pin names from graph connection + const fromNode = newGraph.getNode(graphConn.fromNodeId); + const toNode = newGraph.getNode(graphConn.toNodeId); + if (fromNode && toNode) { + const fromPin = fromNode.outputPins.find(p => p.id === graphConn.fromPinId); + const toPin = toNode.inputPins.find(p => p.id === graphConn.toPinId); + if (fromPin && toPin) { + addConnection({ + id: graphConn.id, + fromNodeId: graphConn.fromNodeId, + fromPin: fromPin.name, + toNodeId: graphConn.toNodeId, + toPin: toPin.name + }); + } + } + } + } + + // Handle removed connections + for (const oldConn of blueprint.connections) { + const exists = newGraph.connections.some(c => c.id === oldConn.id); + if (!exists) { + removeConnection(oldConn.id); + } + } + }, [blueprint, updateNodePosition, addConnection, removeConnection]); + + // Handle selection changes + const handleSelectionChange = useCallback((nodeIds: Set, connectionIds: Set) => { + selectNodes(Array.from(nodeIds)); + setSelectedConnections(connectionIds); + }, [selectNodes]); + + // Handle canvas context menu - open node selection menu + const handleCanvasContextMenu = useCallback((position: Position, e: React.MouseEvent) => { + setContextMenu({ + isOpen: true, + screenPosition: { x: e.clientX, y: e.clientY }, + canvasPosition: position + }); + }, []); + + // Handle template selection from context menu + const handleSelectTemplate = useCallback((template: NodeTemplate, position: Position) => { + addNode({ + id: '', + type: template.id, + position: { x: position.x, y: position.y }, + data: {} + }); + }, [addNode]); + + // Close context menu + const handleCloseContextMenu = useCallback(() => { + setContextMenu(prev => ({ ...prev, isOpen: false })); + }, []); + + // Handle node context menu + const handleNodeContextMenu = useCallback((node: GraphNode, e: React.MouseEvent) => { + e.preventDefault(); + setDeleteDialog({ + isOpen: true, + nodeId: node.id, + nodeTitle: node.title + }); + }, []); + + // Handle delete confirmation + const handleConfirmDelete = useCallback(() => { + if (deleteDialog.nodeId) { + removeNode(deleteDialog.nodeId); + } + setDeleteDialog({ isOpen: false, nodeId: '', nodeTitle: '' }); + }, [deleteDialog.nodeId, removeNode]); + + // Handle delete cancel + const handleCancelDelete = useCallback(() => { + setDeleteDialog({ isOpen: false, nodeId: '', nodeTitle: '' }); + }, []); + + // Get available templates + const templates = useMemo(() => { + const allTemplates = NodeRegistry.instance.getAllTemplates(); + return allTemplates.map(t => convertNodeTemplate(t)); + }, []); + + if (!blueprint) { + return ( +
+
+

No blueprint loaded

+

Create a new blueprint or open an existing one

+
+
+ ); + } + + return ( + <> + + + + + ); +}; diff --git a/packages/blueprint/src/editor/components/BlueprintEditorPanel.tsx b/packages/blueprint/src/editor/components/BlueprintEditorPanel.tsx new file mode 100644 index 00000000..18ef7c0c --- /dev/null +++ b/packages/blueprint/src/editor/components/BlueprintEditorPanel.tsx @@ -0,0 +1,48 @@ +/** + * Blueprint Editor Panel - Main panel for blueprint editing + * 蓝图编辑器面板 - 蓝图编辑的主面板 + */ + +import React, { useEffect } from 'react'; +import { BlueprintCanvas } from './BlueprintCanvas'; +import { useBlueprintEditorStore } from '../stores/blueprintEditorStore'; + +// Import nodes to register them +// 导入节点以注册它们 +import '../../nodes'; + +/** + * Panel container styles + * 面板容器样式 + */ +const panelStyles: React.CSSProperties = { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + backgroundColor: '#1a1a2e', + color: '#fff', + overflow: 'hidden' +}; + +/** + * Blueprint Editor Panel Component + * 蓝图编辑器面板组件 + */ +export const BlueprintEditorPanel: React.FC = () => { + const { blueprint, createNewBlueprint } = useBlueprintEditorStore(); + + // Create a default blueprint if none exists + // 如果不存在则创建默认蓝图 + useEffect(() => { + if (!blueprint) { + createNewBlueprint('New Blueprint'); + } + }, [blueprint, createNewBlueprint]); + + return ( +
+ +
+ ); +}; diff --git a/packages/blueprint/src/editor/components/index.ts b/packages/blueprint/src/editor/components/index.ts new file mode 100644 index 00000000..6879e749 --- /dev/null +++ b/packages/blueprint/src/editor/components/index.ts @@ -0,0 +1,7 @@ +/** + * Blueprint Editor Components + * 蓝图编辑器组件 + */ + +export * from './BlueprintCanvas'; +export * from './BlueprintEditorPanel'; diff --git a/packages/blueprint/src/editor/index.ts b/packages/blueprint/src/editor/index.ts new file mode 100644 index 00000000..9d6159bd --- /dev/null +++ b/packages/blueprint/src/editor/index.ts @@ -0,0 +1,8 @@ +/** + * Blueprint Editor Module + * 蓝图编辑器模块 + */ + +export * from './components'; +export * from './stores'; +export * from './BlueprintPlugin'; diff --git a/packages/blueprint/src/editor/stores/blueprintEditorStore.ts b/packages/blueprint/src/editor/stores/blueprintEditorStore.ts new file mode 100644 index 00000000..66d5221b --- /dev/null +++ b/packages/blueprint/src/editor/stores/blueprintEditorStore.ts @@ -0,0 +1,256 @@ +/** + * Blueprint Editor Store - State management for blueprint editor + * 蓝图编辑器状态管理 + */ + +import { create } from 'zustand'; +import { BlueprintAsset, createEmptyBlueprint } from '../../types/blueprint'; +import { BlueprintNode, BlueprintConnection } from '../../types/nodes'; + +/** + * Blueprint editor state interface + * 蓝图编辑器状态接口 + */ +interface BlueprintEditorState { + /** Current blueprint being edited (当前编辑的蓝图) */ + blueprint: BlueprintAsset | null; + + /** Selected node IDs (选中的节点ID) */ + selectedNodeIds: string[]; + + /** Currently dragging node (当前拖拽的节点) */ + draggingNodeId: string | null; + + /** Canvas pan offset (画布平移偏移) */ + panOffset: { x: number; y: number }; + + /** Canvas zoom level (画布缩放级别) */ + zoom: number; + + /** Whether the blueprint has unsaved changes (是否有未保存的更改) */ + isDirty: boolean; + + /** Current file path if saved (当前文件路径) */ + filePath: string | null; + + // Actions (操作) + /** Create new blueprint (创建新蓝图) */ + createNewBlueprint: (name: string) => void; + + /** Load blueprint from asset (从资产加载蓝图) */ + loadBlueprint: (asset: BlueprintAsset, filePath?: string) => void; + + /** Add a node (添加节点) */ + addNode: (node: BlueprintNode) => void; + + /** Remove a node (移除节点) */ + removeNode: (nodeId: string) => void; + + /** Update node position (更新节点位置) */ + updateNodePosition: (nodeId: string, x: number, y: number) => void; + + /** Update node data (更新节点数据) */ + updateNodeData: (nodeId: string, data: Record) => void; + + /** Add connection (添加连接) */ + addConnection: (connection: BlueprintConnection) => void; + + /** Remove connection (移除连接) */ + removeConnection: (connectionId: string) => void; + + /** Select nodes (选择节点) */ + selectNodes: (nodeIds: string[]) => void; + + /** Clear selection (清除选择) */ + clearSelection: () => void; + + /** Set pan offset (设置平移偏移) */ + setPanOffset: (x: number, y: number) => void; + + /** Set zoom level (设置缩放级别) */ + setZoom: (zoom: number) => void; + + /** Mark as dirty (标记为已修改) */ + markDirty: () => void; + + /** Mark as clean (标记为未修改) */ + markClean: () => void; +} + +/** + * Generate unique ID for nodes and connections + * 为节点和连接生成唯一ID + */ +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * Blueprint editor store + * 蓝图编辑器状态存储 + */ +export const useBlueprintEditorStore = create((set, get) => ({ + blueprint: null, + selectedNodeIds: [], + draggingNodeId: null, + panOffset: { x: 0, y: 0 }, + zoom: 1, + isDirty: false, + filePath: null, + + createNewBlueprint: (name: string) => { + const blueprint = createEmptyBlueprint(name); + set({ + blueprint, + selectedNodeIds: [], + panOffset: { x: 0, y: 0 }, + zoom: 1, + isDirty: false, + filePath: null + }); + }, + + loadBlueprint: (asset: BlueprintAsset, filePath?: string) => { + set({ + blueprint: asset, + selectedNodeIds: [], + panOffset: { x: 0, y: 0 }, + zoom: 1, + isDirty: false, + filePath: filePath ?? null + }); + }, + + addNode: (node: BlueprintNode) => { + const { blueprint } = get(); + if (!blueprint) return; + + const newNode = { ...node, id: node.id || generateId() }; + set({ + blueprint: { + ...blueprint, + nodes: [...blueprint.nodes, newNode], + metadata: { ...blueprint.metadata, modifiedAt: Date.now() } + }, + isDirty: true + }); + }, + + removeNode: (nodeId: string) => { + const { blueprint } = get(); + if (!blueprint) return; + + set({ + blueprint: { + ...blueprint, + nodes: blueprint.nodes.filter(n => n.id !== nodeId), + connections: blueprint.connections.filter( + c => c.fromNodeId !== nodeId && c.toNodeId !== nodeId + ), + metadata: { ...blueprint.metadata, modifiedAt: Date.now() } + }, + selectedNodeIds: get().selectedNodeIds.filter(id => id !== nodeId), + isDirty: true + }); + }, + + updateNodePosition: (nodeId: string, x: number, y: number) => { + const { blueprint } = get(); + if (!blueprint) return; + + set({ + blueprint: { + ...blueprint, + nodes: blueprint.nodes.map(n => + n.id === nodeId ? { ...n, position: { x, y } } : n + ), + metadata: { ...blueprint.metadata, modifiedAt: Date.now() } + }, + isDirty: true + }); + }, + + updateNodeData: (nodeId: string, data: Record) => { + const { blueprint } = get(); + if (!blueprint) return; + + set({ + blueprint: { + ...blueprint, + nodes: blueprint.nodes.map(n => + n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n + ), + metadata: { ...blueprint.metadata, modifiedAt: Date.now() } + }, + isDirty: true + }); + }, + + addConnection: (connection: BlueprintConnection) => { + const { blueprint } = get(); + if (!blueprint) return; + + const newConnection = { ...connection, id: connection.id || generateId() }; + + // Check for existing connection to the same input pin + // 检查是否已存在到同一输入引脚的连接 + const existingIndex = blueprint.connections.findIndex( + c => c.toNodeId === connection.toNodeId && c.toPin === connection.toPin + ); + + const newConnections = [...blueprint.connections]; + if (existingIndex >= 0) { + // Replace existing connection (替换现有连接) + newConnections[existingIndex] = newConnection; + } else { + newConnections.push(newConnection); + } + + set({ + blueprint: { + ...blueprint, + connections: newConnections, + metadata: { ...blueprint.metadata, modifiedAt: Date.now() } + }, + isDirty: true + }); + }, + + removeConnection: (connectionId: string) => { + const { blueprint } = get(); + if (!blueprint) return; + + set({ + blueprint: { + ...blueprint, + connections: blueprint.connections.filter(c => c.id !== connectionId), + metadata: { ...blueprint.metadata, modifiedAt: Date.now() } + }, + isDirty: true + }); + }, + + selectNodes: (nodeIds: string[]) => { + set({ selectedNodeIds: nodeIds }); + }, + + clearSelection: () => { + set({ selectedNodeIds: [] }); + }, + + setPanOffset: (x: number, y: number) => { + set({ panOffset: { x, y } }); + }, + + setZoom: (zoom: number) => { + set({ zoom: Math.max(0.1, Math.min(2, zoom)) }); + }, + + markDirty: () => { + set({ isDirty: true }); + }, + + markClean: () => { + set({ isDirty: false }); + } +})); diff --git a/packages/blueprint/src/editor/stores/index.ts b/packages/blueprint/src/editor/stores/index.ts new file mode 100644 index 00000000..60c45d83 --- /dev/null +++ b/packages/blueprint/src/editor/stores/index.ts @@ -0,0 +1,6 @@ +/** + * Blueprint Editor Stores + * 蓝图编辑器状态存储 + */ + +export * from './blueprintEditorStore'; diff --git a/packages/blueprint/src/index.ts b/packages/blueprint/src/index.ts new file mode 100644 index 00000000..5ba686b4 --- /dev/null +++ b/packages/blueprint/src/index.ts @@ -0,0 +1,31 @@ +/** + * @esengine/blueprint - Visual scripting system for ECS Framework + * 蓝图可视化脚本系统 + */ + +// Types +export * from './types'; + +// Runtime +export * from './runtime'; + +// Nodes (import to register) +import './nodes'; + +// Re-export commonly used items +export { NodeRegistry, RegisterNode } from './runtime/NodeRegistry'; +export { BlueprintVM } from './runtime/BlueprintVM'; +export { + createBlueprintComponentData, + initializeBlueprintVM, + startBlueprint, + stopBlueprint, + tickBlueprint, + cleanupBlueprint +} from './runtime/BlueprintComponent'; +export { + createBlueprintSystem, + triggerBlueprintEvent, + triggerCustomBlueprintEvent +} from './runtime/BlueprintSystem'; +export { createEmptyBlueprint, validateBlueprintAsset } from './types/blueprint'; diff --git a/packages/blueprint/src/nodes/debug/Print.ts b/packages/blueprint/src/nodes/debug/Print.ts new file mode 100644 index 00000000..25bc2964 --- /dev/null +++ b/packages/blueprint/src/nodes/debug/Print.ts @@ -0,0 +1,91 @@ +/** + * Print Node - Outputs a message for debugging + * 打印节点 - 输出调试消息 + */ + +import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes'; +import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext'; +import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry'; + +/** + * Print node template + * Print 节点模板 + */ +export const PrintTemplate: BlueprintNodeTemplate = { + type: 'Print', + title: 'Print String', + category: 'debug', + color: '#785EF0', + description: 'Prints a message to the console for debugging (打印消息到控制台用于调试)', + keywords: ['log', 'debug', 'console', 'output', 'print'], + inputs: [ + { + name: 'exec', + type: 'exec', + displayName: '' + }, + { + name: 'message', + type: 'string', + displayName: 'Message', + defaultValue: 'Hello Blueprint!' + }, + { + name: 'printToScreen', + type: 'bool', + displayName: 'Print to Screen', + defaultValue: true + }, + { + name: 'duration', + type: 'float', + displayName: 'Duration', + defaultValue: 2.0 + } + ], + outputs: [ + { + name: 'exec', + type: 'exec', + displayName: '' + } + ] +}; + +/** + * Print node executor + * Print 节点执行器 + */ +@RegisterNode(PrintTemplate) +export class PrintExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const message = context.evaluateInput(node.id, 'message', 'Hello Blueprint!'); + const printToScreen = context.evaluateInput(node.id, 'printToScreen', true); + const duration = context.evaluateInput(node.id, 'duration', 2.0); + + // Console output + // 控制台输出 + console.log(`[Blueprint] ${message}`); + + // Screen output via event (handled by runtime) + // 通过事件输出到屏幕(由运行时处理) + if (printToScreen) { + const event = new CustomEvent('blueprint:print', { + detail: { + message: String(message), + duration: Number(duration), + entityId: context.entity.id, + entityName: context.entity.name + } + }); + + if (typeof window !== 'undefined') { + window.dispatchEvent(event); + } + } + + return { + nextExec: 'exec' + }; + } +} diff --git a/packages/blueprint/src/nodes/debug/index.ts b/packages/blueprint/src/nodes/debug/index.ts new file mode 100644 index 00000000..14e6cbf1 --- /dev/null +++ b/packages/blueprint/src/nodes/debug/index.ts @@ -0,0 +1,6 @@ +/** + * Debug Nodes - Tools for debugging blueprints + * 调试节点 - 蓝图调试工具 + */ + +export * from './Print'; diff --git a/packages/blueprint/src/nodes/events/EventBeginPlay.ts b/packages/blueprint/src/nodes/events/EventBeginPlay.ts new file mode 100644 index 00000000..5ccdc620 --- /dev/null +++ b/packages/blueprint/src/nodes/events/EventBeginPlay.ts @@ -0,0 +1,44 @@ +/** + * Event Begin Play Node - Triggered when the blueprint starts + * 开始播放事件节点 - 蓝图启动时触发 + */ + +import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes'; +import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext'; +import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry'; + +/** + * EventBeginPlay node template + * EventBeginPlay 节点模板 + */ +export const EventBeginPlayTemplate: BlueprintNodeTemplate = { + type: 'EventBeginPlay', + title: 'Event Begin Play', + category: 'event', + color: '#CC0000', + description: 'Triggered once when the blueprint starts executing (蓝图开始执行时触发一次)', + keywords: ['start', 'begin', 'init', 'event'], + inputs: [], + outputs: [ + { + name: 'exec', + type: 'exec', + displayName: '' + } + ] +}; + +/** + * EventBeginPlay node executor + * EventBeginPlay 节点执行器 + */ +@RegisterNode(EventBeginPlayTemplate) +export class EventBeginPlayExecutor implements INodeExecutor { + execute(_node: BlueprintNode, _context: ExecutionContext): ExecutionResult { + // Event nodes just trigger execution flow + // 事件节点只触发执行流 + return { + nextExec: 'exec' + }; + } +} diff --git a/packages/blueprint/src/nodes/events/EventEndPlay.ts b/packages/blueprint/src/nodes/events/EventEndPlay.ts new file mode 100644 index 00000000..ff9ed746 --- /dev/null +++ b/packages/blueprint/src/nodes/events/EventEndPlay.ts @@ -0,0 +1,42 @@ +/** + * Event End Play Node - Triggered when the blueprint stops + * 结束播放事件节点 - 蓝图停止时触发 + */ + +import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes'; +import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext'; +import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry'; + +/** + * EventEndPlay node template + * EventEndPlay 节点模板 + */ +export const EventEndPlayTemplate: BlueprintNodeTemplate = { + type: 'EventEndPlay', + title: 'Event End Play', + category: 'event', + color: '#CC0000', + description: 'Triggered once when the blueprint stops executing (蓝图停止执行时触发一次)', + keywords: ['stop', 'end', 'destroy', 'event'], + inputs: [], + outputs: [ + { + name: 'exec', + type: 'exec', + displayName: '' + } + ] +}; + +/** + * EventEndPlay node executor + * EventEndPlay 节点执行器 + */ +@RegisterNode(EventEndPlayTemplate) +export class EventEndPlayExecutor implements INodeExecutor { + execute(_node: BlueprintNode, _context: ExecutionContext): ExecutionResult { + return { + nextExec: 'exec' + }; + } +} diff --git a/packages/blueprint/src/nodes/events/EventTick.ts b/packages/blueprint/src/nodes/events/EventTick.ts new file mode 100644 index 00000000..5e4ce12c --- /dev/null +++ b/packages/blueprint/src/nodes/events/EventTick.ts @@ -0,0 +1,50 @@ +/** + * Event Tick Node - Triggered every frame + * 每帧事件节点 - 每帧触发 + */ + +import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes'; +import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext'; +import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry'; + +/** + * EventTick node template + * EventTick 节点模板 + */ +export const EventTickTemplate: BlueprintNodeTemplate = { + type: 'EventTick', + title: 'Event Tick', + category: 'event', + color: '#CC0000', + description: 'Triggered every frame during execution (执行期间每帧触发)', + keywords: ['update', 'frame', 'tick', 'event'], + inputs: [], + outputs: [ + { + name: 'exec', + type: 'exec', + displayName: '' + }, + { + name: 'deltaTime', + type: 'float', + displayName: 'Delta Seconds' + } + ] +}; + +/** + * EventTick node executor + * EventTick 节点执行器 + */ +@RegisterNode(EventTickTemplate) +export class EventTickExecutor implements INodeExecutor { + execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult { + return { + nextExec: 'exec', + outputs: { + deltaTime: context.deltaTime + } + }; + } +} diff --git a/packages/blueprint/src/nodes/events/index.ts b/packages/blueprint/src/nodes/events/index.ts new file mode 100644 index 00000000..b5a2d8f0 --- /dev/null +++ b/packages/blueprint/src/nodes/events/index.ts @@ -0,0 +1,8 @@ +/** + * Event Nodes - Entry points for blueprint execution + * 事件节点 - 蓝图执行的入口点 + */ + +export * from './EventBeginPlay'; +export * from './EventTick'; +export * from './EventEndPlay'; diff --git a/packages/blueprint/src/nodes/index.ts b/packages/blueprint/src/nodes/index.ts new file mode 100644 index 00000000..8644b851 --- /dev/null +++ b/packages/blueprint/src/nodes/index.ts @@ -0,0 +1,11 @@ +/** + * Blueprint Nodes - All node definitions and executors + * 蓝图节点 - 所有节点定义和执行器 + */ + +// Import all nodes to trigger registration +// 导入所有节点以触发注册 +export * from './events'; +export * from './debug'; +export * from './time'; +export * from './math'; diff --git a/packages/blueprint/src/nodes/math/MathOperations.ts b/packages/blueprint/src/nodes/math/MathOperations.ts new file mode 100644 index 00000000..97bcc494 --- /dev/null +++ b/packages/blueprint/src/nodes/math/MathOperations.ts @@ -0,0 +1,122 @@ +/** + * Math Operation Nodes - Basic arithmetic operations + * 数学运算节点 - 基础算术运算 + */ + +import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes'; +import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext'; +import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry'; + +// Add Node (加法节点) +export const AddTemplate: BlueprintNodeTemplate = { + type: 'Add', + title: 'Add', + category: 'math', + color: '#4CAF50', + description: 'Adds two numbers together (将两个数字相加)', + keywords: ['add', 'plus', 'sum', '+', 'math'], + isPure: true, + inputs: [ + { name: 'a', type: 'float', displayName: 'A', defaultValue: 0 }, + { name: 'b', type: 'float', displayName: 'B', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(AddTemplate) +export class AddExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Number(context.evaluateInput(node.id, 'a', 0)); + const b = Number(context.evaluateInput(node.id, 'b', 0)); + return { outputs: { result: a + b } }; + } +} + +// Subtract Node (减法节点) +export const SubtractTemplate: BlueprintNodeTemplate = { + type: 'Subtract', + title: 'Subtract', + category: 'math', + color: '#4CAF50', + description: 'Subtracts B from A (从 A 减去 B)', + keywords: ['subtract', 'minus', '-', 'math'], + isPure: true, + inputs: [ + { name: 'a', type: 'float', displayName: 'A', defaultValue: 0 }, + { name: 'b', type: 'float', displayName: 'B', defaultValue: 0 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(SubtractTemplate) +export class SubtractExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Number(context.evaluateInput(node.id, 'a', 0)); + const b = Number(context.evaluateInput(node.id, 'b', 0)); + return { outputs: { result: a - b } }; + } +} + +// Multiply Node (乘法节点) +export const MultiplyTemplate: BlueprintNodeTemplate = { + type: 'Multiply', + title: 'Multiply', + category: 'math', + color: '#4CAF50', + description: 'Multiplies two numbers (将两个数字相乘)', + keywords: ['multiply', 'times', '*', 'math'], + isPure: true, + inputs: [ + { name: 'a', type: 'float', displayName: 'A', defaultValue: 0 }, + { name: 'b', type: 'float', displayName: 'B', defaultValue: 1 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(MultiplyTemplate) +export class MultiplyExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Number(context.evaluateInput(node.id, 'a', 0)); + const b = Number(context.evaluateInput(node.id, 'b', 1)); + return { outputs: { result: a * b } }; + } +} + +// Divide Node (除法节点) +export const DivideTemplate: BlueprintNodeTemplate = { + type: 'Divide', + title: 'Divide', + category: 'math', + color: '#4CAF50', + description: 'Divides A by B (A 除以 B)', + keywords: ['divide', '/', 'math'], + isPure: true, + inputs: [ + { name: 'a', type: 'float', displayName: 'A', defaultValue: 0 }, + { name: 'b', type: 'float', displayName: 'B', defaultValue: 1 } + ], + outputs: [ + { name: 'result', type: 'float', displayName: 'Result' } + ] +}; + +@RegisterNode(DivideTemplate) +export class DivideExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const a = Number(context.evaluateInput(node.id, 'a', 0)); + const b = Number(context.evaluateInput(node.id, 'b', 1)); + + // Prevent division by zero (防止除零) + if (b === 0) { + return { outputs: { result: 0 } }; + } + + return { outputs: { result: a / b } }; + } +} diff --git a/packages/blueprint/src/nodes/math/index.ts b/packages/blueprint/src/nodes/math/index.ts new file mode 100644 index 00000000..b731e6d7 --- /dev/null +++ b/packages/blueprint/src/nodes/math/index.ts @@ -0,0 +1,6 @@ +/** + * Math Nodes - Mathematical operation nodes + * 数学节点 - 数学运算节点 + */ + +export * from './MathOperations'; diff --git a/packages/blueprint/src/nodes/time/Delay.ts b/packages/blueprint/src/nodes/time/Delay.ts new file mode 100644 index 00000000..ee27f504 --- /dev/null +++ b/packages/blueprint/src/nodes/time/Delay.ts @@ -0,0 +1,57 @@ +/** + * Delay Node - Pauses execution for a specified duration + * 延迟节点 - 暂停执行指定的时长 + */ + +import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes'; +import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext'; +import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry'; + +/** + * Delay node template + * Delay 节点模板 + */ +export const DelayTemplate: BlueprintNodeTemplate = { + type: 'Delay', + title: 'Delay', + category: 'flow', + color: '#FFFFFF', + description: 'Pauses execution for a specified number of seconds (暂停执行指定的秒数)', + keywords: ['wait', 'delay', 'pause', 'sleep', 'timer'], + inputs: [ + { + name: 'exec', + type: 'exec', + displayName: '' + }, + { + name: 'duration', + type: 'float', + displayName: 'Duration', + defaultValue: 1.0 + } + ], + outputs: [ + { + name: 'exec', + type: 'exec', + displayName: 'Completed' + } + ] +}; + +/** + * Delay node executor + * Delay 节点执行器 + */ +@RegisterNode(DelayTemplate) +export class DelayExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult { + const duration = context.evaluateInput(node.id, 'duration', 1.0) as number; + + return { + nextExec: 'exec', + delay: duration + }; + } +} diff --git a/packages/blueprint/src/nodes/time/GetDeltaTime.ts b/packages/blueprint/src/nodes/time/GetDeltaTime.ts new file mode 100644 index 00000000..0f5eef1d --- /dev/null +++ b/packages/blueprint/src/nodes/time/GetDeltaTime.ts @@ -0,0 +1,45 @@ +/** + * Get Delta Time Node - Returns the time since last frame + * 获取增量时间节点 - 返回上一帧以来的时间 + */ + +import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes'; +import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext'; +import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry'; + +/** + * GetDeltaTime node template + * GetDeltaTime 节点模板 + */ +export const GetDeltaTimeTemplate: BlueprintNodeTemplate = { + type: 'GetDeltaTime', + title: 'Get Delta Time', + category: 'time', + color: '#4FC3F7', + description: 'Returns the time elapsed since the last frame in seconds (返回上一帧以来经过的时间,单位秒)', + keywords: ['delta', 'time', 'frame', 'dt'], + isPure: true, + inputs: [], + outputs: [ + { + name: 'deltaTime', + type: 'float', + displayName: 'Delta Seconds' + } + ] +}; + +/** + * GetDeltaTime node executor + * GetDeltaTime 节点执行器 + */ +@RegisterNode(GetDeltaTimeTemplate) +export class GetDeltaTimeExecutor implements INodeExecutor { + execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult { + return { + outputs: { + deltaTime: context.deltaTime + } + }; + } +} diff --git a/packages/blueprint/src/nodes/time/GetTime.ts b/packages/blueprint/src/nodes/time/GetTime.ts new file mode 100644 index 00000000..b0402fe1 --- /dev/null +++ b/packages/blueprint/src/nodes/time/GetTime.ts @@ -0,0 +1,45 @@ +/** + * Get Time Node - Returns the total time since blueprint started + * 获取时间节点 - 返回蓝图启动以来的总时间 + */ + +import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes'; +import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext'; +import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry'; + +/** + * GetTime node template + * GetTime 节点模板 + */ +export const GetTimeTemplate: BlueprintNodeTemplate = { + type: 'GetTime', + title: 'Get Game Time', + category: 'time', + color: '#4FC3F7', + description: 'Returns the total time since the blueprint started in seconds (返回蓝图启动以来的总时间,单位秒)', + keywords: ['time', 'total', 'elapsed', 'game'], + isPure: true, + inputs: [], + outputs: [ + { + name: 'time', + type: 'float', + displayName: 'Seconds' + } + ] +}; + +/** + * GetTime node executor + * GetTime 节点执行器 + */ +@RegisterNode(GetTimeTemplate) +export class GetTimeExecutor implements INodeExecutor { + execute(_node: BlueprintNode, context: ExecutionContext): ExecutionResult { + return { + outputs: { + time: context.time + } + }; + } +} diff --git a/packages/blueprint/src/nodes/time/index.ts b/packages/blueprint/src/nodes/time/index.ts new file mode 100644 index 00000000..e02b7db2 --- /dev/null +++ b/packages/blueprint/src/nodes/time/index.ts @@ -0,0 +1,8 @@ +/** + * Time Nodes - Time-related utility nodes + * 时间节点 - 时间相关的工具节点 + */ + +export * from './GetDeltaTime'; +export * from './GetTime'; +export * from './Delay'; diff --git a/packages/blueprint/src/runtime/BlueprintComponent.ts b/packages/blueprint/src/runtime/BlueprintComponent.ts new file mode 100644 index 00000000..16ddd6f3 --- /dev/null +++ b/packages/blueprint/src/runtime/BlueprintComponent.ts @@ -0,0 +1,116 @@ +/** + * Blueprint Component - Attaches a blueprint to an entity + * 蓝图组件 - 将蓝图附加到实体 + */ + +import { BlueprintAsset } from '../types/blueprint'; +import { BlueprintVM } from './BlueprintVM'; +import { IEntity, IScene } from './ExecutionContext'; + +/** + * Component interface for ECS integration + * 用于 ECS 集成的组件接口 + */ +export interface IBlueprintComponent { + /** Entity ID this component belongs to (此组件所属的实体ID) */ + entityId: number | null; + + /** Blueprint asset reference (蓝图资产引用) */ + blueprintAsset: BlueprintAsset | null; + + /** Blueprint asset path for serialization (用于序列化的蓝图资产路径) */ + blueprintPath: string; + + /** Auto-start execution when entity is created (实体创建时自动开始执行) */ + autoStart: boolean; + + /** Enable debug mode for VM (启用 VM 调试模式) */ + debug: boolean; + + /** Runtime VM instance (运行时 VM 实例) */ + vm: BlueprintVM | null; + + /** Whether the blueprint has started (蓝图是否已启动) */ + isStarted: boolean; +} + +/** + * Creates a blueprint component data object + * 创建蓝图组件数据对象 + */ +export function createBlueprintComponentData(): IBlueprintComponent { + return { + entityId: null, + blueprintAsset: null, + blueprintPath: '', + autoStart: true, + debug: false, + vm: null, + isStarted: false + }; +} + +/** + * Initialize the VM for a blueprint component + * 为蓝图组件初始化 VM + */ +export function initializeBlueprintVM( + component: IBlueprintComponent, + entity: IEntity, + scene: IScene +): void { + if (!component.blueprintAsset) { + return; + } + + // Create VM instance + // 创建 VM 实例 + component.vm = new BlueprintVM(component.blueprintAsset, entity, scene); + component.vm.debug = component.debug; +} + +/** + * Start blueprint execution + * 开始蓝图执行 + */ +export function startBlueprint(component: IBlueprintComponent): void { + if (component.vm && !component.isStarted) { + component.vm.start(); + component.isStarted = true; + } +} + +/** + * Stop blueprint execution + * 停止蓝图执行 + */ +export function stopBlueprint(component: IBlueprintComponent): void { + if (component.vm && component.isStarted) { + component.vm.stop(); + component.isStarted = false; + } +} + +/** + * Update blueprint execution + * 更新蓝图执行 + */ +export function tickBlueprint(component: IBlueprintComponent, deltaTime: number): void { + if (component.vm && component.isStarted) { + component.vm.tick(deltaTime); + } +} + +/** + * Clean up blueprint resources + * 清理蓝图资源 + */ +export function cleanupBlueprint(component: IBlueprintComponent): void { + if (component.vm) { + if (component.isStarted) { + component.vm.stop(); + } + component.vm = null; + component.isStarted = false; + } +} diff --git a/packages/blueprint/src/runtime/BlueprintSystem.ts b/packages/blueprint/src/runtime/BlueprintSystem.ts new file mode 100644 index 00000000..03297a8e --- /dev/null +++ b/packages/blueprint/src/runtime/BlueprintSystem.ts @@ -0,0 +1,121 @@ +/** + * Blueprint Execution System - Manages blueprint lifecycle and execution + * 蓝图执行系统 - 管理蓝图生命周期和执行 + */ + +import { + IBlueprintComponent, + initializeBlueprintVM, + startBlueprint, + tickBlueprint, + cleanupBlueprint +} from './BlueprintComponent'; +import { IEntity, IScene } from './ExecutionContext'; + +/** + * Blueprint system interface for engine integration + * 用于引擎集成的蓝图系统接口 + */ +export interface IBlueprintSystem { + /** Process entities with blueprint components (处理带有蓝图组件的实体) */ + process(entities: IBlueprintEntity[], deltaTime: number): void; + + /** Called when entity is added to system (实体添加到系统时调用) */ + onEntityAdded(entity: IBlueprintEntity): void; + + /** Called when entity is removed from system (实体从系统移除时调用) */ + onEntityRemoved(entity: IBlueprintEntity): void; +} + +/** + * Entity with blueprint component + * 带有蓝图组件的实体 + */ +export interface IBlueprintEntity extends IEntity { + /** Blueprint component data (蓝图组件数据) */ + blueprintComponent: IBlueprintComponent; +} + +/** + * Creates a blueprint execution system + * 创建蓝图执行系统 + */ +export function createBlueprintSystem(scene: IScene): IBlueprintSystem { + return { + process(entities: IBlueprintEntity[], deltaTime: number): void { + for (const entity of entities) { + const component = entity.blueprintComponent; + + // Skip if no blueprint asset loaded + // 如果没有加载蓝图资产则跳过 + if (!component.blueprintAsset) { + continue; + } + + // Initialize VM if needed + // 如果需要则初始化 VM + if (!component.vm) { + initializeBlueprintVM(component, entity, scene); + } + + // Auto-start if enabled + // 如果启用则自动启动 + if (component.autoStart && !component.isStarted) { + startBlueprint(component); + } + + // Tick the blueprint + // 更新蓝图 + tickBlueprint(component, deltaTime); + } + }, + + onEntityAdded(entity: IBlueprintEntity): void { + const component = entity.blueprintComponent; + + if (component.blueprintAsset) { + initializeBlueprintVM(component, entity, scene); + + if (component.autoStart) { + startBlueprint(component); + } + } + }, + + onEntityRemoved(entity: IBlueprintEntity): void { + cleanupBlueprint(entity.blueprintComponent); + } + }; +} + +/** + * Utility to manually trigger blueprint events + * 手动触发蓝图事件的工具 + */ +export function triggerBlueprintEvent( + entity: IBlueprintEntity, + eventType: string, + data?: Record +): void { + const vm = entity.blueprintComponent.vm; + + if (vm && entity.blueprintComponent.isStarted) { + vm.triggerEvent(eventType, data); + } +} + +/** + * Utility to trigger custom events by name + * 按名称触发自定义事件的工具 + */ +export function triggerCustomBlueprintEvent( + entity: IBlueprintEntity, + eventName: string, + data?: Record +): void { + const vm = entity.blueprintComponent.vm; + + if (vm && entity.blueprintComponent.isStarted) { + vm.triggerCustomEvent(eventName, data); + } +} diff --git a/packages/blueprint/src/runtime/BlueprintVM.ts b/packages/blueprint/src/runtime/BlueprintVM.ts new file mode 100644 index 00000000..f09d66e4 --- /dev/null +++ b/packages/blueprint/src/runtime/BlueprintVM.ts @@ -0,0 +1,335 @@ +/** + * Blueprint Virtual Machine - Executes blueprint graphs + * 蓝图虚拟机 - 执行蓝图图 + */ + +import { BlueprintNode } from '../types/nodes'; +import { BlueprintAsset } from '../types/blueprint'; +import { ExecutionContext, ExecutionResult, IEntity, IScene } from './ExecutionContext'; +import { NodeRegistry } from './NodeRegistry'; + +/** + * Pending execution frame (for delayed/async execution) + * 待处理的执行帧(用于延迟/异步执行) + */ +interface PendingExecution { + nodeId: string; + execPin: string; + resumeTime: number; +} + +/** + * Event trigger types + * 事件触发类型 + */ +export type EventType = + | 'BeginPlay' + | 'Tick' + | 'EndPlay' + | 'Collision' + | 'TriggerEnter' + | 'TriggerExit' + | 'Custom'; + +/** + * Blueprint Virtual Machine + * 蓝图虚拟机 + */ +export class BlueprintVM { + /** Execution context (执行上下文) */ + private _context: ExecutionContext; + + /** Pending executions (delayed nodes) (待处理的执行) */ + private _pendingExecutions: PendingExecution[] = []; + + /** Event node cache by type (按类型缓存的事件节点) */ + private _eventNodes: Map = new Map(); + + /** Whether the VM is running (VM 是否运行中) */ + private _isRunning: boolean = false; + + /** Current execution time (当前执行时间) */ + private _currentTime: number = 0; + + /** Maximum execution steps per frame (每帧最大执行步骤) */ + private _maxStepsPerFrame: number = 1000; + + /** Debug mode (调试模式) */ + debug: boolean = false; + + constructor(blueprint: BlueprintAsset, entity: IEntity, scene: IScene) { + this._context = new ExecutionContext(blueprint, entity, scene); + this._cacheEventNodes(); + } + + get context(): ExecutionContext { + return this._context; + } + + get isRunning(): boolean { + return this._isRunning; + } + + /** + * Cache event nodes by type for quick lookup + * 按类型缓存事件节点以便快速查找 + */ + private _cacheEventNodes(): void { + for (const node of this._context.blueprint.nodes) { + // Event nodes start with "Event" + // 事件节点以 "Event" 开头 + if (node.type.startsWith('Event')) { + const eventType = node.type; + if (!this._eventNodes.has(eventType)) { + this._eventNodes.set(eventType, []); + } + this._eventNodes.get(eventType)!.push(node); + } + } + } + + /** + * Start the VM + * 启动 VM + */ + start(): void { + this._isRunning = true; + this._currentTime = 0; + + // Trigger BeginPlay event + // 触发 BeginPlay 事件 + this.triggerEvent('EventBeginPlay'); + } + + /** + * Stop the VM + * 停止 VM + */ + stop(): void { + // Trigger EndPlay event + // 触发 EndPlay 事件 + this.triggerEvent('EventEndPlay'); + + this._isRunning = false; + this._pendingExecutions = []; + } + + /** + * Pause the VM + * 暂停 VM + */ + pause(): void { + this._isRunning = false; + } + + /** + * Resume the VM + * 恢复 VM + */ + resume(): void { + this._isRunning = true; + } + + /** + * Update the VM (called every frame) + * 更新 VM(每帧调用) + */ + tick(deltaTime: number): void { + if (!this._isRunning) return; + + this._currentTime += deltaTime; + this._context.deltaTime = deltaTime; + this._context.time = this._currentTime; + + // Process pending delayed executions + // 处理待处理的延迟执行 + this._processPendingExecutions(); + + // Trigger Tick event + // 触发 Tick 事件 + this.triggerEvent('EventTick'); + } + + /** + * Trigger an event by type + * 按类型触发事件 + */ + triggerEvent(eventType: string, data?: Record): void { + const eventNodes = this._eventNodes.get(eventType); + if (!eventNodes) return; + + for (const node of eventNodes) { + this._executeFromNode(node, 'exec', data); + } + } + + /** + * Trigger a custom event by name + * 按名称触发自定义事件 + */ + triggerCustomEvent(eventName: string, data?: Record): void { + const eventNodes = this._eventNodes.get('EventCustom'); + if (!eventNodes) return; + + for (const node of eventNodes) { + if (node.data.eventName === eventName) { + this._executeFromNode(node, 'exec', data); + } + } + } + + /** + * Execute from a starting node + * 从起始节点执行 + */ + private _executeFromNode( + startNode: BlueprintNode, + startPin: string, + eventData?: Record + ): void { + // Clear output cache for new execution + // 为新执行清除输出缓存 + this._context.clearOutputCache(); + + // Set event data as node outputs + // 设置事件数据为节点输出 + if (eventData) { + this._context.setOutputs(startNode.id, eventData); + } + + // Follow execution chain + // 跟随执行链 + let currentNodeId: string | null = startNode.id; + let currentPin: string = startPin; + let steps = 0; + + while (currentNodeId && steps < this._maxStepsPerFrame) { + steps++; + + // Get connected nodes from current exec pin + // 从当前执行引脚获取连接的节点 + const connections = this._context.getConnectionsFromPin(currentNodeId, currentPin); + + if (connections.length === 0) { + // No more connections, end execution + // 没有更多连接,结束执行 + break; + } + + // Execute connected node + // 执行连接的节点 + const nextConn = connections[0]; + const result = this._executeNode(nextConn.toNodeId); + + if (result.error) { + console.error(`Blueprint error in node ${nextConn.toNodeId}: ${result.error}`); + break; + } + + if (result.delay && result.delay > 0) { + // Schedule delayed execution + // 安排延迟执行 + this._pendingExecutions.push({ + nodeId: nextConn.toNodeId, + execPin: result.nextExec ?? 'exec', + resumeTime: this._currentTime + result.delay + }); + break; + } + + if (result.yield) { + // Yield execution until next frame + // 暂停执行直到下一帧 + break; + } + + if (result.nextExec === null) { + // Explicitly stop execution + // 显式停止执行 + break; + } + + // Continue to next node + // 继续到下一个节点 + currentNodeId = nextConn.toNodeId; + currentPin = result.nextExec ?? 'exec'; + } + + if (steps >= this._maxStepsPerFrame) { + console.warn('Blueprint execution exceeded maximum steps, possible infinite loop'); + } + } + + /** + * Execute a single node + * 执行单个节点 + */ + private _executeNode(nodeId: string): ExecutionResult { + const node = this._context.getNode(nodeId); + if (!node) { + return { error: `Node not found: ${nodeId}` }; + } + + const executor = NodeRegistry.instance.getExecutor(node.type); + if (!executor) { + return { error: `No executor for node type: ${node.type}` }; + } + + try { + if (this.debug) { + console.log(`[Blueprint] Executing: ${node.type} (${nodeId})`); + } + + const result = executor.execute(node, this._context); + + // Cache outputs + // 缓存输出 + if (result.outputs) { + this._context.setOutputs(nodeId, result.outputs); + } + + return result; + } catch (error) { + return { error: `Execution error: ${error}` }; + } + } + + /** + * Process pending delayed executions + * 处理待处理的延迟执行 + */ + private _processPendingExecutions(): void { + const stillPending: PendingExecution[] = []; + + for (const pending of this._pendingExecutions) { + if (this._currentTime >= pending.resumeTime) { + // Resume execution + // 恢复执行 + const node = this._context.getNode(pending.nodeId); + if (node) { + this._executeFromNode(node, pending.execPin); + } + } else { + stillPending.push(pending); + } + } + + this._pendingExecutions = stillPending; + } + + /** + * Get instance variables for serialization + * 获取实例变量用于序列化 + */ + getInstanceVariables(): Map { + return this._context.getInstanceVariables(); + } + + /** + * Set instance variables from serialization + * 从序列化设置实例变量 + */ + setInstanceVariables(variables: Map): void { + this._context.setInstanceVariables(variables); + } +} diff --git a/packages/blueprint/src/runtime/ExecutionContext.ts b/packages/blueprint/src/runtime/ExecutionContext.ts new file mode 100644 index 00000000..aeaaf97d --- /dev/null +++ b/packages/blueprint/src/runtime/ExecutionContext.ts @@ -0,0 +1,294 @@ +/** + * Execution Context - Runtime context for blueprint execution + * 执行上下文 - 蓝图执行的运行时上下文 + */ + +import { BlueprintNode, BlueprintConnection } from '../types/nodes'; +import { BlueprintAsset } from '../types/blueprint'; + +/** + * Result of node execution + * 节点执行的结果 + */ +export interface ExecutionResult { + /** + * Next exec pin to follow (null to stop, undefined to continue default) + * 下一个要执行的引脚(null 停止,undefined 继续默认) + */ + nextExec?: string | null; + + /** + * Output values by pin name + * 按引脚名称的输出值 + */ + outputs?: Record; + + /** + * Whether to yield execution (for async operations) + * 是否暂停执行(用于异步操作) + */ + yield?: boolean; + + /** + * Delay before continuing (in seconds) + * 继续前的延迟(秒) + */ + delay?: number; + + /** + * Error message if execution failed + * 执行失败时的错误消息 + */ + error?: string; +} + +/** + * Entity interface (minimal for decoupling) + * 实体接口(最小化以解耦) + */ +export interface IEntity { + id: number; + name: string; + active: boolean; + getComponent(type: new (...args: unknown[]) => T): T | null; + addComponent(component: T): T; + removeComponent(type: new (...args: unknown[]) => T): void; + hasComponent(type: new (...args: unknown[]) => T): boolean; +} + +/** + * Scene interface (minimal for decoupling) + * 场景接口(最小化以解耦) + */ +export interface IScene { + createEntity(name?: string): IEntity; + destroyEntity(entity: IEntity): void; + findEntityByName(name: string): IEntity | null; + findEntitiesByTag(tag: number): IEntity[]; +} + +/** + * Execution context provides access to runtime services + * 执行上下文提供对运行时服务的访问 + */ +export class ExecutionContext { + /** Current blueprint asset (当前蓝图资产) */ + readonly blueprint: BlueprintAsset; + + /** Owner entity (所有者实体) */ + readonly entity: IEntity; + + /** Current scene (当前场景) */ + readonly scene: IScene; + + /** Frame delta time (帧增量时间) */ + deltaTime: number = 0; + + /** Total time since start (开始以来的总时间) */ + time: number = 0; + + /** Instance variables (实例变量) */ + private _instanceVariables: Map = new Map(); + + /** Local variables (per-execution) (局部变量,每次执行) */ + private _localVariables: Map = new Map(); + + /** Global variables (shared) (全局变量,共享) */ + private static _globalVariables: Map = new Map(); + + /** Node output cache for current execution (当前执行的节点输出缓存) */ + private _outputCache: Map> = new Map(); + + /** Connection lookup by target (按目标的连接查找) */ + private _connectionsByTarget: Map = new Map(); + + /** Connection lookup by source (按源的连接查找) */ + private _connectionsBySource: Map = new Map(); + + constructor(blueprint: BlueprintAsset, entity: IEntity, scene: IScene) { + this.blueprint = blueprint; + this.entity = entity; + this.scene = scene; + + // Initialize instance variables with defaults + // 使用默认值初始化实例变量 + for (const variable of blueprint.variables) { + if (variable.scope === 'instance') { + this._instanceVariables.set(variable.name, variable.defaultValue); + } + } + + // Build connection lookup maps + // 构建连接查找映射 + this._buildConnectionMaps(); + } + + private _buildConnectionMaps(): void { + for (const conn of this.blueprint.connections) { + // By target + const targetKey = `${conn.toNodeId}.${conn.toPin}`; + if (!this._connectionsByTarget.has(targetKey)) { + this._connectionsByTarget.set(targetKey, []); + } + this._connectionsByTarget.get(targetKey)!.push(conn); + + // By source + const sourceKey = `${conn.fromNodeId}.${conn.fromPin}`; + if (!this._connectionsBySource.has(sourceKey)) { + this._connectionsBySource.set(sourceKey, []); + } + this._connectionsBySource.get(sourceKey)!.push(conn); + } + } + + /** + * Get a node by ID + * 通过ID获取节点 + */ + getNode(nodeId: string): BlueprintNode | undefined { + return this.blueprint.nodes.find(n => n.id === nodeId); + } + + /** + * Get connections to a target pin + * 获取到目标引脚的连接 + */ + getConnectionsToPin(nodeId: string, pinName: string): BlueprintConnection[] { + return this._connectionsByTarget.get(`${nodeId}.${pinName}`) ?? []; + } + + /** + * Get connections from a source pin + * 获取从源引脚的连接 + */ + getConnectionsFromPin(nodeId: string, pinName: string): BlueprintConnection[] { + return this._connectionsBySource.get(`${nodeId}.${pinName}`) ?? []; + } + + /** + * Evaluate an input pin value (follows connections or uses default) + * 计算输入引脚值(跟随连接或使用默认值) + */ + evaluateInput(nodeId: string, pinName: string, defaultValue?: unknown): unknown { + const connections = this.getConnectionsToPin(nodeId, pinName); + + if (connections.length === 0) { + // Use default from node data or provided default + // 使用节点数据的默认值或提供的默认值 + const node = this.getNode(nodeId); + return node?.data[pinName] ?? defaultValue; + } + + // Get value from connected output + // 从连接的输出获取值 + const conn = connections[0]; + const cachedOutputs = this._outputCache.get(conn.fromNodeId); + + if (cachedOutputs && conn.fromPin in cachedOutputs) { + return cachedOutputs[conn.fromPin]; + } + + // Need to execute the source node first (lazy evaluation) + // 需要先执行源节点(延迟求值) + return defaultValue; + } + + /** + * Set output values for a node (cached for current execution) + * 设置节点的输出值(为当前执行缓存) + */ + setOutputs(nodeId: string, outputs: Record): void { + this._outputCache.set(nodeId, outputs); + } + + /** + * Get cached outputs for a node + * 获取节点的缓存输出 + */ + getOutputs(nodeId: string): Record | undefined { + return this._outputCache.get(nodeId); + } + + /** + * Clear output cache (call at start of new execution) + * 清除输出缓存(在新执行开始时调用) + */ + clearOutputCache(): void { + this._outputCache.clear(); + this._localVariables.clear(); + } + + /** + * Get a variable value + * 获取变量值 + */ + getVariable(name: string): unknown { + // Check local first, then instance, then global + // 先检查局部,然后实例,然后全局 + if (this._localVariables.has(name)) { + return this._localVariables.get(name); + } + if (this._instanceVariables.has(name)) { + return this._instanceVariables.get(name); + } + if (ExecutionContext._globalVariables.has(name)) { + return ExecutionContext._globalVariables.get(name); + } + + // Return default from variable definition + // 返回变量定义的默认值 + const varDef = this.blueprint.variables.find(v => v.name === name); + return varDef?.defaultValue; + } + + /** + * Set a variable value + * 设置变量值 + */ + setVariable(name: string, value: unknown): void { + const varDef = this.blueprint.variables.find(v => v.name === name); + + if (!varDef) { + // Treat unknown variables as local + // 将未知变量视为局部变量 + this._localVariables.set(name, value); + return; + } + + switch (varDef.scope) { + case 'local': + this._localVariables.set(name, value); + break; + case 'instance': + this._instanceVariables.set(name, value); + break; + case 'global': + ExecutionContext._globalVariables.set(name, value); + break; + } + } + + /** + * Get all instance variables (for serialization) + * 获取所有实例变量(用于序列化) + */ + getInstanceVariables(): Map { + return new Map(this._instanceVariables); + } + + /** + * Set instance variables (for deserialization) + * 设置实例变量(用于反序列化) + */ + setInstanceVariables(variables: Map): void { + this._instanceVariables = new Map(variables); + } + + /** + * Clear global variables (for scene reset) + * 清除全局变量(用于场景重置) + */ + static clearGlobalVariables(): void { + ExecutionContext._globalVariables.clear(); + } +} diff --git a/packages/blueprint/src/runtime/NodeRegistry.ts b/packages/blueprint/src/runtime/NodeRegistry.ts new file mode 100644 index 00000000..f0d05e89 --- /dev/null +++ b/packages/blueprint/src/runtime/NodeRegistry.ts @@ -0,0 +1,151 @@ +/** + * Node Registry - Manages node templates and executors + * 节点注册表 - 管理节点模板和执行器 + */ + +import { BlueprintNodeTemplate, BlueprintNode } from '../types/nodes'; +import { ExecutionContext, ExecutionResult } from './ExecutionContext'; + +/** + * Node executor interface - implements the logic for a node type + * 节点执行器接口 - 实现节点类型的逻辑 + */ +export interface INodeExecutor { + /** + * Execute the node + * 执行节点 + * + * @param node - Node instance (节点实例) + * @param context - Execution context (执行上下文) + * @returns Execution result (执行结果) + */ + execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult; +} + +/** + * Node definition combines template with executor + * 节点定义组合模板和执行器 + */ +export interface NodeDefinition { + template: BlueprintNodeTemplate; + executor: INodeExecutor; +} + +/** + * Node Registry - singleton that holds all registered node types + * 节点注册表 - 持有所有注册节点类型的单例 + */ +export class NodeRegistry { + private static _instance: NodeRegistry; + private _nodes: Map = new Map(); + + private constructor() {} + + static get instance(): NodeRegistry { + if (!NodeRegistry._instance) { + NodeRegistry._instance = new NodeRegistry(); + } + return NodeRegistry._instance; + } + + /** + * Register a node type + * 注册节点类型 + */ + register(template: BlueprintNodeTemplate, executor: INodeExecutor): void { + if (this._nodes.has(template.type)) { + console.warn(`Node type "${template.type}" is already registered, overwriting`); + } + this._nodes.set(template.type, { template, executor }); + } + + /** + * Get a node definition by type + * 通过类型获取节点定义 + */ + get(type: string): NodeDefinition | undefined { + return this._nodes.get(type); + } + + /** + * Get node template by type + * 通过类型获取节点模板 + */ + getTemplate(type: string): BlueprintNodeTemplate | undefined { + return this._nodes.get(type)?.template; + } + + /** + * Get node executor by type + * 通过类型获取节点执行器 + */ + getExecutor(type: string): INodeExecutor | undefined { + return this._nodes.get(type)?.executor; + } + + /** + * Check if a node type is registered + * 检查节点类型是否已注册 + */ + has(type: string): boolean { + return this._nodes.has(type); + } + + /** + * Get all registered templates + * 获取所有注册的模板 + */ + getAllTemplates(): BlueprintNodeTemplate[] { + return Array.from(this._nodes.values()).map(d => d.template); + } + + /** + * Get templates by category + * 按类别获取模板 + */ + getTemplatesByCategory(category: string): BlueprintNodeTemplate[] { + return this.getAllTemplates().filter(t => t.category === category); + } + + /** + * Search templates by keyword + * 按关键词搜索模板 + */ + searchTemplates(keyword: string): BlueprintNodeTemplate[] { + const lower = keyword.toLowerCase(); + return this.getAllTemplates().filter(t => + t.title.toLowerCase().includes(lower) || + t.type.toLowerCase().includes(lower) || + t.keywords?.some(k => k.toLowerCase().includes(lower)) || + t.description?.toLowerCase().includes(lower) + ); + } + + /** + * Clear all registrations (for testing) + * 清除所有注册(用于测试) + */ + clear(): void { + this._nodes.clear(); + } +} + +/** + * Decorator for registering node executors + * 用于注册节点执行器的装饰器 + * + * @example + * ```typescript + * @RegisterNode(EventTickTemplate) + * class EventTickExecutor implements INodeExecutor { + * execute(node, context) { ... } + * } + * ``` + */ +export function RegisterNode(template: BlueprintNodeTemplate) { + return function INodeExecutor>(constructor: T) { + const executor = new constructor(); + NodeRegistry.instance.register(template, executor); + return constructor; + }; +} diff --git a/packages/blueprint/src/runtime/index.ts b/packages/blueprint/src/runtime/index.ts new file mode 100644 index 00000000..3cd594a6 --- /dev/null +++ b/packages/blueprint/src/runtime/index.ts @@ -0,0 +1,10 @@ +/** + * Blueprint Runtime - Execution engine for blueprints + * 蓝图运行时 - 蓝图执行引擎 + */ + +export * from './ExecutionContext'; +export * from './NodeRegistry'; +export * from './BlueprintVM'; +export * from './BlueprintComponent'; +export * from './BlueprintSystem'; diff --git a/packages/blueprint/src/types/blueprint.ts b/packages/blueprint/src/types/blueprint.ts new file mode 100644 index 00000000..de4497b2 --- /dev/null +++ b/packages/blueprint/src/types/blueprint.ts @@ -0,0 +1,125 @@ +/** + * Blueprint Asset Types + * 蓝图资产类型 + */ + +import { BlueprintNode, BlueprintConnection } from './nodes'; + +/** + * Variable scope determines lifetime and accessibility + * 变量作用域决定生命周期和可访问性 + */ +export type VariableScope = + | 'local' // Per-execution (每次执行) + | 'instance' // Per-entity (每个实体) + | 'global'; // Shared across all (全局共享) + +/** + * Blueprint variable definition + * 蓝图变量定义 + */ +export interface BlueprintVariable { + /** Variable name (变量名) */ + name: string; + + /** Variable type (变量类型) */ + type: string; + + /** Default value (默认值) */ + defaultValue: unknown; + + /** Variable scope (变量作用域) */ + scope: VariableScope; + + /** Category for organization (分类) */ + category?: string; + + /** Description tooltip (描述提示) */ + tooltip?: string; +} + +/** + * Blueprint asset metadata + * 蓝图资产元数据 + */ +export interface BlueprintMetadata { + /** Blueprint name (蓝图名称) */ + name: string; + + /** Description (描述) */ + description?: string; + + /** Category for organization (分类) */ + category?: string; + + /** Author (作者) */ + author?: string; + + /** Creation timestamp (创建时间戳) */ + createdAt?: number; + + /** Last modified timestamp (最后修改时间戳) */ + modifiedAt?: number; +} + +/** + * Blueprint asset format - saved to .bp files + * 蓝图资产格式 - 保存为 .bp 文件 + */ +export interface BlueprintAsset { + /** Format version (格式版本) */ + version: number; + + /** Asset type identifier (资产类型标识符) */ + type: 'blueprint'; + + /** Metadata (元数据) */ + metadata: BlueprintMetadata; + + /** Variable definitions (变量定义) */ + variables: BlueprintVariable[]; + + /** Node instances (节点实例) */ + nodes: BlueprintNode[]; + + /** Connections between nodes (节点之间的连接) */ + connections: BlueprintConnection[]; +} + +/** + * Creates an empty blueprint asset + * 创建空的蓝图资产 + */ +export function createEmptyBlueprint(name: string): BlueprintAsset { + return { + version: 1, + type: 'blueprint', + metadata: { + name, + createdAt: Date.now(), + modifiedAt: Date.now() + }, + variables: [], + nodes: [], + connections: [] + }; +} + +/** + * Validates a blueprint asset structure + * 验证蓝图资产结构 + */ +export function validateBlueprintAsset(asset: unknown): asset is BlueprintAsset { + if (!asset || typeof asset !== 'object') return false; + + const bp = asset as BlueprintAsset; + + return ( + typeof bp.version === 'number' && + bp.type === 'blueprint' && + typeof bp.metadata === 'object' && + Array.isArray(bp.variables) && + Array.isArray(bp.nodes) && + Array.isArray(bp.connections) + ); +} diff --git a/packages/blueprint/src/types/index.ts b/packages/blueprint/src/types/index.ts new file mode 100644 index 00000000..bf556945 --- /dev/null +++ b/packages/blueprint/src/types/index.ts @@ -0,0 +1,3 @@ +export * from './pins'; +export * from './nodes'; +export * from './blueprint'; diff --git a/packages/blueprint/src/types/nodes.ts b/packages/blueprint/src/types/nodes.ts new file mode 100644 index 00000000..8e441dd3 --- /dev/null +++ b/packages/blueprint/src/types/nodes.ts @@ -0,0 +1,138 @@ +/** + * Blueprint Node Types + * 蓝图节点类型 + */ + +import { BlueprintPinDefinition } from './pins'; + +/** + * Node category for visual styling and organization + * 节点类别,用于视觉样式和组织 + */ +export type BlueprintNodeCategory = + | 'event' // Event nodes - red (事件节点 - 红色) + | 'flow' // Flow control - gray (流程控制 - 灰色) + | 'entity' // Entity operations - blue (实体操作 - 蓝色) + | 'component' // Component access - cyan (组件访问 - 青色) + | 'math' // Math operations - green (数学运算 - 绿色) + | 'logic' // Logic operations - red (逻辑运算 - 红色) + | 'variable' // Variable access - purple (变量访问 - 紫色) + | 'input' // Input handling - orange (输入处理 - 橙色) + | 'physics' // Physics - yellow (物理 - 黄色) + | 'audio' // Audio - pink (音频 - 粉色) + | 'time' // Time utilities - cyan (时间工具 - 青色) + | 'debug' // Debug utilities - gray (调试工具 - 灰色) + | 'custom'; // Custom nodes (自定义节点) + +/** + * Node template definition - describes a type of node + * 节点模板定义 - 描述一种节点类型 + */ +export interface BlueprintNodeTemplate { + /** Unique type identifier (唯一类型标识符) */ + type: string; + + /** Display title (显示标题) */ + title: string; + + /** Node category (节点类别) */ + category: BlueprintNodeCategory; + + /** Optional subtitle (可选副标题) */ + subtitle?: string; + + /** Icon name (图标名称) */ + icon?: string; + + /** Description for documentation (文档描述) */ + description?: string; + + /** Search keywords (搜索关键词) */ + keywords?: string[]; + + /** Menu path for node palette (节点面板的菜单路径) */ + menuPath?: string[]; + + /** Input pin definitions (输入引脚定义) */ + inputs: BlueprintPinDefinition[]; + + /** Output pin definitions (输出引脚定义) */ + outputs: BlueprintPinDefinition[]; + + /** Whether this node is pure (no exec pins) (是否是纯节点,无执行引脚) */ + isPure?: boolean; + + /** Whether this node can be collapsed (是否可折叠) */ + collapsible?: boolean; + + /** Custom header color override (自定义头部颜色) */ + headerColor?: string; + + /** Node color for visual distinction (节点颜色用于视觉区分) */ + color?: string; +} + +/** + * Node instance in a blueprint graph + * 蓝图图中的节点实例 + */ +export interface BlueprintNode { + /** Unique instance ID (唯一实例ID) */ + id: string; + + /** Template type reference (模板类型引用) */ + type: string; + + /** Position in graph (图中位置) */ + position: { x: number; y: number }; + + /** Custom data for this instance (此实例的自定义数据) */ + data: Record; + + /** Comment/note for this node (此节点的注释) */ + comment?: string; +} + +/** + * Connection between two pins + * 两个引脚之间的连接 + */ +export interface BlueprintConnection { + /** Unique connection ID (唯一连接ID) */ + id: string; + + /** Source node ID (源节点ID) */ + fromNodeId: string; + + /** Source pin name (源引脚名称) */ + fromPin: string; + + /** Target node ID (目标节点ID) */ + toNodeId: string; + + /** Target pin name (目标引脚名称) */ + toPin: string; +} + +/** + * Gets the header color for a node category + * 获取节点类别的头部颜色 + */ +export function getNodeCategoryColor(category: BlueprintNodeCategory): string { + const colors: Record = { + event: '#8b1e1e', + flow: '#4a4a4a', + entity: '#1e5a8b', + component: '#1e8b8b', + math: '#1e8b5a', + logic: '#8b1e5a', + variable: '#5a1e8b', + input: '#8b5a1e', + physics: '#8b8b1e', + audio: '#8b1e6b', + time: '#1e6b8b', + debug: '#5a5a5a', + custom: '#4a4a4a' + }; + return colors[category] ?? colors.custom; +} diff --git a/packages/blueprint/src/types/pins.ts b/packages/blueprint/src/types/pins.ts new file mode 100644 index 00000000..beef1987 --- /dev/null +++ b/packages/blueprint/src/types/pins.ts @@ -0,0 +1,135 @@ +/** + * Blueprint Pin Types + * 蓝图引脚类型 + */ + +/** + * Pin data type for blueprint nodes + * 蓝图节点的引脚数据类型 + */ +export type BlueprintPinType = + | 'exec' // Execution flow (执行流) + | 'bool' // Boolean (布尔) + | 'int' // Integer (整数) + | 'float' // Float (浮点数) + | 'string' // String (字符串) + | 'vector2' // 2D Vector (二维向量) + | 'vector3' // 3D Vector (三维向量) + | 'color' // RGBA Color (颜色) + | 'entity' // Entity reference (实体引用) + | 'component' // Component reference (组件引用) + | 'object' // Generic object (通用对象) + | 'array' // Array (数组) + | 'any'; // Wildcard (通配符) + +/** + * Pin direction + * 引脚方向 + */ +export type BlueprintPinDirection = 'input' | 'output'; + +/** + * Pin definition for node templates + * 节点模板的引脚定义 + * + * Note: direction is determined by whether the pin is in inputs[] or outputs[] array + * 注意:方向由引脚在 inputs[] 还是 outputs[] 数组中决定 + */ +export interface BlueprintPinDefinition { + /** Unique name within node (节点内唯一名称) */ + name: string; + + /** Pin data type (引脚数据类型) */ + type: BlueprintPinType; + + /** Display name shown in the editor (编辑器中显示的名称) */ + displayName?: string; + + /** Default value when not connected (未连接时的默认值) */ + defaultValue?: unknown; + + /** Allow multiple connections (允许多个连接) */ + allowMultiple?: boolean; + + /** Array element type if type is 'array' (数组元素类型) */ + arrayType?: BlueprintPinType; + + /** Whether this pin is optional (是否可选) */ + optional?: boolean; + + /** Tooltip description (提示描述) */ + tooltip?: string; +} + +/** + * Runtime pin with direction - used when processing pins + * 带方向的运行时引脚 - 处理引脚时使用 + */ +export interface BlueprintRuntimePin extends BlueprintPinDefinition { + /** Pin direction (引脚方向) */ + direction: BlueprintPinDirection; +} + +/** + * Pin instance in a node + * 节点中的引脚实例 + */ +export interface BlueprintPin { + id: string; + nodeId: string; + definition: BlueprintPinDefinition; + value?: unknown; +} + +/** + * Gets the color for a pin type + * 获取引脚类型的颜色 + */ +export function getPinTypeColor(type: BlueprintPinType): string { + const colors: Record = { + exec: '#ffffff', + bool: '#cc0000', + int: '#00d4aa', + float: '#88cc00', + string: '#ff88cc', + vector2: '#d4aa00', + vector3: '#ffcc00', + color: '#ff8844', + entity: '#0088ff', + component: '#44aaff', + object: '#4444aa', + array: '#8844ff', + any: '#888888' + }; + return colors[type] ?? colors.any; +} + +/** + * Checks if two pin types are compatible for connection + * 检查两个引脚类型是否兼容连接 + */ +export function arePinTypesCompatible(from: BlueprintPinType, to: BlueprintPinType): boolean { + // Same type always compatible + // 相同类型始终兼容 + if (from === to) return true; + + // Any type is compatible with everything + // any 类型与所有类型兼容 + if (from === 'any' || to === 'any') return true; + + // Exec can only connect to exec + // exec 只能连接 exec + if (from === 'exec' || to === 'exec') return false; + + // Numeric coercion + // 数值类型转换 + const numericTypes: BlueprintPinType[] = ['int', 'float']; + if (numericTypes.includes(from) && numericTypes.includes(to)) return true; + + // Vector coercion + // 向量类型转换 + const vectorTypes: BlueprintPinType[] = ['vector2', 'vector3', 'color']; + if (vectorTypes.includes(from) && vectorTypes.includes(to)) return true; + + return false; +} diff --git a/packages/blueprint/tsconfig.json b/packages/blueprint/tsconfig.json new file mode 100644 index 00000000..9aa5ff0e --- /dev/null +++ b/packages/blueprint/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "composite": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/blueprint/vite.config.ts b/packages/blueprint/vite.config.ts new file mode 100644 index 00000000..5fb95845 --- /dev/null +++ b/packages/blueprint/vite.config.ts @@ -0,0 +1,106 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; +import dts from 'vite-plugin-dts'; +import react from '@vitejs/plugin-react'; + +/** + * 自定义插件:将 CSS 转换为自执行的样式注入代码 + * Custom plugin: Convert CSS to self-executing style injection code + */ +function escapeUnsafeChars(str: string): string { + const charMap: Record = { + '<': '\\u003C', + '>': '\\u003E', + '/': '\\u002F', + '\\': '\\\\', + '\u2028': '\\u2028', + '\u2029': '\\u2029' + }; + return str.replace(/[<>\\/\u2028\u2029]/g, (x) => charMap[x] || x); +} + +function injectCSSPlugin(): unknown { + const cssIdMap = new Map(); + let cssCounter = 0; + + return { + name: 'inject-css-plugin', + enforce: 'post' as const, + generateBundle(_options: unknown, bundle: Record) { + const bundleKeys = Object.keys(bundle); + + // 找到所有 CSS 文件 + const cssFiles = bundleKeys.filter(key => key.endsWith('.css')); + + for (const cssFile of cssFiles) { + const cssChunk = bundle[cssFile]; + if (!cssChunk || !cssChunk.source) continue; + + const cssContent = cssChunk.source; + const styleId = `esengine-blueprint-style-${cssCounter++}`; + cssIdMap.set(cssFile, styleId); + + // 生成样式注入代码 + const injectCode = `(function(){if(typeof document!=='undefined'){var s=document.createElement('style');s.id='${styleId}';if(!document.getElementById(s.id)){s.textContent=${escapeUnsafeChars(JSON.stringify(cssContent))};document.head.appendChild(s);}}})();`; + + // 注入到 editor/index.js 或共享 chunk + for (const jsKey of bundleKeys) { + if (!jsKey.endsWith('.js')) continue; + const jsChunk = bundle[jsKey]; + if (!jsChunk || jsChunk.type !== 'chunk' || !jsChunk.code) continue; + + if (jsKey === 'editor/index.js' || jsKey.match(/^index-[^/]+\.js$/)) { + jsChunk.code = injectCode + '\n' + jsChunk.code; + } + } + + // 删除独立的 CSS 文件 + delete bundle[cssFile]; + } + } + }; +} + +export default defineConfig({ + plugins: [ + react(), + dts({ + include: ['src'], + outDir: 'dist', + rollupTypes: false + }), + injectCSSPlugin() + ], + esbuild: { + jsx: 'automatic', + }, + build: { + lib: { + entry: { + index: resolve(__dirname, 'src/index.ts'), + 'editor/index': resolve(__dirname, 'src/editor/index.ts') + }, + formats: ['es'], + fileName: (_format, entryName) => `${entryName}.js` + }, + rollupOptions: { + external: [ + '@esengine/ecs-framework', + '@esengine/editor-runtime', + 'react', + 'react/jsx-runtime', + 'lucide-react', + 'zustand', + /^@esengine\//, + /^@tauri-apps\// + ], + output: { + exports: 'named', + preserveModules: false + } + }, + target: 'es2020', + minify: false, + sourcemap: true + } +}); diff --git a/packages/components/src/CorePlugin.ts b/packages/components/src/CorePlugin.ts index 3591b80d..f0c61bfc 100644 --- a/packages/components/src/CorePlugin.ts +++ b/packages/components/src/CorePlugin.ts @@ -60,6 +60,12 @@ export interface IRuntimeModuleLoader { registerComponents(registry: typeof ComponentRegistryType): void; registerServices?(services: ServiceContainer): void; createSystems?(scene: IScene, context: SystemContext): void; + /** + * 所有系统创建完成后调用 + * 用于处理跨插件的系统依赖关系 + * Called after all systems are created, used for cross-plugin system dependencies + */ + onSystemsCreated?(scene: IScene, context: SystemContext): void; onInitialize?(): Promise; onDestroy?(): void; } diff --git a/packages/core/src/ECS/Decorators/PropertyDecorator.ts b/packages/core/src/ECS/Decorators/PropertyDecorator.ts index 85437bd0..fa5b24a6 100644 --- a/packages/core/src/ECS/Decorators/PropertyDecorator.ts +++ b/packages/core/src/ECS/Decorators/PropertyDecorator.ts @@ -1,6 +1,6 @@ import 'reflect-metadata'; -export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'enum' | 'asset' | 'animationClips'; +export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'enum' | 'asset' | 'animationClips' | 'collisionLayer' | 'collisionMask'; /** * 资源类型 @@ -132,6 +132,22 @@ interface AnimationClipsPropertyOptions extends PropertyOptionsBase { type: 'animationClips'; } +/** + * 碰撞层属性选项 + * Collision layer property options + */ +interface CollisionLayerPropertyOptions extends PropertyOptionsBase { + type: 'collisionLayer'; +} + +/** + * 碰撞掩码属性选项 + * Collision mask property options + */ +interface CollisionMaskPropertyOptions extends PropertyOptionsBase { + type: 'collisionMask'; +} + /** * 属性选项联合类型 * Property options union type @@ -144,7 +160,9 @@ export type PropertyOptions = | VectorPropertyOptions | EnumPropertyOptions | AssetPropertyOptions - | AnimationClipsPropertyOptions; + | AnimationClipsPropertyOptions + | CollisionLayerPropertyOptions + | CollisionMaskPropertyOptions; // 使用 Symbol.for 创建全局 Symbol,确保跨包共享元数据 // Use Symbol.for to create a global Symbol to ensure metadata sharing across packages diff --git a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts index ab0f7fde..d6085b20 100644 --- a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts +++ b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts @@ -520,6 +520,58 @@ export class EngineBridge implements IEngineBridge { this.getEngine().addGizmoRect(x, y, width, height, rotation, originX, originY, r, g, b, a, showHandles); } + /** + * Add a circle outline gizmo (native rendering). + * 添加圆形边框Gizmo(原生渲染)。 + */ + addGizmoCircle( + x: number, + y: number, + radius: number, + r: number, + g: number, + b: number, + a: number + ): void { + if (!this.initialized) return; + this.getEngine().addGizmoCircle(x, y, radius, r, g, b, a); + } + + /** + * Add a line gizmo (native rendering). + * 添加线条Gizmo(原生渲染)。 + */ + addGizmoLine( + points: number[], + r: number, + g: number, + b: number, + a: number, + closed: boolean + ): void { + if (!this.initialized) return; + this.getEngine().addGizmoLine(new Float32Array(points), r, g, b, a, closed); + } + + /** + * Add a capsule outline gizmo (native rendering). + * 添加胶囊边框Gizmo(原生渲染)。 + */ + addGizmoCapsule( + x: number, + y: number, + radius: number, + halfHeight: number, + rotation: number, + r: number, + g: number, + b: number, + a: number + ): void { + if (!this.initialized) return; + this.getEngine().addGizmoCapsule(x, y, radius, halfHeight, rotation, r, g, b, a); + } + /** * Set transform tool mode. * 设置变换工具模式。 diff --git a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts index ec8ab5a7..cd0fd251 100644 --- a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts +++ b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts @@ -68,7 +68,7 @@ interface GizmoColorInternal { * @internal */ interface GizmoRenderDataInternal { - type: 'rect' | 'circle' | 'line' | 'grid'; + type: 'rect' | 'circle' | 'line' | 'grid' | 'capsule'; color: GizmoColorInternal; // Rect specific x?: number; @@ -87,6 +87,8 @@ interface GizmoRenderDataInternal { // Grid specific cols?: number; rows?: number; + // Capsule specific + halfHeight?: number; } /** @@ -568,8 +570,6 @@ export class EngineRenderSystem extends EntitySystem { break; case 'grid': - // Render grid as multiple line segments - // 将网格渲染为多条线段 if (data.x !== undefined && data.y !== undefined && data.width !== undefined && data.height !== undefined && data.cols !== undefined && data.rows !== undefined) { @@ -578,18 +578,32 @@ export class EngineRenderSystem extends EntitySystem { break; case 'line': - // Lines are rendered as connected rect segments (thin) - // 线条渲染为连接的细矩形段 if (data.points && data.points.length >= 2) { - this.renderLineGizmo(data.points, data.closed ?? false, r, g, b, a); + const flatPoints: number[] = []; + for (const p of data.points) { + flatPoints.push(p.x, p.y); + } + this.bridge.addGizmoLine(flatPoints, r, g, b, a, data.closed ?? false); } break; case 'circle': - // Circle rendered as polygon approximation - // 圆形渲染为多边形近似 if (data.x !== undefined && data.y !== undefined && data.radius !== undefined) { - this.renderCircleGizmo(data.x, data.y, data.radius, r, g, b, a); + this.bridge.addGizmoCircle(data.x, data.y, data.radius, r, g, b, a); + } + break; + + case 'capsule': + if (data.x !== undefined && data.y !== undefined && + data.radius !== undefined && data.halfHeight !== undefined) { + this.bridge.addGizmoCapsule( + data.x, + data.y, + data.radius, + data.halfHeight, + data.rotation ?? 0, + r, g, b, a + ); } break; } @@ -1041,9 +1055,9 @@ export class EngineRenderSystem extends EntitySystem { * 将十六进制颜色字符串转换为打包的RGBA。 */ private hexToPackedColor(hex: string, alpha: number): number { - // Parse hex color like "#ffffff" or "#fff" let r = 255, g = 255, b = 255; - if (hex.startsWith('#')) { + + if (typeof hex === 'string' && hex.startsWith('#')) { const hexValue = hex.slice(1); if (hexValue.length === 3) { r = parseInt(hexValue[0] + hexValue[0], 16); @@ -1055,6 +1069,7 @@ export class EngineRenderSystem extends EntitySystem { b = parseInt(hexValue.slice(4, 6), 16); } } + const a = Math.round(alpha * 255); // Pack as 0xAABBGGRR for WebGL return ((a & 0xFF) << 24) | ((b & 0xFF) << 16) | ((g & 0xFF) << 8) | (r & 0xFF); diff --git a/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts b/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts index 681ac314..f16ea536 100644 --- a/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts +++ b/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts @@ -69,6 +69,11 @@ export class GameEngine { * 设置网格可见性。 */ setShowGrid(show: boolean): void; + /** + * Add a line gizmo. + * 添加线条Gizmo。 + */ + addGizmoLine(points: Float32Array, r: number, g: number, b: number, a: number, closed: boolean): void; /** * Add a rectangle gizmo outline. * 添加矩形Gizmo边框。 @@ -111,11 +116,21 @@ export class GameEngine { * 设置辅助工具可见性。 */ setShowGizmos(show: boolean): void; + /** + * Add a circle gizmo outline. + * 添加圆形Gizmo边框。 + */ + addGizmoCircle(x: number, y: number, radius: number, r: number, g: number, b: number, a: number): void; /** * Get all registered viewport IDs. * 获取所有已注册的视口ID。 */ getViewportIds(): string[]; + /** + * Add a capsule gizmo outline. + * 添加胶囊Gizmo边框。 + */ + addGizmoCapsule(x: number, y: number, radius: number, half_height: number, rotation: number, r: number, g: number, b: number, a: number): void; /** * Register a new viewport. * 注册新视口。 @@ -252,6 +267,9 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl export interface InitOutput { readonly memory: WebAssembly.Memory; readonly __wbg_gameengine_free: (a: number, b: number) => void; + readonly gameengine_addGizmoCapsule: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number) => void; + readonly gameengine_addGizmoCircle: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void; + readonly gameengine_addGizmoLine: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void; readonly gameengine_addGizmoRect: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number) => void; readonly gameengine_clear: (a: number, b: number, c: number, d: number, e: number) => void; readonly gameengine_fromExternal: (a: any, b: number, c: number) => [number, number, number]; diff --git a/packages/editor-app/package.json b/packages/editor-app/package.json index a453ab32..245608ee 100644 --- a/packages/editor-app/package.json +++ b/packages/editor-app/package.json @@ -17,6 +17,7 @@ "dependencies": { "@esengine/asset-system": "workspace:*", "@esengine/behavior-tree": "workspace:*", + "@esengine/blueprint": "workspace:*", "@esengine/editor-runtime": "workspace:*", "@esengine/ecs-components": "workspace:*", "@esengine/physics-rapier2d": "workspace:*", diff --git a/packages/editor-app/src-tauri/tauri.conf.json b/packages/editor-app/src-tauri/tauri.conf.json index d5a56fde..e62099e2 100644 --- a/packages/editor-app/src-tauri/tauri.conf.json +++ b/packages/editor-app/src-tauri/tauri.conf.json @@ -58,7 +58,7 @@ "minHeight": 600, "resizable": true, "fullscreen": false, - "decorations": true, + "decorations": false, "transparent": false, "center": true, "skipTaskbar": false, @@ -84,6 +84,11 @@ "permissions": [ "core:default", "core:window:allow-start-dragging", + "core:window:allow-minimize", + "core:window:allow-maximize", + "core:window:allow-toggle-maximize", + "core:window:allow-close", + "core:window:allow-is-maximized", "shell:default", "dialog:default", "updater:default", diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index 460e0c80..06314753 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -44,8 +44,10 @@ import { ErrorDialog } from './components/ErrorDialog'; import { ConfirmDialog } from './components/ConfirmDialog'; import { PluginGeneratorWindow } from './components/PluginGeneratorWindow'; import { ToastProvider, useToast } from './components/Toast'; -import { MenuBar } from './components/MenuBar'; +import { TitleBar } from './components/TitleBar'; +import { MainToolbar } from './components/MainToolbar'; import { FlexLayoutDockContainer, FlexDockPanel } from './components/FlexLayoutDockContainer'; +import { StatusBar } from './components/StatusBar'; import { TauriAPI } from './api/tauri'; import { SettingsService } from './services/SettingsService'; import { PluginLoader } from './services/PluginLoader'; @@ -55,7 +57,7 @@ import { checkForUpdatesOnStartup } from './utils/updater'; import { useLocale } from './hooks/useLocale'; import { en, zh } from './locales'; import type { Locale } from '@esengine/editor-core'; -import { Loader2, Globe, ChevronDown } from 'lucide-react'; +import { Loader2 } from 'lucide-react'; import './styles/App.css'; const coreInstance = Core.create({ debug: true }); @@ -129,8 +131,6 @@ function App() { compilerId: string; currentFileName?: string; }>({ isOpen: false, compilerId: '' }); - const [showLocaleMemu, setShowLocaleMenu] = useState(false); - const localeMenuRef = useRef(null); useEffect(() => { // 禁用默认右键菜单 @@ -145,17 +145,6 @@ function App() { }; }, []); - // 语言菜单点击外部关闭 - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (localeMenuRef.current && !localeMenuRef.current.contains(e.target as Node)) { - setShowLocaleMenu(false); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - // 快捷键监听 useEffect(() => { const handleKeyDown = async (e: KeyboardEvent) => { @@ -727,12 +716,6 @@ function App() { title: locale === 'zh' ? '检视器' : 'Inspector', content: , closable: false - }, - { - id: 'console', - title: locale === 'zh' ? '控制台' : 'Console', - content: , - closable: false } ]; } else { @@ -754,18 +737,6 @@ function App() { title: locale === 'zh' ? '检视器' : 'Inspector', content: , closable: false - }, - { - id: 'assets', - title: locale === 'zh' ? '资产' : 'Assets', - content: , - closable: false - }, - { - id: 'console', - title: locale === 'zh' ? '控制台' : 'Console', - content: , - closable: false } ]; } @@ -899,12 +870,8 @@ function App() {
{!isEditorFullscreen && ( <> -
- {projectName} - ESEngine Editor -
-
- -
-
- - {showLocaleMemu && ( -
- - -
- )} -
- {status} -
-
+ )} @@ -986,6 +924,7 @@ function App() { { logger.info('Panel closed:', panelId); setActiveDynamicPanels((prev) => prev.filter((id) => id !== panelId)); @@ -993,11 +932,15 @@ function App() { />
-
- {t('footer.plugins')}: {pluginManager?.getAllPlugins().length ?? 0} - {t('footer.entities')}: {entityStore?.getAllEntities().length ?? 0} - {t('footer.core')}: {t('footer.active')} -
+ {showProfiler && ( diff --git a/packages/editor-app/src/app/managers/PluginInstaller.ts b/packages/editor-app/src/app/managers/PluginInstaller.ts index e5d68329..df8ff967 100644 --- a/packages/editor-app/src/app/managers/PluginInstaller.ts +++ b/packages/editor-app/src/app/managers/PluginInstaller.ts @@ -18,6 +18,7 @@ import { TilemapPlugin } from '@esengine/tilemap'; import { UIPlugin } from '@esengine/ui'; import { BehaviorTreePlugin } from '@esengine/behavior-tree'; import { Physics2DPlugin } from '@esengine/physics-rapier2d'; +import { BlueprintPlugin } from '@esengine/blueprint/editor'; export class PluginInstaller { /** @@ -52,6 +53,7 @@ export class PluginInstaller { { name: 'UIPlugin', plugin: UIPlugin }, { name: 'BehaviorTreePlugin', plugin: BehaviorTreePlugin }, { name: 'Physics2DPlugin', plugin: Physics2DPlugin }, + { name: 'BlueprintPlugin', plugin: BlueprintPlugin }, ]; for (const { name, plugin } of modulePlugins) { diff --git a/packages/editor-app/src/app/managers/ServiceRegistry.ts b/packages/editor-app/src/app/managers/ServiceRegistry.ts index d59f4656..19240107 100644 --- a/packages/editor-app/src/app/managers/ServiceRegistry.ts +++ b/packages/editor-app/src/app/managers/ServiceRegistry.ts @@ -66,6 +66,7 @@ import { ColorFieldEditor, AnimationClipsFieldEditor } from '../../infrastructure/field-editors'; +import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector'; export interface EditorServices { uiRegistry: UIRegistry; @@ -200,6 +201,10 @@ export class ServiceRegistry { fieldEditorRegistry.register(new ColorFieldEditor()); fieldEditorRegistry.register(new AnimationClipsFieldEditor()); + // 注册组件检查器 + // Register component inspectors + componentInspectorRegistry.register(new TransformComponentInspector()); + // 注册默认场景模板 - 创建默认相机 // Register default scene template - creates default camera this.registerDefaultSceneTemplate(); diff --git a/packages/editor-app/src/components/AssetBrowser.tsx b/packages/editor-app/src/components/AssetBrowser.tsx index ef8729cd..6cd65160 100644 --- a/packages/editor-app/src/components/AssetBrowser.tsx +++ b/packages/editor-app/src/components/AssetBrowser.tsx @@ -1,968 +1,22 @@ -import { useState, useEffect, useRef } from 'react'; -import * as LucideIcons from 'lucide-react'; -import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List, ChevronsUp, RefreshCw, Plus } from 'lucide-react'; -import { Core } from '@esengine/ecs-framework'; -import { MessageHub, FileActionRegistry } from '@esengine/editor-core'; -import { TauriAPI, DirectoryEntry } from '../api/tauri'; -import { FileTree, FileTreeHandle } from './FileTree'; -import { ResizablePanel } from './ResizablePanel'; -import { ContextMenu, ContextMenuItem } from './ContextMenu'; -import '../styles/AssetBrowser.css'; - /** - * 根据图标名称获取 Lucide 图标组件 + * Asset Browser - 资产浏览器 + * 包装 ContentBrowser 组件,保持向后兼容 */ -function getIconComponent(iconName: string | undefined, size: number = 16): React.ReactNode { - if (!iconName) return ; - const icons = LucideIcons as unknown as Record>; - const IconComponent = icons[iconName]; - if (IconComponent) { - return ; - } - return ; -} -interface AssetItem { - name: string; - path: string; - type: 'file' | 'folder'; - extension?: string; - size?: number; - modified?: number; -} +import { ContentBrowser } from './ContentBrowser'; interface AssetBrowserProps { - projectPath: string | null; - locale: string; - onOpenScene?: (scenePath: string) => void; + projectPath: string | null; + locale: string; + onOpenScene?: (scenePath: string) => void; } export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserProps) { - const messageHub = Core.services.resolve(MessageHub); - const fileActionRegistry = Core.services.resolve(FileActionRegistry); - const detailViewFileTreeRef = useRef(null); - const treeOnlyViewFileTreeRef = useRef(null); - const [currentPath, setCurrentPath] = useState(null); - const [selectedPaths, setSelectedPaths] = useState>(new Set()); - const [lastSelectedPath, setLastSelectedPath] = useState(null); - const [assets, setAssets] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [loading, setLoading] = useState(false); - const [showDetailView, setShowDetailView] = useState(() => { - const saved = localStorage.getItem('asset-browser-detail-view'); - return saved !== null ? saved === 'true' : false; - }); - const [contextMenu, setContextMenu] = useState<{ - position: { x: number; y: number }; - asset: AssetItem; - } | null>(null); - const [renameDialog, setRenameDialog] = useState<{ - asset: AssetItem; - newName: string; - } | null>(null); - const [deleteConfirmDialog, setDeleteConfirmDialog] = useState(null); - - const translations = { - en: { - title: 'Content Browser', - noProject: 'No project loaded', - loading: 'Loading...', - empty: 'No assets found', - search: 'Search...', - name: 'Name', - type: 'Type', - file: 'File', - folder: 'Folder' - }, - zh: { - title: '内容浏览器', - noProject: '没有加载项目', - loading: '加载中...', - empty: '没有找到资产', - search: '搜索...', - name: '名称', - type: '类型', - file: '文件', - folder: '文件夹' - } - }; - - const t = translations[locale as keyof typeof translations] || translations.en; - - useEffect(() => { - if (projectPath) { - setCurrentPath(projectPath); - loadAssets(projectPath); - } else { - setAssets([]); - setCurrentPath(null); - setSelectedPaths(new Set()); - } - }, [projectPath]); - - // Listen for asset reveal requests - useEffect(() => { - const messageHub = Core.services.resolve(MessageHub); - if (!messageHub) return; - - const unsubscribe = messageHub.subscribe('asset:reveal', async (data: any) => { - const filePath = data.path; - if (!filePath || !projectPath) return; - - // Convert relative path to absolute path if needed - let absoluteFilePath = filePath; - if (!filePath.includes(':') && !filePath.startsWith('/')) { - absoluteFilePath = `${projectPath}/${filePath}`.replace(/\\/g, '/'); - } - - const lastSlashIndex = Math.max(absoluteFilePath.lastIndexOf('/'), absoluteFilePath.lastIndexOf('\\')); - const dirPath = lastSlashIndex > 0 ? absoluteFilePath.substring(0, lastSlashIndex) : null; - - if (dirPath) { - try { - const dirExists = await TauriAPI.pathExists(dirPath); - if (!dirExists) return; - - setCurrentPath(dirPath); - await loadAssets(dirPath); - setSelectedPaths(new Set([absoluteFilePath])); - - // Expand tree to reveal the file - if (showDetailView) { - detailViewFileTreeRef.current?.revealPath(absoluteFilePath); - } else { - treeOnlyViewFileTreeRef.current?.revealPath(absoluteFilePath); - } - } catch (error) { - console.error(`[AssetBrowser] Failed to reveal asset: ${absoluteFilePath}`, error); - } - } - }); - - return () => unsubscribe(); - }, [showDetailView, projectPath]); - - const loadAssets = async (path: string) => { - setLoading(true); - try { - const entries = await TauriAPI.listDirectory(path); - - const assetItems: AssetItem[] = entries.map((entry: DirectoryEntry) => { - const extension = entry.is_dir ? undefined : - (entry.name.includes('.') ? entry.name.split('.').pop() : undefined); - - return { - name: entry.name, - path: entry.path, - type: entry.is_dir ? 'folder' as const : 'file' as const, - extension, - size: entry.size, - modified: entry.modified - }; - }); - - setAssets(assetItems.sort((a, b) => { - if (a.type === b.type) return a.name.localeCompare(b.name); - return a.type === 'folder' ? -1 : 1; - })); - } catch (error) { - console.error('Failed to load assets:', error); - setAssets([]); - } finally { - setLoading(false); - } - }; - - const searchProjectRecursively = async (rootPath: string, query: string): Promise => { - const results: AssetItem[] = []; - const lowerQuery = query.toLowerCase(); - - const searchDirectory = async (dirPath: string) => { - try { - const entries = await TauriAPI.listDirectory(dirPath); - - for (const entry of entries) { - if (entry.name.startsWith('.')) continue; - - if (entry.name.toLowerCase().includes(lowerQuery)) { - const extension = entry.is_dir ? undefined : - (entry.name.includes('.') ? entry.name.split('.').pop() : undefined); - - results.push({ - name: entry.name, - path: entry.path, - type: entry.is_dir ? 'folder' as const : 'file' as const, - extension, - size: entry.size, - modified: entry.modified - }); - } - - if (entry.is_dir) { - await searchDirectory(entry.path); - } - } - } catch (error) { - console.error(`Failed to search directory ${dirPath}:`, error); - } - }; - - await searchDirectory(rootPath); - return results.sort((a, b) => { - if (a.type === b.type) return a.name.localeCompare(b.name); - return a.type === 'folder' ? -1 : 1; - }); - }; - - useEffect(() => { - const performSearch = async () => { - if (!searchQuery.trim()) { - setSearchResults([]); - setIsSearching(false); - return; - } - - if (!projectPath) return; - - setIsSearching(true); - try { - const results = await searchProjectRecursively(projectPath, searchQuery); - setSearchResults(results); - } catch (error) { - console.error('Search failed:', error); - setSearchResults([]); - } finally { - setIsSearching(false); - } - }; - - const timeoutId = setTimeout(performSearch, 300); - return () => clearTimeout(timeoutId); - }, [searchQuery, projectPath]); - - const handleFolderSelect = (path: string) => { - setCurrentPath(path); - loadAssets(path); - }; - - const handleTreeMultiSelect = (paths: string[], modifiers: { ctrlKey: boolean; shiftKey: boolean }) => { - if (paths.length === 0) return; - const path = paths[0]; - if (!path) return; - - if (modifiers.shiftKey && paths.length > 1) { - // Range select - paths already contains the range from FileTree - setSelectedPaths(new Set(paths)); - } else if (modifiers.ctrlKey) { - const newSelected = new Set(selectedPaths); - if (newSelected.has(path)) { - newSelected.delete(path); - } else { - newSelected.add(path); - } - setSelectedPaths(newSelected); - setLastSelectedPath(path); - } else { - setSelectedPaths(new Set([path])); - setLastSelectedPath(path); - } - }; - - const handleAssetClick = (asset: AssetItem, e: React.MouseEvent) => { - const filteredAssets = searchQuery.trim() ? searchResults : assets; - - if (e.shiftKey && lastSelectedPath) { - // Range select with Shift - const lastIndex = filteredAssets.findIndex((a) => a.path === lastSelectedPath); - const currentIndex = filteredAssets.findIndex((a) => a.path === asset.path); - if (lastIndex !== -1 && currentIndex !== -1) { - const start = Math.min(lastIndex, currentIndex); - const end = Math.max(lastIndex, currentIndex); - const rangePaths = filteredAssets.slice(start, end + 1).map((a) => a.path); - setSelectedPaths(new Set(rangePaths)); - } - } else if (e.ctrlKey || e.metaKey) { - // Multi-select with Ctrl/Cmd - const newSelected = new Set(selectedPaths); - if (newSelected.has(asset.path)) { - newSelected.delete(asset.path); - } else { - newSelected.add(asset.path); - } - setSelectedPaths(newSelected); - setLastSelectedPath(asset.path); - } else { - // Single select - setSelectedPaths(new Set([asset.path])); - setLastSelectedPath(asset.path); - } - - messageHub?.publish('asset-file:selected', { - fileInfo: { - name: asset.name, - path: asset.path, - extension: asset.extension, - size: asset.size, - modified: asset.modified, - isDirectory: asset.type === 'folder' - } - }); - }; - - const handleAssetDoubleClick = async (asset: AssetItem) => { - if (asset.type === 'folder') { - setCurrentPath(asset.path); - loadAssets(asset.path); - } else if (asset.type === 'file') { - const ext = asset.extension?.toLowerCase(); - if (ext === 'ecs' && onOpenScene) { - onOpenScene(asset.path); - return; - } - - if (fileActionRegistry) { - const handled = await fileActionRegistry.handleDoubleClick(asset.path); - if (handled) { - return; - } - } - - try { - await TauriAPI.openFileWithSystemApp(asset.path); - } catch (error) { - console.error('Failed to open file:', error); - } - } - }; - - const handleRename = async (asset: AssetItem, newName: string) => { - if (!newName.trim() || newName === asset.name) { - setRenameDialog(null); - return; - } - - try { - const lastSlash = Math.max(asset.path.lastIndexOf('/'), asset.path.lastIndexOf('\\')); - const parentPath = asset.path.substring(0, lastSlash); - const newPath = `${parentPath}/${newName}`; - - await TauriAPI.renameFileOrFolder(asset.path, newPath); - - // 刷新当前目录 - if (currentPath) { - await loadAssets(currentPath); - } - - // 更新选中路径 - if (selectedPaths.has(asset.path)) { - const newSelected = new Set(selectedPaths); - newSelected.delete(asset.path); - newSelected.add(newPath); - setSelectedPaths(newSelected); - } - - setRenameDialog(null); - } catch (error) { - console.error('Failed to rename:', error); - alert(`重命名失败: ${error}`); - } - }; - - const handleDelete = async (asset: AssetItem) => { - try { - if (asset.type === 'folder') { - await TauriAPI.deleteFolder(asset.path); - } else { - await TauriAPI.deleteFile(asset.path); - } - - // 刷新当前目录 - if (currentPath) { - await loadAssets(currentPath); - } - - // 清除选中状态 - if (selectedPaths.has(asset.path)) { - const newSelected = new Set(selectedPaths); - newSelected.delete(asset.path); - setSelectedPaths(newSelected); - } - - setDeleteConfirmDialog(null); - } catch (error) { - console.error('Failed to delete:', error); - alert(`删除失败: ${error}`); - } - }; - - const handleContextMenu = (e: React.MouseEvent, asset: AssetItem) => { - e.preventDefault(); - setContextMenu({ - position: { x: e.clientX, y: e.clientY }, - asset - }); - }; - - const getContextMenuItems = (asset: AssetItem): ContextMenuItem[] => { - const items: ContextMenuItem[] = []; - - if (asset.type === 'file') { - items.push({ - label: locale === 'zh' ? '打开' : 'Open', - icon: , - onClick: () => handleAssetDoubleClick(asset) - }); - - if (fileActionRegistry) { - const handlers = fileActionRegistry.getHandlersForFile(asset.path); - for (const handler of handlers) { - if (handler.getContextMenuItems) { - const parentPath = asset.path.substring(0, asset.path.lastIndexOf('/')); - const pluginItems = handler.getContextMenuItems(asset.path, parentPath); - for (const pluginItem of pluginItems) { - items.push({ - label: pluginItem.label, - icon: pluginItem.icon, - onClick: () => pluginItem.onClick(asset.path, parentPath), - disabled: pluginItem.disabled, - separator: pluginItem.separator - }); - } - } - } - } - - items.push({ label: '', separator: true, onClick: () => {} }); - } - - if (asset.type === 'folder' && fileActionRegistry) { - const templates = fileActionRegistry.getCreationTemplates(); - if (templates.length > 0) { - items.push({ label: '', separator: true, onClick: () => {} }); - for (const template of templates) { - items.push({ - label: `${locale === 'zh' ? '新建' : 'New'} ${template.label}`, - icon: getIconComponent(template.icon, 16), - onClick: async () => { - const fileName = `new_${template.id}.${template.extension}`; - const filePath = `${asset.path}/${fileName}`; - await template.create(filePath); - if (currentPath) { - await loadAssets(currentPath); - } - } - }); - } - } - } - - // 在文件管理器中显示 - items.push({ - label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer', - icon: , - onClick: async () => { - try { - await TauriAPI.showInFolder(asset.path); - } catch (error) { - console.error('Failed to show in folder:', error); - } - } - }); - - items.push({ label: '', separator: true, onClick: () => {} }); - - // 复制路径 - items.push({ - label: locale === 'zh' ? '复制路径' : 'Copy Path', - icon: , - onClick: () => { - navigator.clipboard.writeText(asset.path); - } - }); - - items.push({ label: '', separator: true, onClick: () => {} }); - - // 重命名 - items.push({ - label: locale === 'zh' ? '重命名' : 'Rename', - icon: , - onClick: () => { - setRenameDialog({ - asset, - newName: asset.name - }); - setContextMenu(null); - }, - disabled: false - }); - - // 删除 - items.push({ - label: locale === 'zh' ? '删除' : 'Delete', - icon: , - onClick: () => { - setDeleteConfirmDialog(asset); - setContextMenu(null); - }, - disabled: false - }); - - return items; - }; - - const getBreadcrumbs = () => { - if (!currentPath || !projectPath) return []; - - const relative = currentPath.replace(projectPath, ''); - const parts = relative.split(/[/\\]/).filter((p) => p); - - const crumbs = [{ name: 'Content', path: projectPath }]; - let accPath = projectPath; - - for (const part of parts) { - accPath = `${accPath}${accPath.endsWith('\\') || accPath.endsWith('/') ? '' : '/'}${part}`; - crumbs.push({ name: part, path: accPath }); - } - - return crumbs; - }; - - const filteredAssets = searchQuery.trim() ? searchResults : assets; - - const getRelativePath = (fullPath: string): string => { - if (!projectPath) return fullPath; - const relativePath = fullPath.replace(projectPath, '').replace(/^[/\\]/, ''); - const parts = relativePath.split(/[/\\]/); - return parts.slice(0, -1).join('/'); - }; - - const getFileIcon = (asset: AssetItem) => { - if (asset.type === 'folder') { - // 检查是否为框架专用文件夹 - const folderName = asset.name.toLowerCase(); - if (folderName === 'plugins' || folderName === '.ecs') { - return ; - } - return ; - } - - const ext = asset.extension?.toLowerCase(); - switch (ext) { - case 'ecs': - return ; - case 'btree': - return ; - case 'ts': - case 'tsx': - case 'js': - case 'jsx': - return ; - case 'json': - return ; - case 'png': - case 'jpg': - case 'jpeg': - case 'gif': - return ; - default: - return ; - } - }; - - if (!projectPath) { - return ( -
-
-

{t.title}

-
-
-

{t.noProject}

-
-
- ); - } - - const breadcrumbs = getBreadcrumbs(); - return ( -
-
-
-
- - -
- - - setSearchQuery(e.target.value)} - style={{ - flex: 1, - padding: '6px 10px', - background: '#3c3c3c', - border: '1px solid #3e3e3e', - borderRadius: '3px', - color: '#cccccc', - fontSize: '12px', - outline: 'none' - }} - /> -
- {showDetailView ? ( - - -
- } - rightOrBottom={ -
-
- {breadcrumbs.map((crumb, index) => ( - - { - setCurrentPath(crumb.path); - loadAssets(crumb.path); - }} - > - {crumb.name} - - {index < breadcrumbs.length - 1 && / } - - ))} -
- {(loading || isSearching) ? ( -
-

{isSearching ? '搜索中...' : t.loading}

-
- ) : filteredAssets.length === 0 ? ( -
-

{searchQuery.trim() ? '未找到匹配的资产' : t.empty}

-
- ) : ( -
- {filteredAssets.map((asset, index) => { - const relativePath = getRelativePath(asset.path); - const showPath = searchQuery.trim() && relativePath; - return ( -
handleAssetClick(asset, e)} - onDoubleClick={() => handleAssetDoubleClick(asset)} - onContextMenu={(e) => handleContextMenu(e, asset)} - draggable={asset.type === 'file'} - onDragStart={(e) => { - if (asset.type === 'file') { - e.dataTransfer.effectAllowed = 'copy'; - - // Get all selected file assets - const selectedFiles = selectedPaths.has(asset.path) && selectedPaths.size > 1 - ? Array.from(selectedPaths) - .filter((p) => { - const a = assets?.find((item) => item.path === p); - return a && a.type === 'file'; - }) - .map((p) => { - const a = assets?.find((item) => item.path === p); - return { type: 'file', path: p, name: a?.name, extension: a?.extension }; - }) - : [{ type: 'file', path: asset.path, name: asset.name, extension: asset.extension }]; - - // Set drag data as JSON array for multi-file support - e.dataTransfer.setData('application/json', JSON.stringify(selectedFiles)); - e.dataTransfer.setData('asset-path', asset.path); - e.dataTransfer.setData('asset-name', asset.name); - e.dataTransfer.setData('asset-extension', asset.extension || ''); - e.dataTransfer.setData('text/plain', asset.path); - - // 设置拖拽时的视觉效果 - const dragImage = e.currentTarget.cloneNode(true) as HTMLElement; - dragImage.style.position = 'absolute'; - dragImage.style.top = '-9999px'; - dragImage.style.opacity = '0.8'; - if (selectedFiles.length > 1) { - dragImage.textContent = `${selectedFiles.length} files`; - } - document.body.appendChild(dragImage); - e.dataTransfer.setDragImage(dragImage, 0, 0); - setTimeout(() => document.body.removeChild(dragImage), 0); - } - }} - style={{ - cursor: asset.type === 'file' ? 'grab' : 'pointer' - }} - > - {getFileIcon(asset)} -
-
- {asset.name} -
- {showPath && ( -
- {relativePath} -
- )} -
-
- {asset.type === 'folder' ? t.folder : (asset.extension || t.file)} -
-
- ); - })} -
- )} -
- } - /> - ) : ( -
- -
- )} -
- {contextMenu && ( - setContextMenu(null)} - /> - )} - - {/* 重命名对话框 */} - {renameDialog && ( -
setRenameDialog(null)}> -
e.stopPropagation()}> -
-

{locale === 'zh' ? '重命名' : 'Rename'}

-
-
- setRenameDialog({ ...renameDialog, newName: e.target.value })} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleRename(renameDialog.asset, renameDialog.newName); - } else if (e.key === 'Escape') { - setRenameDialog(null); - } - }} - autoFocus - style={{ - width: '100%', - padding: '8px', - backgroundColor: '#2d2d2d', - border: '1px solid #3e3e3e', - borderRadius: '4px', - color: '#cccccc', - fontSize: '13px' - }} - /> -
-
- - -
-
-
- )} - - {/* 删除确认对话框 */} - {deleteConfirmDialog && ( -
setDeleteConfirmDialog(null)}> -
e.stopPropagation()}> -
-

{locale === 'zh' ? '确认删除' : 'Confirm Delete'}

-
-
-

- {locale === 'zh' - ? `确定要删除 "${deleteConfirmDialog.name}" 吗?此操作不可撤销。` - : `Are you sure you want to delete "${deleteConfirmDialog.name}"? This action cannot be undone.`} -

-
-
- - -
-
-
- )} - + ); } diff --git a/packages/editor-app/src/components/ContentBrowser.tsx b/packages/editor-app/src/components/ContentBrowser.tsx new file mode 100644 index 00000000..512d48b8 --- /dev/null +++ b/packages/editor-app/src/components/ContentBrowser.tsx @@ -0,0 +1,957 @@ +/** + * Content Browser - 内容浏览器 + * 用于浏览和管理项目资产 + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + Plus, + Download, + Save, + ChevronRight, + ChevronDown, + Search, + SlidersHorizontal, + LayoutGrid, + List, + FolderClosed, + FolderOpen, + Folder, + File, + FileCode, + FileJson, + FileImage, + FileText, + Copy, + Trash2, + Edit3, + ExternalLink, + PanelRightClose +} from 'lucide-react'; +import { Core } from '@esengine/ecs-framework'; +import { MessageHub, FileActionRegistry, type FileCreationTemplate } from '@esengine/editor-core'; +import { TauriAPI, DirectoryEntry } from '../api/tauri'; +import { ContextMenu, ContextMenuItem } from './ContextMenu'; +import { PromptDialog } from './PromptDialog'; +import '../styles/ContentBrowser.css'; + +interface AssetItem { + name: string; + path: string; + type: 'file' | 'folder'; + extension?: string; + size?: number; + modified?: number; +} + +interface FolderNode { + name: string; + path: string; + children: FolderNode[]; + isExpanded: boolean; +} + +interface ContentBrowserProps { + projectPath: string | null; + locale?: string; + onOpenScene?: (scenePath: string) => void; + isDrawer?: boolean; + onDockInLayout?: () => void; + revealPath?: string | null; +} + +// 获取资产类型显示名称 +function getAssetTypeName(asset: AssetItem): string { + if (asset.type === 'folder') return 'Folder'; + + // Check for compound extensions first + const name = asset.name.toLowerCase(); + if (name.endsWith('.tilemap.json') || name.endsWith('.tilemap')) return 'Tilemap'; + if (name.endsWith('.tileset.json') || name.endsWith('.tileset')) return 'Tileset'; + + const ext = asset.extension?.toLowerCase(); + switch (ext) { + case 'ecs': return 'Scene'; + case 'btree': return 'Behavior Tree'; + case 'png': + case 'jpg': + case 'jpeg': + case 'webp': return 'Texture'; + case 'ts': + case 'tsx': return 'TypeScript'; + case 'js': + case 'jsx': return 'JavaScript'; + case 'json': return 'JSON'; + case 'prefab': return 'Prefab'; + case 'mat': return 'Material'; + case 'anim': return 'Animation'; + default: return ext?.toUpperCase() || 'File'; + } +} + +export function ContentBrowser({ + projectPath, + locale = 'en', + onOpenScene, + isDrawer = false, + onDockInLayout, + revealPath +}: ContentBrowserProps) { + const messageHub = Core.services.resolve(MessageHub); + const fileActionRegistry = Core.services.resolve(FileActionRegistry); + + // State + const [currentPath, setCurrentPath] = useState(null); + const [selectedPaths, setSelectedPaths] = useState>(new Set()); + const [lastSelectedPath, setLastSelectedPath] = useState(null); + const [assets, setAssets] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [loading, setLoading] = useState(false); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + + // Folder tree state + const [folderTree, setFolderTree] = useState(null); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + + // Sections collapse state + const [favoritesExpanded, setFavoritesExpanded] = useState(true); + const [collectionsExpanded, setCollectionsExpanded] = useState(true); + + // Favorites (stored paths) + const [favorites] = useState([]); + + // Dialog states + const [contextMenu, setContextMenu] = useState<{ + position: { x: number; y: number }; + asset: AssetItem | null; + isBackground?: boolean; + } | null>(null); + const [renameDialog, setRenameDialog] = useState<{ + asset: AssetItem; + newName: string; + } | null>(null); + const [deleteConfirmDialog, setDeleteConfirmDialog] = useState(null); + const [createFileDialog, setCreateFileDialog] = useState<{ + parentPath: string; + template: FileCreationTemplate; + } | null>(null); + + const t = { + en: { + favorites: 'Favorites', + collections: 'Collections', + add: 'Add', + import: 'Import', + saveAll: 'Save All', + search: 'Search', + items: 'items', + dockInLayout: 'Dock in Layout', + noProject: 'No project loaded', + empty: 'This folder is empty', + newFolder: 'New Folder' + }, + zh: { + favorites: '收藏夹', + collections: '收藏集', + add: '添加', + import: '导入', + saveAll: '全部保存', + search: '搜索', + items: '项', + dockInLayout: '停靠到布局', + noProject: '未加载项目', + empty: '文件夹为空', + newFolder: '新建文件夹' + } + }[locale] || { + favorites: 'Favorites', + collections: 'Collections', + add: 'Add', + import: 'Import', + saveAll: 'Save All', + search: 'Search', + items: 'items', + dockInLayout: 'Dock in Layout', + noProject: 'No project loaded', + empty: 'This folder is empty', + newFolder: 'New Folder' + }; + + // Build folder tree - use ref to avoid dependency cycle + const expandedFoldersRef = useRef(expandedFolders); + expandedFoldersRef.current = expandedFolders; + + const buildFolderTree = useCallback(async (rootPath: string): Promise => { + const currentExpanded = expandedFoldersRef.current; + + const buildNode = async (path: string, name: string): Promise => { + const node: FolderNode = { + name, + path, + children: [], + isExpanded: currentExpanded.has(path) + }; + + try { + const entries = await TauriAPI.listDirectory(path); + const folders = entries + .filter((e: DirectoryEntry) => e.is_dir && !e.name.startsWith('.')) + .sort((a: DirectoryEntry, b: DirectoryEntry) => a.name.localeCompare(b.name)); + + for (const folder of folders) { + if (currentExpanded.has(path)) { + node.children.push(await buildNode(folder.path, folder.name)); + } else { + node.children.push({ + name: folder.name, + path: folder.path, + children: [], + isExpanded: false + }); + } + } + } catch (error) { + console.error('Failed to build folder tree:', error); + } + + return node; + }; + + return buildNode(rootPath, 'All'); + }, []); + + // Load assets + const loadAssets = useCallback(async (path: string) => { + setLoading(true); + try { + const entries = await TauriAPI.listDirectory(path); + const assetItems: AssetItem[] = entries.map((entry: DirectoryEntry) => ({ + name: entry.name, + path: entry.path, + type: entry.is_dir ? 'folder' as const : 'file' as const, + extension: entry.is_dir ? undefined : entry.name.split('.').pop(), + size: entry.size, + modified: entry.modified + })); + + setAssets(assetItems.sort((a, b) => { + if (a.type === b.type) return a.name.localeCompare(b.name); + return a.type === 'folder' ? -1 : 1; + })); + } catch (error) { + console.error('Failed to load assets:', error); + setAssets([]); + } finally { + setLoading(false); + } + }, []); + + // Initialize on mount + useEffect(() => { + if (projectPath) { + setCurrentPath(projectPath); + setExpandedFolders(new Set([projectPath])); + loadAssets(projectPath); + buildFolderTree(projectPath).then(setFolderTree); + } + // Only run on mount, not on every projectPath change + }, []); + + // Handle projectPath change after initial mount + const prevProjectPath = useRef(projectPath); + useEffect(() => { + if (projectPath && projectPath !== prevProjectPath.current) { + prevProjectPath.current = projectPath; + setCurrentPath(projectPath); + setExpandedFolders(new Set([projectPath])); + loadAssets(projectPath); + buildFolderTree(projectPath).then(setFolderTree); + } + }, [projectPath, loadAssets, buildFolderTree]); + + // Rebuild tree when expanded folders change + const expandedFoldersVersion = useRef(0); + useEffect(() => { + // Skip first render (handled by initialization) + if (expandedFoldersVersion.current === 0) { + expandedFoldersVersion.current = 1; + return; + } + if (projectPath) { + buildFolderTree(projectPath).then(setFolderTree); + } + }, [expandedFolders, projectPath, buildFolderTree]); + + // Handle reveal path - navigate to folder and select file + const prevRevealPath = useRef(null); + useEffect(() => { + if (revealPath && revealPath !== prevRevealPath.current && projectPath) { + prevRevealPath.current = revealPath; + + // Remove timestamp query if present + const cleanPath = revealPath.split('?')[0] || revealPath; + + // Get full path + const fullPath = cleanPath.startsWith('/') || cleanPath.includes(':') + ? cleanPath + : `${projectPath}/${cleanPath}`; + + // Get parent directory + const pathParts = fullPath.replace(/\\/g, '/').split('/'); + pathParts.pop(); // Remove filename + const parentDir = pathParts.join('/'); + + // Expand all parent folders + const foldersToExpand = new Set(); + let currentFolder = parentDir; + while (currentFolder && currentFolder.length >= (projectPath?.length || 0)) { + foldersToExpand.add(currentFolder); + const parts = currentFolder.split('/'); + parts.pop(); + currentFolder = parts.join('/'); + } + + // Update expanded folders and navigate + setExpandedFolders((prev) => { + const next = new Set(prev); + foldersToExpand.forEach((f) => next.add(f)); + return next; + }); + + // Navigate to parent folder and select the file + setCurrentPath(parentDir); + loadAssets(parentDir).then(() => { + // Select the file after assets are loaded + setSelectedPaths(new Set([fullPath])); + setLastSelectedPath(fullPath); + }); + } + }, [revealPath, projectPath, loadAssets]); + + // Handle folder selection in tree + const handleFolderSelect = useCallback((path: string) => { + setCurrentPath(path); + loadAssets(path); + }, [loadAssets]); + + // Toggle folder expansion + const toggleFolderExpand = useCallback((path: string, e: React.MouseEvent) => { + e.stopPropagation(); + setExpandedFolders(prev => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); + + // Handle asset click + const handleAssetClick = useCallback((asset: AssetItem, e: React.MouseEvent) => { + if (e.shiftKey && lastSelectedPath) { + const lastIndex = assets.findIndex(a => a.path === lastSelectedPath); + const currentIndex = assets.findIndex(a => a.path === asset.path); + if (lastIndex !== -1 && currentIndex !== -1) { + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + const rangePaths = assets.slice(start, end + 1).map(a => a.path); + setSelectedPaths(new Set(rangePaths)); + } + } else if (e.ctrlKey || e.metaKey) { + const newSelected = new Set(selectedPaths); + if (newSelected.has(asset.path)) { + newSelected.delete(asset.path); + } else { + newSelected.add(asset.path); + } + setSelectedPaths(newSelected); + setLastSelectedPath(asset.path); + } else { + setSelectedPaths(new Set([asset.path])); + setLastSelectedPath(asset.path); + } + + messageHub?.publish('asset-file:selected', { + fileInfo: { + name: asset.name, + path: asset.path, + extension: asset.extension, + isDirectory: asset.type === 'folder' + } + }); + }, [assets, lastSelectedPath, selectedPaths, messageHub]); + + // Handle asset double click + const handleAssetDoubleClick = useCallback(async (asset: AssetItem) => { + if (asset.type === 'folder') { + setCurrentPath(asset.path); + loadAssets(asset.path); + setExpandedFolders(prev => new Set([...prev, asset.path])); + } else { + const ext = asset.extension?.toLowerCase(); + if (ext === 'ecs' && onOpenScene) { + onOpenScene(asset.path); + return; + } + + if (fileActionRegistry) { + const handled = await fileActionRegistry.handleDoubleClick(asset.path); + if (handled) return; + } + + try { + await TauriAPI.openFileWithSystemApp(asset.path); + } catch (error) { + console.error('Failed to open file:', error); + } + } + }, [loadAssets, onOpenScene, fileActionRegistry]); + + // Handle context menu + const handleContextMenu = useCallback((e: React.MouseEvent, asset?: AssetItem) => { + e.preventDefault(); + setContextMenu({ + position: { x: e.clientX, y: e.clientY }, + asset: asset || null, + isBackground: !asset + }); + }, []); + + // Handle rename + const handleRename = useCallback(async (asset: AssetItem, newName: string) => { + if (!newName.trim() || newName === asset.name) { + setRenameDialog(null); + return; + } + + try { + const lastSlash = Math.max(asset.path.lastIndexOf('/'), asset.path.lastIndexOf('\\')); + const parentPath = asset.path.substring(0, lastSlash); + const newPath = `${parentPath}/${newName}`; + + await TauriAPI.renameFileOrFolder(asset.path, newPath); + + if (currentPath) { + await loadAssets(currentPath); + } + + setRenameDialog(null); + } catch (error) { + console.error('Failed to rename:', error); + } + }, [currentPath, loadAssets]); + + // Handle delete + const handleDelete = useCallback(async (asset: AssetItem) => { + try { + if (asset.type === 'folder') { + await TauriAPI.deleteFolder(asset.path); + } else { + await TauriAPI.deleteFile(asset.path); + } + + if (currentPath) { + await loadAssets(currentPath); + } + + setDeleteConfirmDialog(null); + } catch (error) { + console.error('Failed to delete:', error); + } + }, [currentPath, loadAssets]); + + // Get breadcrumbs + const getBreadcrumbs = useCallback(() => { + if (!currentPath || !projectPath) return []; + + const relative = currentPath.replace(projectPath, ''); + const parts = relative.split(/[/\\]/).filter(p => p); + + const crumbs = [{ name: 'All', path: projectPath }]; + crumbs.push({ name: 'Content', path: projectPath }); + + let accPath = projectPath; + for (const part of parts) { + accPath = `${accPath}/${part}`; + crumbs.push({ name: part, path: accPath }); + } + + return crumbs; + }, [currentPath, projectPath]); + + // Get file icon + const getFileIcon = useCallback((asset: AssetItem, size: number = 48) => { + if (asset.type === 'folder') { + return ; + } + + const ext = asset.extension?.toLowerCase(); + switch (ext) { + case 'ecs': + return ; + case 'btree': + return ; + case 'ts': + case 'tsx': + case 'js': + case 'jsx': + return ; + case 'json': + return ; + case 'png': + case 'jpg': + case 'jpeg': + case 'gif': + case 'webp': + return ; + default: + return ; + } + }, []); + + // Get context menu items + const getContextMenuItems = useCallback((asset: AssetItem | null): ContextMenuItem[] => { + const items: ContextMenuItem[] = []; + + if (!asset) { + // Background context menu + items.push({ + label: t.newFolder, + icon: , + onClick: async () => { + if (!currentPath) return; + const folderName = `New Folder`; + const folderPath = `${currentPath}/${folderName}`; + try { + await TauriAPI.createDirectory(folderPath); + await loadAssets(currentPath); + } catch (error) { + console.error('Failed to create folder:', error); + } + } + }); + + if (fileActionRegistry) { + const templates = fileActionRegistry.getCreationTemplates(); + if (templates.length > 0) { + items.push({ label: '', separator: true, onClick: () => {} }); + for (const template of templates) { + items.push({ + label: `New ${template.label}`, + onClick: () => { + setContextMenu(null); + if (currentPath) { + setCreateFileDialog({ + parentPath: currentPath, + template + }); + } + } + }); + } + } + } + + return items; + } + + // Asset context menu + if (asset.type === 'file') { + items.push({ + label: locale === 'zh' ? '打开' : 'Open', + icon: , + onClick: () => handleAssetDoubleClick(asset) + }); + items.push({ label: '', separator: true, onClick: () => {} }); + } + + items.push({ + label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer', + icon: , + onClick: async () => { + try { + await TauriAPI.showInFolder(asset.path); + } catch (error) { + console.error('Failed to show in folder:', error); + } + } + }); + + items.push({ + label: locale === 'zh' ? '复制路径' : 'Copy Path', + icon: , + onClick: () => navigator.clipboard.writeText(asset.path) + }); + + items.push({ label: '', separator: true, onClick: () => {} }); + + items.push({ + label: locale === 'zh' ? '重命名' : 'Rename', + icon: , + onClick: () => { + setRenameDialog({ asset, newName: asset.name }); + setContextMenu(null); + } + }); + + items.push({ + label: locale === 'zh' ? '删除' : 'Delete', + icon: , + onClick: () => { + setDeleteConfirmDialog(asset); + setContextMenu(null); + } + }); + + return items; + }, [currentPath, fileActionRegistry, handleAssetDoubleClick, loadAssets, locale, t.newFolder]); + + // Render folder tree node + const renderFolderNode = useCallback((node: FolderNode, depth: number = 0) => { + const isSelected = currentPath === node.path; + const isExpanded = expandedFolders.has(node.path); + const hasChildren = node.children.length > 0; + + return ( +
+
handleFolderSelect(node.path)} + > + toggleFolderExpand(node.path, e)} + > + {hasChildren ? ( + isExpanded ? : + ) : ( + + )} + + + {isExpanded ? : } + + {node.name} +
+ {isExpanded && node.children.map(child => renderFolderNode(child, depth + 1))} +
+ ); + }, [currentPath, expandedFolders, handleFolderSelect, toggleFolderExpand]); + + // Filter assets by search + const filteredAssets = searchQuery.trim() + ? assets.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase())) + : assets; + + const breadcrumbs = getBreadcrumbs(); + + if (!projectPath) { + return ( +
+
+

{t.noProject}

+
+
+ ); + } + + return ( +
+ {/* Left Panel - Folder Tree */} +
+ {/* Favorites Section */} +
+
setFavoritesExpanded(!favoritesExpanded)} + > + {favoritesExpanded ? : } + {t.favorites} + +
+ {favoritesExpanded && ( +
+ {favorites.length === 0 ? ( +
+ {/* Empty favorites */} +
+ ) : ( + favorites.map(fav => ( +
+ + {fav.split('/').pop()} +
+ )) + )} +
+ )} +
+ + {/* Folder Tree */} +
+ {folderTree && renderFolderNode(folderTree)} +
+ + {/* Collections Section */} +
+
setCollectionsExpanded(!collectionsExpanded)} + > + {collectionsExpanded ? : } + {t.collections} +
+ + +
+
+ {collectionsExpanded && ( +
+ {/* Collections list */} +
+ )} +
+
+ + {/* Right Panel - Content Area */} +
+ {/* Top Toolbar */} +
+
+ + + +
+ + {/* Breadcrumb Navigation */} +
+ {breadcrumbs.map((crumb, index) => ( + + {index > 0 && } + handleFolderSelect(crumb.path)} + > + {crumb.name} + + + ))} +
+ +
+ {isDrawer && onDockInLayout && ( + + )} +
+
+ + {/* Search Bar */} +
+ +
+ + setSearchQuery(e.target.value)} + /> +
+
+ + +
+
+ + {/* Asset Grid */} +
handleContextMenu(e)} + > + {loading ? ( +
Loading...
+ ) : filteredAssets.length === 0 ? ( +
{t.empty}
+ ) : ( + filteredAssets.map(asset => ( +
handleAssetClick(asset, e)} + onDoubleClick={() => handleAssetDoubleClick(asset)} + onContextMenu={(e) => handleContextMenu(e, asset)} + draggable={asset.type === 'file'} + onDragStart={(e) => { + if (asset.type === 'file') { + e.dataTransfer.setData('asset-path', asset.path); + e.dataTransfer.setData('text/plain', asset.path); + } + }} + > +
+ {getFileIcon(asset)} +
+
+
+ {asset.name} +
+
+ {getAssetTypeName(asset)} +
+
+
+ )) + )} +
+ + {/* Status Bar */} +
+ {filteredAssets.length} {t.items} +
+
+ + {/* Context Menu */} + {contextMenu && ( + setContextMenu(null)} + /> + )} + + {/* Rename Dialog */} + {renameDialog && ( +
setRenameDialog(null)}> +
e.stopPropagation()}> +
+

{locale === 'zh' ? '重命名' : 'Rename'}

+
+
+ setRenameDialog({ ...renameDialog, newName: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') handleRename(renameDialog.asset, renameDialog.newName); + if (e.key === 'Escape') setRenameDialog(null); + }} + autoFocus + /> +
+
+ + +
+
+
+ )} + + {/* Delete Confirm Dialog */} + {deleteConfirmDialog && ( +
setDeleteConfirmDialog(null)}> +
e.stopPropagation()}> +
+

{locale === 'zh' ? '确认删除' : 'Confirm Delete'}

+
+
+

+ {locale === 'zh' + ? `确定要删除 "${deleteConfirmDialog.name}" 吗?` + : `Delete "${deleteConfirmDialog.name}"?`} +

+
+
+ + +
+
+
+ )} + + {/* Create File Dialog */} + {createFileDialog && ( + { + const { parentPath, template } = createFileDialog; + setCreateFileDialog(null); + + let fileName = value; + if (!fileName.endsWith(`.${template.extension}`)) { + fileName = `${fileName}.${template.extension}`; + } + const filePath = `${parentPath}/${fileName}`; + + try { + const content = await template.getContent(fileName); + await TauriAPI.writeFileContent(filePath, content); + if (currentPath) { + await loadAssets(currentPath); + } + } catch (error) { + console.error('Failed to create file:', error); + } + }} + onCancel={() => setCreateFileDialog(null)} + /> + )} +
+ ); +} diff --git a/packages/editor-app/src/components/FileTree.tsx b/packages/editor-app/src/components/FileTree.tsx index 2a034622..03395fee 100644 --- a/packages/editor-app/src/components/FileTree.tsx +++ b/packages/editor-app/src/components/FileTree.tsx @@ -83,8 +83,8 @@ export const FileTree = forwardRef(({ rootPath, o type: 'create-file' | 'create-folder' | 'create-template'; parentPath: string; templateExtension?: string; - templateContent?: (fileName: string) => Promise; - } | null>(null); + templateGetContent?: (fileName: string) => string | Promise; + } | null>(null); const [filteredTree, setFilteredTree] = useState([]); const fileActionRegistry = Core.services.resolve(FileActionRegistry); @@ -515,14 +515,14 @@ export const FileTree = forwardRef(({ rootPath, o type: 'create-template', parentPath, templateExtension: template.extension, - templateContent: template.createContent + templateGetContent: template.getContent }); }; const handlePromptConfirm = async (value: string) => { if (!promptDialog) return; - const { type, parentPath, templateExtension, templateContent } = promptDialog; + const { type, parentPath, templateExtension, templateGetContent } = promptDialog; setPromptDialog(null); let fileName = value; @@ -533,13 +533,13 @@ export const FileTree = forwardRef(({ rootPath, o await TauriAPI.createFile(targetPath); } else if (type === 'create-folder') { await TauriAPI.createDirectory(targetPath); - } else if (type === 'create-template' && templateExtension && templateContent) { + } else if (type === 'create-template' && templateExtension && templateGetContent) { if (!fileName.endsWith(`.${templateExtension}`)) { fileName = `${fileName}.${templateExtension}`; targetPath = `${parentPath}/${fileName}`; } - - const content = await templateContent(fileName); + // 获取内容并通过后端 API 写入文件 + const content = await templateGetContent(fileName); await TauriAPI.writeFileContent(targetPath, content); } await refreshTree(); diff --git a/packages/editor-app/src/components/FlexLayoutDockContainer.tsx b/packages/editor-app/src/components/FlexLayoutDockContainer.tsx index d282e000..83ef2b78 100644 --- a/packages/editor-app/src/components/FlexLayoutDockContainer.tsx +++ b/packages/editor-app/src/components/FlexLayoutDockContainer.tsx @@ -4,7 +4,7 @@ */ import { useCallback, useRef, useEffect, useState, useMemo } from 'react'; -import { Layout, Model, TabNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react'; +import { Layout, Model, TabNode, TabSetNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react'; import 'flexlayout-react/style/light.css'; import '../styles/FlexLayoutDock.css'; import { LayoutMerger, LayoutBuilder, FlexDockPanel } from '../shared/layout'; @@ -91,9 +91,10 @@ interface FlexLayoutDockContainerProps { panels: FlexDockPanel[]; onPanelClose?: (panelId: string) => void; activePanelId?: string; + messageHub?: { subscribe: (event: string, callback: (data: any) => void) => () => void } | null; } -export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }: FlexLayoutDockContainerProps) { +export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, messageHub }: FlexLayoutDockContainerProps) { const layoutRef = useRef(null); const previousLayoutJsonRef = useRef(null); const previousPanelIdsRef = useRef(''); @@ -104,6 +105,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }: const [visiblePersistentPanels, setVisiblePersistentPanels] = useState>( () => new Set(PERSISTENT_PANEL_IDS) ); + const [isAnyTabsetMaximized, setIsAnyTabsetMaximized] = useState(false); const persistentPanels = useMemo( () => panels.filter((p) => PERSISTENT_PANEL_IDS.includes(p.id)), @@ -337,8 +339,34 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }: // 保存布局状态以便在panels变化时恢复 const layoutJson = newModel.toJson(); previousLayoutJsonRef.current = JSON.stringify(layoutJson); + + // Check if any tabset is maximized + let hasMaximized = false; + newModel.visitNodes((node) => { + if (node.getType() === 'tabset') { + const tabset = node as TabSetNode; + if (tabset.isMaximized()) { + hasMaximized = true; + } + } + }); + setIsAnyTabsetMaximized(hasMaximized); }, []); + useEffect(() => { + if (!messageHub || !model) return; + + const unsubscribe = messageHub.subscribe('panel:select', (data: { panelId: string }) => { + const { panelId } = data; + const node = model.getNodeById(panelId); + if (node && node.getType() === 'tab') { + model.doAction(Actions.selectTab(panelId)); + } + }); + + return () => unsubscribe?.(); + }, [messageHub, model]); + return (
))}
@@ -370,14 +399,20 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }: function PersistentPanelContainer({ panel, rect, - isVisible + isVisible, + isMaximized }: { panel: FlexDockPanel; rect?: DOMRect; isVisible: boolean; + isMaximized: boolean; }) { const hasValidRect = rect && rect.width > 0 && rect.height > 0; + // Hide persistent panel completely when another tabset is maximized + // (unless this panel itself is in the maximized tabset) + const shouldHide = isMaximized && !isVisible; + return (
diff --git a/packages/editor-app/src/components/MainToolbar.tsx b/packages/editor-app/src/components/MainToolbar.tsx new file mode 100644 index 00000000..d9692373 --- /dev/null +++ b/packages/editor-app/src/components/MainToolbar.tsx @@ -0,0 +1,323 @@ +import { useState, useEffect, useRef } from 'react'; +import { + Play, + Pause, + Square, + SkipForward, + Save, + FolderOpen, + Undo2, + Redo2, + Eye, + Globe, + QrCode, + ChevronDown +} from 'lucide-react'; +import type { MessageHub, CommandManager } from '@esengine/editor-core'; +import '../styles/MainToolbar.css'; + +export type PlayState = 'stopped' | 'playing' | 'paused'; + +interface MainToolbarProps { + locale?: string; + messageHub?: MessageHub; + commandManager?: CommandManager; + onSaveScene?: () => void; + onOpenScene?: () => void; + onUndo?: () => void; + onRedo?: () => void; + onPlay?: () => void; + onPause?: () => void; + onStop?: () => void; + onStep?: () => void; + onRunInBrowser?: () => void; + onRunOnDevice?: () => void; +} + +interface ToolButtonProps { + icon: React.ReactNode; + label: string; + active?: boolean; + disabled?: boolean; + onClick?: () => void; +} + +function ToolButton({ icon, label, active, disabled, onClick }: ToolButtonProps) { + return ( + + ); +} + +function ToolSeparator() { + return
; +} + +export function MainToolbar({ + locale = 'en', + messageHub, + commandManager, + onSaveScene, + onOpenScene, + onUndo, + onRedo, + onPlay, + onPause, + onStop, + onStep, + onRunInBrowser, + onRunOnDevice +}: MainToolbarProps) { + const [playState, setPlayState] = useState('stopped'); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const [showRunMenu, setShowRunMenu] = useState(false); + const runMenuRef = useRef(null); + + const t = (key: string) => { + const translations: Record> = { + en: { + play: 'Play', + pause: 'Pause', + stop: 'Stop', + step: 'Step Forward', + save: 'Save Scene (Ctrl+S)', + open: 'Open Scene', + undo: 'Undo (Ctrl+Z)', + redo: 'Redo (Ctrl+Y)', + preview: 'Preview Mode', + runOptions: 'Run Options', + runInBrowser: 'Run in Browser', + runOnDevice: 'Run on Device' + }, + zh: { + play: '播放', + pause: '暂停', + stop: '停止', + step: '单步执行', + save: '保存场景 (Ctrl+S)', + open: '打开场景', + undo: '撤销 (Ctrl+Z)', + redo: '重做 (Ctrl+Y)', + preview: '预览模式', + runOptions: '运行选项', + runInBrowser: '浏览器运行', + runOnDevice: '真机运行' + } + }; + return translations[locale]?.[key] || key; + }; + + // Close run menu when clicking outside + useEffect(() => { + if (!showRunMenu) return; + + const handleClickOutside = (e: MouseEvent) => { + if (runMenuRef.current && !runMenuRef.current.contains(e.target as Node)) { + setShowRunMenu(false); + } + }; + + const timer = setTimeout(() => { + document.addEventListener('click', handleClickOutside); + }, 10); + + return () => { + clearTimeout(timer); + document.removeEventListener('click', handleClickOutside); + }; + }, [showRunMenu]); + + useEffect(() => { + if (commandManager) { + const updateUndoRedo = () => { + setCanUndo(commandManager.canUndo()); + setCanRedo(commandManager.canRedo()); + }; + updateUndoRedo(); + + if (messageHub) { + const unsubscribe = messageHub.subscribe('command:executed', updateUndoRedo); + return () => unsubscribe(); + } + } + }, [commandManager, messageHub]); + + useEffect(() => { + if (messageHub) { + const unsubscribePlay = messageHub.subscribe('preview:started', () => { + setPlayState('playing'); + }); + const unsubscribePause = messageHub.subscribe('preview:paused', () => { + setPlayState('paused'); + }); + const unsubscribeStop = messageHub.subscribe('preview:stopped', () => { + setPlayState('stopped'); + }); + + return () => { + unsubscribePlay(); + unsubscribePause(); + unsubscribeStop(); + }; + } + }, [messageHub]); + + const handlePlay = () => { + if (playState === 'stopped' || playState === 'paused') { + onPlay?.(); + messageHub?.publish('preview:start', {}); + } + }; + + const handlePause = () => { + if (playState === 'playing') { + onPause?.(); + messageHub?.publish('preview:pause', {}); + } + }; + + const handleStop = () => { + if (playState !== 'stopped') { + onStop?.(); + messageHub?.publish('preview:stop', {}); + } + }; + + const handleStep = () => { + onStep?.(); + messageHub?.publish('preview:step', {}); + }; + + const handleUndo = () => { + if (commandManager?.canUndo()) { + commandManager.undo(); + onUndo?.(); + } + }; + + const handleRedo = () => { + if (commandManager?.canRedo()) { + commandManager.redo(); + onRedo?.(); + } + }; + + const handleRunInBrowser = () => { + setShowRunMenu(false); + onRunInBrowser?.(); + messageHub?.publish('viewport:run-in-browser', {}); + }; + + const handleRunOnDevice = () => { + setShowRunMenu(false); + onRunOnDevice?.(); + messageHub?.publish('viewport:run-on-device', {}); + }; + + return ( +
+ {/* File Operations */} +
+ } + label={t('save')} + onClick={onSaveScene} + /> + } + label={t('open')} + onClick={onOpenScene} + /> +
+ + + + {/* Undo/Redo */} +
+ } + label={t('undo')} + disabled={!canUndo} + onClick={handleUndo} + /> + } + label={t('redo')} + disabled={!canRedo} + onClick={handleRedo} + /> +
+ + {/* Play Controls - Absolutely Centered */} +
+
+ : } + label={playState === 'playing' ? t('pause') : t('play')} + onClick={playState === 'playing' ? handlePause : handlePlay} + /> + } + label={t('stop')} + disabled={playState === 'stopped'} + onClick={handleStop} + /> + } + label={t('step')} + disabled={playState === 'playing'} + onClick={handleStep} + /> + + + + {/* Run Options Dropdown */} +
+ + {showRunMenu && ( +
+ + +
+ )} +
+
+
+ + {/* Preview Mode Indicator - Right aligned */} +
+ {playState !== 'stopped' && ( +
+ + {t('preview')} +
+ )} +
+
+ ); +} diff --git a/packages/editor-app/src/components/OutputLogPanel.tsx b/packages/editor-app/src/components/OutputLogPanel.tsx new file mode 100644 index 00000000..71bfc910 --- /dev/null +++ b/packages/editor-app/src/components/OutputLogPanel.tsx @@ -0,0 +1,474 @@ +import { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react'; +import { LogService, LogEntry } from '@esengine/editor-core'; +import { LogLevel } from '@esengine/ecs-framework'; +import { + Search, Filter, Settings, X, Trash2, ChevronDown, + Bug, Info, AlertTriangle, XCircle, AlertCircle, Wifi, Pause, Play +} from 'lucide-react'; +import { JsonViewer } from './JsonViewer'; +import '../styles/OutputLogPanel.css'; + +interface OutputLogPanelProps { + logService: LogService; + locale?: string; + onClose?: () => void; +} + +const MAX_LOGS = 1000; + +function tryParseJSON(message: string): { isJSON: boolean; parsed?: unknown } { + try { + const parsed: unknown = JSON.parse(message); + return { isJSON: true, parsed }; + } catch { + return { isJSON: false }; + } +} + +function formatTime(date: Date): string { + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + const seconds = date.getSeconds().toString().padStart(2, '0'); + const ms = date.getMilliseconds().toString().padStart(3, '0'); + return `${hours}:${minutes}:${seconds}.${ms}`; +} + +function getLevelIcon(level: LogLevel) { + switch (level) { + case LogLevel.Debug: + return ; + case LogLevel.Info: + return ; + case LogLevel.Warn: + return ; + case LogLevel.Error: + case LogLevel.Fatal: + return ; + default: + return ; + } +} + +function getLevelClass(level: LogLevel): string { + switch (level) { + case LogLevel.Debug: + return 'log-entry-debug'; + case LogLevel.Info: + return 'log-entry-info'; + case LogLevel.Warn: + return 'log-entry-warn'; + case LogLevel.Error: + case LogLevel.Fatal: + return 'log-entry-error'; + default: + return ''; + } +} + +const LogEntryItem = memo(({ log, onOpenJsonViewer }: { + log: LogEntry; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onOpenJsonViewer: (data: any) => void; +}) => { + const { isJSON, parsed } = useMemo(() => tryParseJSON(log.message), [log.message]); + const shouldTruncate = log.message.length > 200; + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+
+ {getLevelIcon(log.level)} +
+
+ {formatTime(log.timestamp)} +
+
+ [{log.source === 'remote' ? '🌐 Remote' : log.source}] +
+ {log.clientId && ( +
+ {log.clientId} +
+ )} +
+
+
+ {shouldTruncate && !isExpanded ? ( + <> + + {log.message.substring(0, 200)}... + + + + ) : ( + <> + {log.message} + {shouldTruncate && ( + + )} + + )} +
+ {isJSON && parsed !== undefined && ( + + )} +
+
+
+ ); +}); + +LogEntryItem.displayName = 'LogEntryItem'; + +export function OutputLogPanel({ logService, locale = 'en', onClose }: OutputLogPanelProps) { + const [logs, setLogs] = useState(() => logService.getLogs().slice(-MAX_LOGS)); + const [searchQuery, setSearchQuery] = useState(''); + const [levelFilter, setLevelFilter] = useState>(new Set([ + LogLevel.Debug, + LogLevel.Info, + LogLevel.Warn, + LogLevel.Error, + LogLevel.Fatal + ])); + const [showRemoteOnly, setShowRemoteOnly] = useState(false); + const [autoScroll, setAutoScroll] = useState(true); + const [showFilterMenu, setShowFilterMenu] = useState(false); + const [showSettingsMenu, setShowSettingsMenu] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [jsonViewerData, setJsonViewerData] = useState(null); + const [showTimestamp, setShowTimestamp] = useState(true); + const [showSource, setShowSource] = useState(true); + const logContainerRef = useRef(null); + const filterMenuRef = useRef(null); + const settingsMenuRef = useRef(null); + + useEffect(() => { + const unsubscribe = logService.subscribe((entry) => { + setLogs((prev) => { + const newLogs = [...prev, entry]; + return newLogs.length > MAX_LOGS ? newLogs.slice(-MAX_LOGS) : newLogs; + }); + }); + return unsubscribe; + }, [logService]); + + useEffect(() => { + if (autoScroll && logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [logs, autoScroll]); + + // Close menus on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (filterMenuRef.current && !filterMenuRef.current.contains(e.target as Node)) { + setShowFilterMenu(false); + } + if (settingsMenuRef.current && !settingsMenuRef.current.contains(e.target as Node)) { + setShowSettingsMenu(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleScroll = useCallback(() => { + if (logContainerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current; + const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; + setAutoScroll(isAtBottom); + } + }, []); + + const handleClear = useCallback(() => { + logService.clear(); + setLogs([]); + }, [logService]); + + const toggleLevelFilter = useCallback((level: LogLevel) => { + setLevelFilter((prev) => { + const newFilter = new Set(prev); + if (newFilter.has(level)) { + newFilter.delete(level); + } else { + newFilter.add(level); + } + return newFilter; + }); + }, []); + + const filteredLogs = useMemo(() => { + return logs.filter((log) => { + if (!levelFilter.has(log.level)) return false; + if (showRemoteOnly && log.source !== 'remote') return false; + if (searchQuery) { + const query = searchQuery.toLowerCase(); + if (!log.message.toLowerCase().includes(query) && + !log.source.toLowerCase().includes(query)) { + return false; + } + } + return true; + }); + }, [logs, levelFilter, showRemoteOnly, searchQuery]); + + const levelCounts = useMemo(() => ({ + [LogLevel.Debug]: logs.filter((l) => l.level === LogLevel.Debug).length, + [LogLevel.Info]: logs.filter((l) => l.level === LogLevel.Info).length, + [LogLevel.Warn]: logs.filter((l) => l.level === LogLevel.Warn).length, + [LogLevel.Error]: logs.filter((l) => l.level === LogLevel.Error || l.level === LogLevel.Fatal).length + }), [logs]); + + const remoteLogCount = useMemo(() => + logs.filter((l) => l.source === 'remote').length + , [logs]); + + const activeFilterCount = useMemo(() => { + let count = 0; + if (!levelFilter.has(LogLevel.Debug)) count++; + if (!levelFilter.has(LogLevel.Info)) count++; + if (!levelFilter.has(LogLevel.Warn)) count++; + if (!levelFilter.has(LogLevel.Error)) count++; + if (showRemoteOnly) count++; + return count; + }, [levelFilter, showRemoteOnly]); + + return ( +
+ {/* Toolbar */} +
+
+
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
+
+ +
+ {/* Filter Dropdown */} +
+ + {showFilterMenu && ( +
+
+ {locale === 'zh' ? '日志级别' : 'Log Levels'} +
+ + + + +
+ +
+ )} +
+ + {/* Auto Scroll Toggle */} + + + {/* Settings Dropdown */} +
+ + {showSettingsMenu && ( +
+
+ {locale === 'zh' ? '显示选项' : 'Display Options'} +
+ + +
+ +
+ )} +
+ + {/* Close Button */} + {onClose && ( + + )} +
+
+ + {/* Log Content */} +
+ {filteredLogs.length === 0 ? ( +
+ +

{searchQuery + ? (locale === 'zh' ? '没有匹配的日志' : 'No matching logs') + : (locale === 'zh' ? '暂无日志' : 'No logs to display') + }

+
+ ) : ( + filteredLogs.map((log, index) => ( + + )) + )} +
+ + {/* Status Bar */} +
+ {filteredLogs.length} / {logs.length} {locale === 'zh' ? '条日志' : 'logs'} + {!autoScroll && ( + + )} +
+ + {/* JSON Viewer Modal */} + {jsonViewerData && ( + setJsonViewerData(null)} + /> + )} +
+ ); +} diff --git a/packages/editor-app/src/components/PluginListSetting.tsx b/packages/editor-app/src/components/PluginListSetting.tsx index c7b6bce2..f4a2ec2c 100644 --- a/packages/editor-app/src/components/PluginListSetting.tsx +++ b/packages/editor-app/src/components/PluginListSetting.tsx @@ -29,10 +29,11 @@ const categoryLabels: Record = { audio: { zh: '音频', en: 'Audio' }, networking: { zh: '网络', en: 'Networking' }, tools: { zh: '工具', en: 'Tools' }, + scripting: { zh: '脚本', en: 'Scripting' }, content: { zh: '内容', en: 'Content' } }; -const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'physics', 'audio', 'networking', 'tools']; +const categoryOrder: PluginCategory[] = ['core', 'rendering', 'ui', 'ai', 'scripting', 'physics', 'audio', 'networking', 'tools', 'content']; export function PluginListSetting({ pluginManager }: PluginListSettingProps) { const [plugins, setPlugins] = useState([]); diff --git a/packages/editor-app/src/components/PropertyInspector.tsx b/packages/editor-app/src/components/PropertyInspector.tsx index 159b1a17..1b28d2b7 100644 --- a/packages/editor-app/src/components/PropertyInspector.tsx +++ b/packages/editor-app/src/components/PropertyInspector.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useRef } from 'react'; import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework'; -import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub } from '@esengine/editor-core'; +import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, FileActionRegistry } from '@esengine/editor-core'; import { ChevronRight, ChevronDown, Lock } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor'; import { AssetField } from './inspectors/fields/AssetField'; +import { CollisionLayerField } from './inspectors/fields/CollisionLayerField'; import '../styles/PropertyInspector.css'; const animationClipsEditor = new AnimationClipsFieldEditor(); @@ -140,9 +141,9 @@ export function PropertyInspector({ component, entity, version, onChange, onActi ); case 'color': { - // Convert numeric color (0xRRGGBB) to hex string (#RRGGBB) let colorValue = value ?? '#ffffff'; - if (typeof colorValue === 'number') { + const wasNumber = typeof colorValue === 'number'; + if (wasNumber) { colorValue = '#' + colorValue.toString(16).padStart(6, '0'); } return ( @@ -152,9 +153,12 @@ export function PropertyInspector({ component, entity, version, onChange, onActi value={colorValue} readOnly={metadata.readOnly} onChange={(newValue) => { - // Convert hex string back to number for storage - const numericValue = parseInt(newValue.slice(1), 16); - handleChange(propertyName, numericValue); + if (wasNumber) { + const numericValue = parseInt(newValue.slice(1), 16); + handleChange(propertyName, numericValue); + } else { + handleChange(propertyName, newValue); + } }} /> ); @@ -206,25 +210,30 @@ export function PropertyInspector({ component, entity, version, onChange, onActi } }; + // 从 FileActionRegistry 获取资产创建消息映射 + const fileActionRegistry = Core.services.tryResolve(FileActionRegistry); + const getCreationMapping = () => { + if (!fileActionRegistry || !assetMeta.extensions) return null; + for (const ext of assetMeta.extensions) { + const mapping = fileActionRegistry.getAssetCreationMapping(ext); + if (mapping) return mapping; + } + return null; + }; + + const creationMapping = getCreationMapping(); + const handleCreate = () => { const messageHub = Core.services.tryResolve(MessageHub); - if (messageHub) { - if (fileExtension === '.tilemap.json') { - messageHub.publish('tilemap:create-asset', { - entityId: entity?.id, - onChange: (newValue: string) => handleChange(propertyName, newValue) - }); - } else if (fileExtension === '.btree') { - messageHub.publish('behavior-tree:create-asset', { - entityId: entity?.id, - onChange: (newValue: string) => handleChange(propertyName, newValue) - }); - } + if (messageHub && creationMapping) { + messageHub.publish(creationMapping.createMessage, { + entityId: entity?.id, + onChange: (newValue: string) => handleChange(propertyName, newValue) + }); } }; - const creatableExtensions = ['.tilemap.json', '.btree']; - const canCreate = assetMeta.extensions?.some(ext => creatableExtensions.includes(ext)); + const canCreate = creationMapping !== null; return (
@@ -267,6 +276,30 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
); + case 'collisionLayer': + return ( + handleChange(propertyName, newValue)} + /> + ); + + case 'collisionMask': + return ( + handleChange(propertyName, newValue)} + /> + ); + default: return null; } diff --git a/packages/editor-app/src/components/SceneHierarchy.tsx b/packages/editor-app/src/components/SceneHierarchy.tsx index fa55db61..10bc5c28 100644 --- a/packages/editor-app/src/components/SceneHierarchy.tsx +++ b/packages/editor-app/src/components/SceneHierarchy.tsx @@ -3,32 +3,28 @@ import { Entity, Core } from '@esengine/ecs-framework'; import { EntityStoreService, MessageHub, SceneManagerService, CommandManager, EntityCreationRegistry, EntityCreationTemplate } from '@esengine/editor-core'; import { useLocale } from '../hooks/useLocale'; import * as LucideIcons from 'lucide-react'; -import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, ChevronRight } from 'lucide-react'; +import { + Box, Wifi, Search, Plus, Trash2, Monitor, Globe, ChevronRight, ChevronDown, + Eye, Star, Lock, Settings, Filter, Folder, Sun, Cloud, Mountain, Flag, + SquareStack +} from 'lucide-react'; import { ProfilerService, RemoteEntity } from '../services/ProfilerService'; import { confirm } from '@tauri-apps/plugin-dialog'; import { CreateEntityCommand, DeleteEntityCommand } from '../application/commands/entity'; import '../styles/SceneHierarchy.css'; -/** - * 根据图标名称获取 Lucide 图标组件 - */ -function getIconComponent(iconName: string | undefined, size: number = 12): React.ReactNode { - if (!iconName) return ; +function getIconComponent(iconName: string | undefined, size: number = 14): React.ReactNode { + if (!iconName) return ; - // 获取图标组件 const icons = LucideIcons as unknown as Record>; const IconComponent = icons[iconName]; if (IconComponent) { return ; } - // 回退到 Plus 图标 - return ; + return ; } -/** - * 类别图标映射 - */ const categoryIconMap: Record = { 'rendering': 'Image', 'ui': 'LayoutGrid', @@ -38,13 +34,35 @@ const categoryIconMap: Record = { 'other': 'MoreHorizontal', }; +// 实体类型到图标的映射 +const entityTypeIcons: Record = { + 'World': , + 'Folder': , + 'DirectionalLight': , + 'SkyLight': , + 'SkyAtmosphere': , + 'VolumetricCloud': , + 'StaticMeshActor': , + 'PlayerStart': , + 'ExponentialHeightFog': , +}; + type ViewMode = 'local' | 'remote'; +type SortColumn = 'name' | 'type'; +type SortDirection = 'asc' | 'desc'; interface SceneHierarchyProps { - entityStore: EntityStoreService; - messageHub: MessageHub; - commandManager: CommandManager; - isProfilerMode?: boolean; + entityStore: EntityStoreService; + messageHub: MessageHub; + commandManager: CommandManager; + isProfilerMode?: boolean; +} + +interface EntityNode { + entity: Entity; + children: EntityNode[]; + isExpanded: boolean; + depth: number; } export function SceneHierarchy({ entityStore, messageHub, commandManager, isProfilerMode = false }: SceneHierarchyProps) { @@ -52,7 +70,7 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf const [remoteEntities, setRemoteEntities] = useState([]); const [isRemoteConnected, setIsRemoteConnected] = useState(false); const [viewMode, setViewMode] = useState(isProfilerMode ? 'remote' : 'local'); - const [selectedId, setSelectedId] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(''); const [sceneName, setSceneName] = useState('Untitled'); const [remoteSceneName, setRemoteSceneName] = useState(null); @@ -62,9 +80,14 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf const [draggedEntityId, setDraggedEntityId] = useState(null); const [dropTargetIndex, setDropTargetIndex] = useState(null); const [pluginTemplates, setPluginTemplates] = useState([]); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [sortColumn, setSortColumn] = useState('name'); + const [sortDirection, setSortDirection] = useState('asc'); + const [showFilterMenu, setShowFilterMenu] = useState(false); const { t, locale } = useLocale(); const isShowingRemote = viewMode === 'remote' && isRemoteConnected; + const selectedId = selectedIds.size > 0 ? Array.from(selectedIds)[0] : null; // Get entity creation templates from plugins useEffect(() => { @@ -77,7 +100,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf updateTemplates(); - // Update when plugins are installed const unsubInstalled = messageHub.subscribe('plugin:installed', updateTemplates); const unsubUninstalled = messageHub.subscribe('plugin:uninstalled', updateTemplates); @@ -134,7 +156,11 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf }; const handleSelection = (data: { entity: Entity | null }) => { - setSelectedId(data.entity?.id ?? null); + if (data.entity) { + setSelectedIds(new Set([data.entity.id])); + } else { + setSelectedIds(new Set()); + } }; updateEntities(); @@ -174,25 +200,22 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf setIsRemoteConnected(connected); if (connected && data.entities && data.entities.length > 0) { - // 只在实体列表发生实质性变化时才更新 setRemoteEntities((prev) => { if (prev.length !== data.entities!.length) { return data.entities!; } - // 检查实体ID和名称是否变化 const hasChanged = data.entities!.some((entity, index) => { const prevEntity = prev[index]; return !prevEntity || - prevEntity.id !== entity.id || - prevEntity.name !== entity.name || - prevEntity.componentCount !== entity.componentCount; + prevEntity.id !== entity.id || + prevEntity.name !== entity.name || + prevEntity.componentCount !== entity.componentCount; }); return hasChanged ? data.entities! : prev; }); - // 请求第一个实体的详情以获取场景名称 if (!remoteSceneName && data.entities.length > 0 && data.entities[0]) { profilerService.requestEntityDetails(data.entities[0].id); } @@ -218,8 +241,21 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf return () => window.removeEventListener('profiler:entity-details', handleEntityDetails); }, []); - const handleEntityClick = (entity: Entity) => { - entityStore.selectEntity(entity); + const handleEntityClick = (entity: Entity, e: React.MouseEvent) => { + if (e.ctrlKey || e.metaKey) { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(entity.id)) { + next.delete(entity.id); + } else { + next.add(entity.id); + } + return next; + }); + } else { + setSelectedIds(new Set([entity.id])); + entityStore.selectEntity(entity); + } }; const handleDragStart = (e: React.DragEvent, entityId: number) => { @@ -253,15 +289,13 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf }; const handleRemoteEntityClick = (entity: RemoteEntity) => { - setSelectedId(entity.id); + setSelectedIds(new Set([entity.id])); - // 请求完整的实体详情(包含组件属性) const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined; if (profilerService) { profilerService.requestEntityDetails(entity.id); } - // 先发布基本信息,详细信息稍后通过 ProfilerService 异步返回 messageHub.publish('remote-entity:selected', { entity: { id: entity.id, @@ -273,12 +307,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf }); }; - const handleSceneNameClick = () => { - if (sceneFilePath) { - messageHub.publish('asset:reveal', { path: sceneFilePath }); - } - }; - const handleCreateEntity = () => { const entityCount = entityStore.getAllEntities().length; const entityName = `Entity ${entityCount + 1}`; @@ -326,7 +354,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf setContextMenu(null); }; - // Close context menu on click outside useEffect(() => { const handleClick = () => closeContextMenu(); if (contextMenu) { @@ -335,7 +362,6 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf } }, [contextMenu]); - // Listen for Delete key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Delete' && selectedId && !isShowingRemote) { @@ -347,6 +373,42 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedId, isShowingRemote]); + const toggleFolderExpand = (entityId: number) => { + setExpandedFolders(prev => { + const next = new Set(prev); + if (next.has(entityId)) { + next.delete(entityId); + } else { + next.add(entityId); + } + return next; + }); + }; + + const handleSortClick = (column: SortColumn) => { + if (sortColumn === column) { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortColumn(column); + setSortDirection('asc'); + } + }; + + // Get entity type for display + const getEntityType = (entity: Entity): string => { + const components = entity.components || []; + if (components.length > 0) { + const firstComponent = components[0]; + return firstComponent?.constructor?.name || 'Entity'; + } + return 'Entity'; + }; + + // Get icon for entity type + const getEntityIcon = (entityType: string): React.ReactNode => { + return entityTypeIcons[entityType] || ; + }; + // Filter entities based on search query const filterRemoteEntities = (entityList: RemoteEntity[]): RemoteEntity[] => { if (!searchQuery.trim()) return entityList; @@ -356,12 +418,10 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf const name = entity.name; const id = entity.id.toString(); - // Search by name or ID if (name.toLowerCase().includes(query) || id.includes(query)) { return true; } - // Search by component types if (Array.isArray(entity.componentTypes)) { return entity.componentTypes.some((type) => type.toLowerCase().includes(query) @@ -377,41 +437,64 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf const query = searchQuery.toLowerCase(); return entityList.filter((entity) => { + const name = entity.name || ''; const id = entity.id.toString(); - return id.includes(query); + return name.toLowerCase().includes(query) || id.includes(query); }); }; - // Determine which entities to display const displayEntities = isShowingRemote ? filterRemoteEntities(remoteEntities) : filterLocalEntities(entities); const showRemoteIndicator = isShowingRemote && remoteEntities.length > 0; const displaySceneName = isShowingRemote && remoteSceneName ? remoteSceneName : sceneName; + const totalCount = displayEntities.length; + const selectedCount = selectedIds.size; + return ( -
-
- -

{t('hierarchy.title')}

-
- - {displaySceneName} - - {!isRemoteConnected && isSceneModified && ( - - )} +
+ {/* Toolbar */} +
+
+
+ +
+ + setSearchQuery(e.target.value)} + /> + +
+ +
+ {!isShowingRemote && ( + + )} + +
+ {isRemoteConnected && !isProfilerMode && (
)} + {showRemoteIndicator && (
)}
-
- - setSearchQuery(e.target.value)} - /> -
- {!isShowingRemote && ( -
- - + + {/* Column Headers */} +
+
+ + +
- )} -
!isShowingRemote && handleContextMenu(e, null)}> +
handleSortClick('name')} + > + Item Label + {sortColumn === 'name' && ( + {sortDirection === 'asc' ? '▲' : '▼'} + )} +
+
handleSortClick('type')} + > + Type + {sortColumn === 'type' && ( + {sortDirection === 'asc' ? '▲' : '▼'} + )} +
+
+ + {/* Entity List */} +
!isShowingRemote && handleContextMenu(e, null)}> {displayEntities.length === 0 ? (
- -
{t('hierarchy.empty')}
+
{isShowingRemote - ? 'No entities in remote game' - : 'Create an entity to get started'} + ? (locale === 'zh' ? '远程游戏中没有实体' : 'No entities in remote game') + : (locale === 'zh' ? '创建实体开始使用' : 'Create an entity to get started')}
) : isShowingRemote ? ( -
    +
    {(displayEntities as RemoteEntity[]).map((entity) => ( -
  • handleRemoteEntityClick(entity)} > - - {entity.name} - {entity.tag !== 0 && ( - - #{entity.tag} - - )} - {entity.componentCount > 0 && ( - {entity.componentCount} - )} -
  • +
    + +
    +
    + + {getEntityIcon(entity.componentTypes?.[0] || 'Entity')} + {entity.name} +
    +
    + {entity.componentTypes?.[0] || 'Entity'} +
    +
    ))} -
+
) : ( -
    - {entities.map((entity, index) => ( -
  • handleEntityClick(entity)} - onDragStart={(e) => handleDragStart(e, entity.id)} - onDragOver={(e) => handleDragOver(e, index)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, index)} - onDragEnd={handleDragEnd} - onContextMenu={(e) => { - e.stopPropagation(); - handleEntityClick(entity); - handleContextMenu(e, entity.id); - }} - > - - {entity.name || `Entity ${entity.id}`} -
  • - ))} -
+
+ {/* World/Scene Root */} +
toggleFolderExpand(-1)} + > +
+ +
+
+ { e.stopPropagation(); toggleFolderExpand(-1); }} + > + {expandedFolders.has(-1) ? : } + + + {displaySceneName} (Editor) +
+
World
+
+ + {/* Entity Items */} + {expandedFolders.has(-1) && entities.map((entity, index) => { + const entityType = getEntityType(entity); + return ( +
handleEntityClick(entity, e)} + onDragStart={(e) => handleDragStart(e, entity.id)} + onDragOver={(e) => handleDragOver(e, index)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, index)} + onDragEnd={handleDragEnd} + onContextMenu={(e) => { + e.stopPropagation(); + handleEntityClick(entity, e); + handleContextMenu(e, entity.id); + }} + > +
+ +
+
+ + {getEntityIcon(entityType)} + {entity.name || `Entity ${entity.id}`} +
+
{entityType}
+
+ ); + })} +
+ )} +
+ + {/* Status Bar */} +
+ {totalCount} {locale === 'zh' ? '个对象' : 'actors'} + {selectedCount > 0 && ( + ({selectedCount} {locale === 'zh' ? '个已选中' : 'selected'}) )}
@@ -578,7 +703,6 @@ function ContextMenuWithSubmenu({ return labels ? (locale === 'zh' ? labels.zh : labels.en) : category; }; - // 将模板按类别分组(所有模板现在都来自插件) const templatesByCategory = pluginTemplates.reduce((acc, template) => { const cat = template.category || 'other'; if (!acc[cat]) acc[cat] = []; @@ -586,7 +710,6 @@ function ContextMenuWithSubmenu({ return acc; }, {} as Record); - // 按顺序排序每个类别内的模板 Object.values(templatesByCategory).forEach(templates => { templates.sort((a, b) => (a.order ?? 100) - (b.order ?? 100)); }); @@ -597,7 +720,6 @@ function ContextMenuWithSubmenu({ setActiveSubmenu(category); }; - // 定义类别显示顺序 const categoryOrder = ['rendering', 'ui', 'physics', 'audio', 'basic', 'other']; const sortedCategories = Object.entries(templatesByCategory).sort(([a], [b]) => { const orderA = categoryOrder.indexOf(a); @@ -618,7 +740,6 @@ function ContextMenuWithSubmenu({ {sortedCategories.length > 0 &&
} - {/* 按类别渲染所有模板 */} {sortedCategories.map(([category, templates]) => (
void; - settingsRegistry: SettingsRegistry; - initialCategoryId?: string; + onClose: () => void; + settingsRegistry: SettingsRegistry; + initialCategoryId?: string; +} + +// 主分类结构 +interface MainCategory { + id: string; + title: string; + subCategories: SettingCategory[]; } export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: SettingsWindowProps) { @@ -17,14 +35,88 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: const [selectedCategoryId, setSelectedCategoryId] = useState(initialCategoryId || null); const [values, setValues] = useState>(new Map()); const [errors, setErrors] = useState>(new Map()); + const [searchTerm, setSearchTerm] = useState(''); + const [expandedSections, setExpandedSections] = useState>(new Set()); + const [expandedMainCategories, setExpandedMainCategories] = useState>(new Set(['通用'])); + + // 将分类组织成主分类和子分类 + const mainCategories = useMemo((): MainCategory[] => { + const categoryMap = new Map(); + + // 定义主分类映射 + const mainCategoryMapping: Record = { + 'appearance': '通用', + 'general': '通用', + 'project': '通用', + 'plugins': '通用', + 'editor': '通用', + 'physics': '全局', + 'rendering': '全局', + 'audio': '全局', + 'world': '世界分区', + 'local': '世界分区(本地)', + 'performance': '性能' + }; + + categories.forEach((cat) => { + const mainCatName = mainCategoryMapping[cat.id] || '其他'; + if (!categoryMap.has(mainCatName)) { + categoryMap.set(mainCatName, []); + } + categoryMap.get(mainCatName)!.push(cat); + }); + + // 定义固定的主分类顺序 + const orderedMainCategories = [ + '通用', + '全局', + '世界分区', + '世界分区(本地)', + '性能', + '其他' + ]; + + return orderedMainCategories + .filter((name) => categoryMap.has(name)) + .map((name) => ({ + id: name, + title: name, + subCategories: categoryMap.get(name)! + })); + }, [categories]); + + // 获取显示的子分类标题 + const subCategoryTitle = useMemo(() => { + if (!selectedCategoryId) return ''; + const cat = categories.find((c) => c.id === selectedCategoryId); + return cat?.title || ''; + }, [categories, selectedCategoryId]); + + // 获取主分类标题 + const mainCategoryTitle = useMemo(() => { + for (const main of mainCategories) { + if (main.subCategories.some((sub) => sub.id === selectedCategoryId)) { + return main.title; + } + } + return ''; + }, [mainCategories, selectedCategoryId]); useEffect(() => { const allCategories = settingsRegistry.getAllCategories(); setCategories(allCategories); + // 默认展开所有section + const allSectionIds = new Set(); + allCategories.forEach((cat) => { + cat.sections.forEach((section) => { + allSectionIds.add(`${cat.id}-${section.id}`); + }); + }); + setExpandedSections(allSectionIds); + if (allCategories.length > 0 && !selectedCategoryId) { - // 如果有 initialCategoryId,尝试使用它 - if (initialCategoryId && allCategories.some(c => c.id === initialCategoryId)) { + if (initialCategoryId && allCategories.some((c) => c.id === initialCategoryId)) { setSelectedCategoryId(initialCategoryId); } else { const firstCategory = allCategories[0]; @@ -40,7 +132,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: const initialValues = new Map(); for (const [key, descriptor] of allSettings.entries()) { - // Project-scoped settings are loaded from ProjectService if (key.startsWith('project.') && projectService) { if (key === 'project.uiDesignResolution.width') { const resolution = projectService.getUIDesignResolution(); @@ -52,7 +143,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: const resolution = projectService.getUIDesignResolution(); initialValues.set(key, `${resolution.width}x${resolution.height}`); } else { - // For other project settings, use default initialValues.set(key, descriptor.defaultValue); } } else { @@ -62,7 +152,7 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: } setValues(initialValues); - }, [settingsRegistry, selectedCategoryId]); + }, [settingsRegistry, initialCategoryId]); const handleValueChange = (key: string, value: any, descriptor: SettingDescriptor) => { const newValues = new Map(values); @@ -71,7 +161,7 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: const newErrors = new Map(errors); if (!settingsRegistry.validateSetting(descriptor, value)) { - newErrors.set(key, descriptor.validator?.errorMessage || 'Invalid value'); + newErrors.set(key, descriptor.validator?.errorMessage || '无效值'); } else { newErrors.delete(key); } @@ -87,13 +177,11 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: const projectService = Core.services.tryResolve(ProjectService); const changedSettings: Record = {}; - // Track UI resolution changes for batch saving let uiResolutionChanged = false; let newWidth = 1920; let newHeight = 1080; for (const [key, value] of values.entries()) { - // Project-scoped settings are saved to ProjectService if (key.startsWith('project.') && projectService) { if (key === 'project.uiDesignResolution.width') { newWidth = value; @@ -102,7 +190,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: newHeight = value; uiResolutionChanged = true; } else if (key === 'project.uiDesignResolution.preset') { - // Preset changes width and height together const [w, h] = value.split('x').map(Number); if (w && h) { newWidth = w; @@ -117,7 +204,6 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: } } - // Save UI resolution if changed if (uiResolutionChanged && projectService) { await projectService.setUIDesignResolution({ width: newWidth, height: newHeight }); } @@ -133,6 +219,76 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: onClose(); }; + const handleResetToDefault = () => { + const allSettings = settingsRegistry.getAllSettings(); + const defaultValues = new Map(); + for (const [key, descriptor] of allSettings.entries()) { + defaultValues.set(key, descriptor.defaultValue); + } + setValues(defaultValues); + }; + + const handleExport = () => { + const exportData: Record = {}; + for (const [key, value] of values.entries()) { + exportData[key] = value; + } + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'editor-settings.json'; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleImport = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const importData = JSON.parse(text); + const newValues = new Map(values); + for (const [key, value] of Object.entries(importData)) { + newValues.set(key, value); + } + setValues(newValues); + } catch (err) { + console.error('Failed to import settings:', err); + } + }; + input.click(); + }; + + const toggleSection = (sectionId: string) => { + setExpandedSections((prev) => { + const newSet = new Set(prev); + if (newSet.has(sectionId)) { + newSet.delete(sectionId); + } else { + newSet.add(sectionId); + } + return newSet; + }); + }; + + const toggleMainCategory = (categoryId: string) => { + setExpandedMainCategories((prev) => { + const newSet = new Set(prev); + if (newSet.has(categoryId)) { + newSet.delete(categoryId); + } else { + newSet.add(categoryId); + } + return newSet; + }); + }; + const renderSettingInput = (setting: SettingDescriptor) => { const value = values.get(setting.key) ?? setting.defaultValue; const error = errors.get(setting.key); @@ -140,105 +296,109 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }: switch (setting.type) { case 'boolean': return ( -
-