fix(behavior-tree): 修复插件节点执行问题并完善文档
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { NodeTemplate, PropertyDefinition } from '../Serialization/NodeTemplates';
|
||||
import { NodeType } from '../Types/TaskStatus';
|
||||
import { getComponentTypeName } from '@esengine/ecs-framework';
|
||||
|
||||
/**
|
||||
* 行为树节点元数据
|
||||
@@ -80,7 +81,7 @@ export function BehaviorNode(metadata: BehaviorNodeMetadata) {
|
||||
return function <T extends { new (...args: any[]): any }>(constructor: T) {
|
||||
const metadataWithClassName = {
|
||||
...metadata,
|
||||
className: constructor.name
|
||||
className: getComponentTypeName(constructor as any)
|
||||
};
|
||||
NodeClassRegistry.registerNodeClass(constructor, metadataWithClassName);
|
||||
return constructor;
|
||||
@@ -129,14 +130,12 @@ export const NodeProperty = BehaviorProperty;
|
||||
*/
|
||||
export function getRegisteredNodeTemplates(): NodeTemplate[] {
|
||||
return NodeClassRegistry.getAllNodeClasses().map(({ metadata, constructor }) => {
|
||||
// 从类的 __nodeProperties 收集属性定义
|
||||
const propertyDefs = constructor.__nodeProperties || [];
|
||||
|
||||
const defaultConfig: any = {
|
||||
nodeType: metadata.type.toLowerCase()
|
||||
};
|
||||
|
||||
// 从类的默认值中提取配置,并补充 defaultValue
|
||||
const instance = new constructor();
|
||||
const properties: PropertyDefinition[] = propertyDefs.map((prop: PropertyDefinition) => {
|
||||
const defaultValue = instance[prop.name];
|
||||
@@ -149,7 +148,6 @@ export function getRegisteredNodeTemplates(): NodeTemplate[] {
|
||||
};
|
||||
});
|
||||
|
||||
// 添加子类型字段
|
||||
switch (metadata.type) {
|
||||
case NodeType.Composite:
|
||||
defaultConfig.compositeType = metadata.displayName;
|
||||
@@ -173,6 +171,7 @@ export function getRegisteredNodeTemplates(): NodeTemplate[] {
|
||||
description: metadata.description,
|
||||
color: metadata.color,
|
||||
className: metadata.className,
|
||||
componentClass: constructor,
|
||||
requiresChildren: metadata.requiresChildren,
|
||||
defaultConfig,
|
||||
properties
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Entity, IScene, createLogger } from '@esengine/ecs-framework';
|
||||
import { Entity, IScene, createLogger, ComponentRegistry, Component } from '@esengine/ecs-framework';
|
||||
import type { BehaviorTreeAsset, BehaviorTreeNodeData, BlackboardVariableDefinition, PropertyBinding } from './BehaviorTreeAsset';
|
||||
import { BehaviorTreeNode } from '../Components/BehaviorTreeNode';
|
||||
import { BlackboardComponent } from '../Components/BlackboardComponent';
|
||||
@@ -306,6 +306,19 @@ export class BehaviorTreeAssetLoader {
|
||||
} else if (nameLower.includes('execute') || nameLower.includes('自定义')) {
|
||||
const action = entity.addComponent(new ExecuteAction());
|
||||
action.actionCode = data.actionCode ?? 'return TaskStatus.Success;';
|
||||
} else if (data.className) {
|
||||
const ComponentClass = ComponentRegistry.getComponentType(data.className);
|
||||
if (ComponentClass) {
|
||||
try {
|
||||
const component = new (ComponentClass as any)();
|
||||
Object.assign(component, data);
|
||||
entity.addComponent(component as Component);
|
||||
} catch (error) {
|
||||
logger.error(`创建动作组件失败: ${data.className}, error: ${error}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`未找到动作组件类: ${data.className}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`未知的动作类型: ${name}`);
|
||||
}
|
||||
@@ -335,6 +348,19 @@ export class BehaviorTreeAssetLoader {
|
||||
const condition = entity.addComponent(new ExecuteCondition());
|
||||
condition.conditionCode = data.conditionCode ?? '';
|
||||
condition.invertResult = data.invertResult ?? false;
|
||||
} else if (data.className) {
|
||||
const ComponentClass = ComponentRegistry.getComponentType(data.className);
|
||||
if (ComponentClass) {
|
||||
try {
|
||||
const component = new (ComponentClass as any)();
|
||||
Object.assign(component, data);
|
||||
entity.addComponent(component as Component);
|
||||
} catch (error) {
|
||||
logger.error(`创建条件组件失败: ${data.className}, error: ${error}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`未找到条件组件类: ${data.className}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`未知的条件类型: ${name}`);
|
||||
}
|
||||
|
||||
@@ -67,13 +67,11 @@ export class EditorFormatConverter {
|
||||
static toAsset(editorData: EditorFormat, metadata?: Partial<AssetMetadata>): BehaviorTreeAsset {
|
||||
logger.info('开始转换编辑器格式到资产格式');
|
||||
|
||||
// 查找根节点
|
||||
const rootNode = this.findRootNode(editorData.nodes);
|
||||
if (!rootNode) {
|
||||
throw new Error('未找到根节点');
|
||||
}
|
||||
|
||||
// 转换元数据
|
||||
const assetMetadata: AssetMetadata = {
|
||||
name: metadata?.name || editorData.metadata?.name || 'Untitled Behavior Tree',
|
||||
description: metadata?.description || editorData.metadata?.description,
|
||||
@@ -82,13 +80,10 @@ export class EditorFormatConverter {
|
||||
modifiedAt: metadata?.modifiedAt || new Date().toISOString()
|
||||
};
|
||||
|
||||
// 转换节点
|
||||
const nodes = this.convertNodes(editorData.nodes);
|
||||
|
||||
// 转换黑板
|
||||
const blackboard = this.convertBlackboard(editorData.blackboard);
|
||||
|
||||
// 转换属性绑定
|
||||
const propertyBindings = this.convertPropertyBindings(
|
||||
editorData.connections,
|
||||
editorData.nodes,
|
||||
@@ -130,11 +125,13 @@ export class EditorFormatConverter {
|
||||
* 转换单个节点
|
||||
*/
|
||||
private static convertNode(editorNode: EditorNode): BehaviorTreeNodeData {
|
||||
// 复制data,去除编辑器特有的字段
|
||||
const data = { ...editorNode.data };
|
||||
|
||||
// 移除可能存在的UI相关字段
|
||||
delete data.nodeType; // 这个信息已经在nodeType字段中
|
||||
delete data.nodeType;
|
||||
|
||||
if (editorNode.template.className) {
|
||||
data.className = editorNode.template.className;
|
||||
}
|
||||
|
||||
return {
|
||||
id: editorNode.id,
|
||||
@@ -152,7 +149,6 @@ export class EditorFormatConverter {
|
||||
const variables: BlackboardVariableDefinition[] = [];
|
||||
|
||||
for (const [name, value] of Object.entries(blackboard)) {
|
||||
// 推断类型
|
||||
const type = this.inferBlackboardType(value);
|
||||
|
||||
variables.push({
|
||||
@@ -191,7 +187,6 @@ export class EditorFormatConverter {
|
||||
const bindings: PropertyBinding[] = [];
|
||||
const blackboardVarNames = new Set(blackboard.map(v => v.name));
|
||||
|
||||
// 只处理属性类型的连接
|
||||
const propertyConnections = connections.filter(conn => conn.connectionType === 'property');
|
||||
|
||||
for (const conn of propertyConnections) {
|
||||
@@ -205,7 +200,6 @@ export class EditorFormatConverter {
|
||||
|
||||
let variableName: string | undefined;
|
||||
|
||||
// 检查 from 节点是否是黑板变量节点
|
||||
if (fromNode.data.nodeType === 'blackboard-variable') {
|
||||
variableName = fromNode.data.variableName;
|
||||
} else if (conn.fromProperty) {
|
||||
@@ -241,22 +235,18 @@ export class EditorFormatConverter {
|
||||
static fromAsset(asset: BehaviorTreeAsset): EditorFormat {
|
||||
logger.info('开始转换资产格式到编辑器格式');
|
||||
|
||||
// 转换节点
|
||||
const nodes = this.convertNodesFromAsset(asset.nodes);
|
||||
|
||||
// 转换黑板
|
||||
const blackboard: Record<string, any> = {};
|
||||
for (const variable of asset.blackboard) {
|
||||
blackboard[variable.name] = variable.defaultValue;
|
||||
}
|
||||
|
||||
// 转换属性绑定为连接
|
||||
const connections = this.convertPropertyBindingsToConnections(
|
||||
asset.propertyBindings || [],
|
||||
asset.nodes
|
||||
);
|
||||
|
||||
// 添加节点连接(基于children关系)
|
||||
const nodeConnections = this.buildNodeConnections(asset.nodes);
|
||||
connections.push(...nodeConnections);
|
||||
|
||||
@@ -287,19 +277,24 @@ export class EditorFormatConverter {
|
||||
*/
|
||||
private static convertNodesFromAsset(assetNodes: BehaviorTreeNodeData[]): EditorNode[] {
|
||||
return assetNodes.map((node, index) => {
|
||||
// 简单的自动布局:按索引计算位置
|
||||
const position = {
|
||||
x: 100 + (index % 5) * 250,
|
||||
y: 100 + Math.floor(index / 5) * 150
|
||||
};
|
||||
|
||||
const template: any = {
|
||||
displayName: node.name,
|
||||
category: this.inferCategory(node.nodeType),
|
||||
type: node.nodeType
|
||||
};
|
||||
|
||||
if (node.data.className) {
|
||||
template.className = node.data.className;
|
||||
}
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
template: {
|
||||
displayName: node.name,
|
||||
category: this.inferCategory(node.nodeType),
|
||||
type: node.nodeType
|
||||
},
|
||||
template,
|
||||
data: { ...node.data },
|
||||
position,
|
||||
children: node.children
|
||||
@@ -335,10 +330,8 @@ export class EditorFormatConverter {
|
||||
const connections: EditorConnection[] = [];
|
||||
|
||||
for (const binding of bindings) {
|
||||
// 需要找到代表这个黑板变量的节点(如果有的话)
|
||||
// 这里简化处理,在实际使用中可能需要更复杂的逻辑
|
||||
connections.push({
|
||||
from: 'blackboard', // 占位符,实际使用时需要更复杂的处理
|
||||
from: 'blackboard',
|
||||
to: binding.nodeId,
|
||||
toProperty: binding.propertyName,
|
||||
connectionType: 'property'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NodeType } from '../Types/TaskStatus';
|
||||
import { getRegisteredNodeTemplates } from '../Decorators/BehaviorNodeDecorator';
|
||||
|
||||
/**
|
||||
* 节点数据JSON格式(用于编辑器)
|
||||
* 节点数据JSON格式
|
||||
*/
|
||||
export interface NodeDataJSON {
|
||||
nodeType: string;
|
||||
@@ -11,12 +11,49 @@ export interface NodeDataJSON {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置属性类型常量
|
||||
*/
|
||||
export const PropertyType = {
|
||||
/** 字符串 */
|
||||
String: 'string',
|
||||
/** 数值 */
|
||||
Number: 'number',
|
||||
/** 布尔值 */
|
||||
Boolean: 'boolean',
|
||||
/** 选择框 */
|
||||
Select: 'select',
|
||||
/** 黑板变量引用 */
|
||||
Blackboard: 'blackboard',
|
||||
/** 代码编辑器 */
|
||||
Code: 'code',
|
||||
/** 变量引用 */
|
||||
Variable: 'variable',
|
||||
/** 资产引用 */
|
||||
Asset: 'asset'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 属性类型(支持自定义扩展)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 使用内置类型
|
||||
* type: PropertyType.String
|
||||
*
|
||||
* // 使用自定义类型
|
||||
* type: 'color-picker'
|
||||
* type: 'curve-editor'
|
||||
* ```
|
||||
*/
|
||||
export type PropertyType = typeof PropertyType[keyof typeof PropertyType] | string;
|
||||
|
||||
/**
|
||||
* 属性定义(用于编辑器)
|
||||
*/
|
||||
export interface PropertyDefinition {
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'blackboard' | 'code' | 'variable' | 'asset';
|
||||
type: PropertyType;
|
||||
label: string;
|
||||
description?: string;
|
||||
defaultValue?: any;
|
||||
@@ -25,6 +62,62 @@ export interface PropertyDefinition {
|
||||
max?: number;
|
||||
step?: number;
|
||||
required?: boolean;
|
||||
|
||||
/**
|
||||
* 自定义渲染配置
|
||||
*
|
||||
* 用于指定编辑器如何渲染此属性
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* renderConfig: {
|
||||
* component: 'ColorPicker', // 渲染器组件名称
|
||||
* props: { // 传递给组件的属性
|
||||
* showAlpha: true,
|
||||
* presets: ['#FF0000', '#00FF00']
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
renderConfig?: {
|
||||
/** 渲染器组件名称或路径 */
|
||||
component?: string;
|
||||
/** 传递给渲染器的属性配置 */
|
||||
props?: Record<string, any>;
|
||||
/** 渲染器的样式类名 */
|
||||
className?: string;
|
||||
/** 渲染器的内联样式 */
|
||||
style?: Record<string, any>;
|
||||
/** 其他自定义配置 */
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证规则
|
||||
*
|
||||
* 用于在编辑器中验证输入
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* validation: {
|
||||
* pattern: /^\d+$/,
|
||||
* message: '只能输入数字',
|
||||
* validator: (value) => value > 0
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
validation?: {
|
||||
/** 正则表达式验证 */
|
||||
pattern?: RegExp | string;
|
||||
/** 验证失败的提示信息 */
|
||||
message?: string;
|
||||
/** 自定义验证函数 */
|
||||
validator?: string; // 函数字符串,编辑器会解析
|
||||
/** 最小长度(字符串) */
|
||||
minLength?: number;
|
||||
/** 最大长度(字符串) */
|
||||
maxLength?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +131,7 @@ export interface NodeTemplate {
|
||||
description: string;
|
||||
color?: string;
|
||||
className?: string;
|
||||
componentClass?: Function;
|
||||
requiresChildren?: boolean;
|
||||
defaultConfig: Partial<NodeDataJSON>;
|
||||
properties: PropertyDefinition[];
|
||||
|
||||
@@ -67,7 +67,10 @@ export class LeafExecutionSystem extends EntitySystem {
|
||||
} else if (entity.hasComponent(ExecuteAction)) {
|
||||
status = this.executeCustomAction(entity);
|
||||
} else {
|
||||
this.outputLog(entity, `动作节点没有找到任何已知的动作组件`, 'warn');
|
||||
status = this.executeGenericAction(entity);
|
||||
if (status === TaskStatus.Failure) {
|
||||
this.outputLog(entity, `动作节点没有找到任何已知的动作组件`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
node.status = status;
|
||||
@@ -298,6 +301,41 @@ export class LeafExecutionSystem extends EntitySystem {
|
||||
return func(entity, blackboard, Time.deltaTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行通用动作组件
|
||||
* 查找实体上具有 execute 方法的自定义组件并执行
|
||||
*/
|
||||
private executeGenericAction(entity: Entity): TaskStatus {
|
||||
for (const component of entity.components) {
|
||||
if (component instanceof BehaviorTreeNode ||
|
||||
component instanceof ActiveNode ||
|
||||
component instanceof BlackboardComponent ||
|
||||
component instanceof PropertyBindings ||
|
||||
component instanceof LogOutput) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof (component as any).execute === 'function') {
|
||||
try {
|
||||
const blackboard = this.findBlackboard(entity);
|
||||
const status = (component as any).execute(entity, blackboard);
|
||||
|
||||
if (typeof status === 'number' &&
|
||||
(status === TaskStatus.Success ||
|
||||
status === TaskStatus.Failure ||
|
||||
status === TaskStatus.Running)) {
|
||||
return status;
|
||||
}
|
||||
} catch (error) {
|
||||
this.outputLog(entity, `执行动作组件时发生错误: ${error}`, 'error');
|
||||
return TaskStatus.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TaskStatus.Failure;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行条件节点
|
||||
*/
|
||||
|
||||
@@ -13,18 +13,34 @@ export enum TaskStatus {
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点类型
|
||||
* 内置节点类型常量
|
||||
*/
|
||||
export enum NodeType {
|
||||
export const NodeType = {
|
||||
/** 复合节点 - 有多个子节点 */
|
||||
Composite = 'composite',
|
||||
Composite: 'composite',
|
||||
/** 装饰器节点 - 有一个子节点 */
|
||||
Decorator = 'decorator',
|
||||
Decorator: 'decorator',
|
||||
/** 动作节点 - 叶子节点 */
|
||||
Action = 'action',
|
||||
Action: 'action',
|
||||
/** 条件节点 - 叶子节点 */
|
||||
Condition = 'condition'
|
||||
}
|
||||
Condition: 'condition'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 节点类型(支持自定义扩展)
|
||||
*
|
||||
* 使用内置类型或自定义字符串
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 使用内置类型
|
||||
* type: NodeType.Action
|
||||
*
|
||||
* // 使用自定义类型
|
||||
* type: 'custom-behavior'
|
||||
* ```
|
||||
*/
|
||||
export type NodeType = typeof NodeType[keyof typeof NodeType] | string;
|
||||
|
||||
/**
|
||||
* 复合节点类型
|
||||
|
||||
18
packages/editor-app/.swcrc
Normal file
18
packages/editor-app/.swcrc
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"decorators": true
|
||||
},
|
||||
"transform": {
|
||||
"legacyDecorator": true,
|
||||
"decoratorMetadata": true,
|
||||
"useDefineForClassFields": false,
|
||||
"react": {
|
||||
"runtime": "automatic"
|
||||
}
|
||||
},
|
||||
"target": "es2020"
|
||||
}
|
||||
}
|
||||
@@ -32,13 +32,16 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.13.5",
|
||||
"@tauri-apps/cli": "^2.2.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-react-swc": "^4.2.0",
|
||||
"sharp": "^0.34.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.0.7"
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-swc-transform": "^1.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { AboutDialog } from './components/AboutDialog';
|
||||
import { ErrorDialog } from './components/ErrorDialog';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { BehaviorTreeWindow } from './components/BehaviorTreeWindow';
|
||||
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { Viewport } from './components/Viewport';
|
||||
import { MenuBar } from './components/MenuBar';
|
||||
@@ -27,6 +28,7 @@ import { FlexLayoutDockContainer, FlexDockPanel } from './components/FlexLayoutD
|
||||
import { TauriAPI } from './api/tauri';
|
||||
import { TauriFileAPI } from './adapters/TauriFileAPI';
|
||||
import { SettingsService } from './services/SettingsService';
|
||||
import { PluginLoader } from './services/PluginLoader';
|
||||
import { checkForUpdatesOnStartup } from './utils/updater';
|
||||
import { useLocale } from './hooks/useLocale';
|
||||
import { en, zh } from './locales';
|
||||
@@ -45,6 +47,7 @@ Core.services.registerSingleton(GlobalBlackboardService);
|
||||
|
||||
function App() {
|
||||
const initRef = useRef(false);
|
||||
const pluginLoaderRef = useRef<PluginLoader>(new PluginLoader());
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [projectLoaded, setProjectLoaded] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -67,6 +70,7 @@ function App() {
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [showBehaviorTreeEditor, setShowBehaviorTreeEditor] = useState(false);
|
||||
const [behaviorTreeFilePath, setBehaviorTreeFilePath] = useState<string | null>(null);
|
||||
const [showPluginGenerator, setShowPluginGenerator] = useState(false);
|
||||
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
|
||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||
const [isProfilerMode, setIsProfilerMode] = useState(false);
|
||||
@@ -274,6 +278,12 @@ function App() {
|
||||
|
||||
setCurrentProjectPath(projectPath);
|
||||
setProjectLoaded(true);
|
||||
|
||||
if (pluginManager) {
|
||||
setLoadingMessage(locale === 'zh' ? '加载项目插件...' : 'Loading project plugins...');
|
||||
await pluginLoaderRef.current.loadProjectPlugins(projectPath, pluginManager);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to open project:', error);
|
||||
@@ -486,7 +496,10 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseProject = () => {
|
||||
const handleCloseProject = async () => {
|
||||
if (pluginManager) {
|
||||
await pluginLoaderRef.current.unloadProjectPlugins(pluginManager);
|
||||
}
|
||||
setProjectLoaded(false);
|
||||
setCurrentProjectPath(null);
|
||||
setIsProfilerMode(false);
|
||||
@@ -514,6 +527,10 @@ function App() {
|
||||
setShowAbout(true);
|
||||
};
|
||||
|
||||
const handleCreatePlugin = () => {
|
||||
setShowPluginGenerator(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
|
||||
let corePanels: FlexDockPanel[];
|
||||
@@ -675,6 +692,7 @@ function App() {
|
||||
onOpenSettings={() => setShowSettings(true)}
|
||||
onToggleDevtools={handleToggleDevtools}
|
||||
onOpenAbout={handleOpenAbout}
|
||||
onCreatePlugin={handleCreatePlugin}
|
||||
/>
|
||||
<div className="header-right">
|
||||
<button onClick={handleLocaleChange} className="toolbar-btn locale-btn" title={locale === 'en' ? '切换到中文' : 'Switch to English'}>
|
||||
@@ -729,6 +747,14 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPluginGenerator && (
|
||||
<PluginGeneratorWindow
|
||||
onClose={() => setShowPluginGenerator(false)}
|
||||
projectPath={currentProjectPath}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errorDialog && (
|
||||
<ErrorDialog
|
||||
title={errorDialog.title}
|
||||
|
||||
@@ -32,6 +32,7 @@ interface MenuBarProps {
|
||||
onOpenSettings?: () => void;
|
||||
onToggleDevtools?: () => void;
|
||||
onOpenAbout?: () => void;
|
||||
onCreatePlugin?: () => void;
|
||||
}
|
||||
|
||||
export function MenuBar({
|
||||
@@ -51,7 +52,8 @@ export function MenuBar({
|
||||
onOpenPortManager,
|
||||
onOpenSettings,
|
||||
onToggleDevtools,
|
||||
onOpenAbout
|
||||
onOpenAbout,
|
||||
onCreatePlugin
|
||||
}: MenuBarProps) {
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||
@@ -144,6 +146,7 @@ export function MenuBar({
|
||||
viewport: 'Viewport',
|
||||
pluginManager: 'Plugin Manager',
|
||||
tools: 'Tools',
|
||||
createPlugin: 'Create Plugin',
|
||||
portManager: 'Port Manager',
|
||||
settings: 'Settings',
|
||||
help: 'Help',
|
||||
@@ -177,6 +180,7 @@ export function MenuBar({
|
||||
viewport: '视口',
|
||||
pluginManager: '插件管理器',
|
||||
tools: '工具',
|
||||
createPlugin: '创建插件',
|
||||
portManager: '端口管理器',
|
||||
settings: '设置',
|
||||
help: '帮助',
|
||||
@@ -226,6 +230,8 @@ export function MenuBar({
|
||||
{ label: t('devtools'), onClick: onToggleDevtools }
|
||||
],
|
||||
tools: [
|
||||
{ label: t('createPlugin'), onClick: onCreatePlugin },
|
||||
{ separator: true },
|
||||
{ label: t('portManager'), onClick: onOpenPortManager },
|
||||
{ separator: true },
|
||||
{ label: t('settings'), onClick: onOpenSettings }
|
||||
|
||||
213
packages/editor-app/src/components/PluginGeneratorWindow.tsx
Normal file
213
packages/editor-app/src/components/PluginGeneratorWindow.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState } from 'react';
|
||||
import { X, FolderOpen } from 'lucide-react';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
import '../styles/PluginGeneratorWindow.css';
|
||||
|
||||
interface PluginGeneratorWindowProps {
|
||||
onClose: () => void;
|
||||
projectPath: string | null;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function PluginGeneratorWindow({ onClose, projectPath, locale }: PluginGeneratorWindowProps) {
|
||||
const [pluginName, setPluginName] = useState('');
|
||||
const [pluginVersion, setPluginVersion] = useState('1.0.0');
|
||||
const [outputPath, setOutputPath] = useState(projectPath ? `${projectPath}/plugins` : '');
|
||||
const [includeExample, setIncludeExample] = useState(true);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
title: '创建插件',
|
||||
pluginName: '插件名称',
|
||||
pluginNamePlaceholder: '例如: my-game-plugin',
|
||||
pluginVersion: '插件版本',
|
||||
outputPath: '输出路径',
|
||||
selectPath: '选择路径',
|
||||
includeExample: '包含示例节点',
|
||||
generate: '生成插件',
|
||||
cancel: '取消',
|
||||
generating: '正在生成...',
|
||||
success: '插件创建成功!',
|
||||
errorEmpty: '请输入插件名称',
|
||||
errorInvalidName: '插件名称只能包含字母、数字、连字符和下划线',
|
||||
errorNoPath: '请选择输出路径'
|
||||
},
|
||||
en: {
|
||||
title: 'Create Plugin',
|
||||
pluginName: 'Plugin Name',
|
||||
pluginNamePlaceholder: 'e.g: my-game-plugin',
|
||||
pluginVersion: 'Plugin Version',
|
||||
outputPath: 'Output Path',
|
||||
selectPath: 'Select Path',
|
||||
includeExample: 'Include Example Node',
|
||||
generate: 'Generate Plugin',
|
||||
cancel: 'Cancel',
|
||||
generating: 'Generating...',
|
||||
success: 'Plugin created successfully!',
|
||||
errorEmpty: 'Please enter plugin name',
|
||||
errorInvalidName: 'Plugin name can only contain letters, numbers, hyphens and underscores',
|
||||
errorNoPath: 'Please select output path'
|
||||
}
|
||||
};
|
||||
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||
};
|
||||
|
||||
const handleSelectPath = async () => {
|
||||
try {
|
||||
const selected = await TauriAPI.openProjectDialog();
|
||||
if (selected) {
|
||||
setOutputPath(selected);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select path:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const validatePluginName = (name: string): boolean => {
|
||||
if (!name) {
|
||||
setError(t('errorEmpty'));
|
||||
return false;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9-_]+$/.test(name)) {
|
||||
setError(t('errorInvalidName'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!validatePluginName(pluginName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!outputPath) {
|
||||
setError(t('errorNoPath'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/@plugin-generator', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pluginName,
|
||||
pluginVersion,
|
||||
outputPath,
|
||||
includeExample
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate plugin');
|
||||
}
|
||||
|
||||
alert(t('success'));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to generate plugin:', error);
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content plugin-generator-window" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{t('title')}</h2>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label>{t('pluginName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pluginName}
|
||||
onChange={e => setPluginName(e.target.value)}
|
||||
placeholder={t('pluginNamePlaceholder')}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('pluginVersion')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pluginVersion}
|
||||
onChange={e => setPluginVersion(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('outputPath')}</label>
|
||||
<div className="path-input-group">
|
||||
<input
|
||||
type="text"
|
||||
value={outputPath}
|
||||
onChange={e => setOutputPath(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<button
|
||||
className="select-path-btn"
|
||||
onClick={handleSelectPath}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
{t('selectPath')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeExample}
|
||||
onChange={e => setIncludeExample(e.target.checked)}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<span>{t('includeExample')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? t('generating') : t('generate')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
packages/editor-app/src/services/PluginLoader.ts
Normal file
189
packages/editor-app/src/services/PluginLoader.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { EditorPluginManager } from '@esengine/editor-core';
|
||||
import type { IEditorPlugin } from '@esengine/editor-core';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
|
||||
interface PluginPackageJson {
|
||||
name: string;
|
||||
version: string;
|
||||
main?: string;
|
||||
module?: string;
|
||||
exports?: {
|
||||
'.': {
|
||||
import?: string;
|
||||
require?: string;
|
||||
development?: {
|
||||
types?: string;
|
||||
import?: string;
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class PluginLoader {
|
||||
private loadedPluginNames: Set<string> = new Set();
|
||||
|
||||
async loadProjectPlugins(projectPath: string, pluginManager: EditorPluginManager): Promise<void> {
|
||||
const pluginsPath = `${projectPath}/plugins`;
|
||||
|
||||
try {
|
||||
const exists = await TauriAPI.pathExists(pluginsPath);
|
||||
if (!exists) {
|
||||
console.log('[PluginLoader] No plugins directory found');
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await TauriAPI.listDirectory(pluginsPath);
|
||||
const pluginDirs = entries.filter(entry => entry.is_dir && !entry.name.startsWith('.'));
|
||||
console.log('[PluginLoader] Found plugin directories:', pluginDirs.map(d => d.name));
|
||||
|
||||
for (const entry of pluginDirs) {
|
||||
const pluginPath = `${pluginsPath}/${entry.name}`;
|
||||
await this.loadPlugin(pluginPath, entry.name, pluginManager);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PluginLoader] Failed to load project plugins:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPlugin(pluginPath: string, pluginDirName: string, pluginManager: EditorPluginManager): Promise<void> {
|
||||
try {
|
||||
const packageJsonPath = `${pluginPath}/package.json`;
|
||||
const packageJsonExists = await TauriAPI.pathExists(packageJsonPath);
|
||||
|
||||
if (!packageJsonExists) {
|
||||
console.warn(`[PluginLoader] No package.json found in ${pluginPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageJsonContent = await TauriAPI.readFileContent(packageJsonPath);
|
||||
const packageJson: PluginPackageJson = JSON.parse(packageJsonContent);
|
||||
|
||||
if (this.loadedPluginNames.has(packageJson.name)) {
|
||||
console.log(`[PluginLoader] Plugin ${packageJson.name} already loaded`);
|
||||
return;
|
||||
}
|
||||
|
||||
let entryPoint = 'src/index.ts';
|
||||
|
||||
if (packageJson.exports?.['.']?.development?.import) {
|
||||
entryPoint = packageJson.exports['.'].development.import;
|
||||
} else if (packageJson.exports?.['.']?.import) {
|
||||
const importPath = packageJson.exports['.'].import;
|
||||
if (importPath.startsWith('src/')) {
|
||||
entryPoint = importPath;
|
||||
} else {
|
||||
const srcPath = importPath.replace('dist/', 'src/').replace('.js', '.ts');
|
||||
const srcExists = await TauriAPI.pathExists(`${pluginPath}/${srcPath}`);
|
||||
entryPoint = srcExists ? srcPath : importPath;
|
||||
}
|
||||
} else if (packageJson.module) {
|
||||
const srcPath = packageJson.module.replace('dist/', 'src/').replace('.js', '.ts');
|
||||
const srcExists = await TauriAPI.pathExists(`${pluginPath}/${srcPath}`);
|
||||
entryPoint = srcExists ? srcPath : packageJson.module;
|
||||
} else if (packageJson.main) {
|
||||
const srcPath = packageJson.main.replace('dist/', 'src/').replace('.js', '.ts');
|
||||
const srcExists = await TauriAPI.pathExists(`${pluginPath}/${srcPath}`);
|
||||
entryPoint = srcExists ? srcPath : packageJson.main;
|
||||
}
|
||||
|
||||
// 移除开头的 ./
|
||||
entryPoint = entryPoint.replace(/^\.\//, '');
|
||||
|
||||
const moduleUrl = `/@user-project/plugins/${pluginDirName}/${entryPoint}`;
|
||||
|
||||
console.log(`[PluginLoader] Loading plugin from: ${moduleUrl}`);
|
||||
|
||||
const module = await import(/* @vite-ignore */ moduleUrl);
|
||||
console.log(`[PluginLoader] Module loaded successfully`);
|
||||
|
||||
let pluginInstance: IEditorPlugin | null = null;
|
||||
try {
|
||||
pluginInstance = this.findPluginInstance(module);
|
||||
} catch (findError) {
|
||||
console.error(`[PluginLoader] Error finding plugin instance:`, findError);
|
||||
console.error(`[PluginLoader] Module object:`, module);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pluginInstance) {
|
||||
console.error(`[PluginLoader] No plugin instance found in ${packageJson.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await pluginManager.installEditor(pluginInstance);
|
||||
this.loadedPluginNames.add(packageJson.name);
|
||||
|
||||
console.log(`[PluginLoader] Successfully loaded plugin: ${packageJson.name}`);
|
||||
} catch (error) {
|
||||
console.error(`[PluginLoader] Failed to load plugin from ${pluginPath}:`, error);
|
||||
if (error instanceof Error) {
|
||||
console.error(`[PluginLoader] Error stack:`, error.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findPluginInstance(module: any): IEditorPlugin | null {
|
||||
console.log('[PluginLoader] Module exports:', Object.keys(module));
|
||||
|
||||
if (module.default && this.isPluginInstance(module.default)) {
|
||||
console.log('[PluginLoader] Found plugin in default export');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(module)) {
|
||||
const value = module[key];
|
||||
if (value && this.isPluginInstance(value)) {
|
||||
console.log(`[PluginLoader] Found plugin in export: ${key}`);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('[PluginLoader] No valid plugin instance found. Exports:', module);
|
||||
return null;
|
||||
}
|
||||
|
||||
private isPluginInstance(obj: any): obj is IEditorPlugin {
|
||||
try {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasRequiredProperties =
|
||||
typeof obj.name === 'string' &&
|
||||
typeof obj.version === 'string' &&
|
||||
typeof obj.displayName === 'string' &&
|
||||
typeof obj.category === 'string' &&
|
||||
typeof obj.install === 'function' &&
|
||||
typeof obj.uninstall === 'function';
|
||||
|
||||
if (!hasRequiredProperties) {
|
||||
console.log('[PluginLoader] Object is not a valid plugin:', {
|
||||
hasName: typeof obj.name === 'string',
|
||||
hasVersion: typeof obj.version === 'string',
|
||||
hasDisplayName: typeof obj.displayName === 'string',
|
||||
hasCategory: typeof obj.category === 'string',
|
||||
hasInstall: typeof obj.install === 'function',
|
||||
hasUninstall: typeof obj.uninstall === 'function',
|
||||
objectType: typeof obj,
|
||||
objectConstructor: obj?.constructor?.name
|
||||
});
|
||||
}
|
||||
|
||||
return hasRequiredProperties;
|
||||
} catch (error) {
|
||||
console.error('[PluginLoader] Error in isPluginInstance:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async unloadProjectPlugins(pluginManager: EditorPluginManager): Promise<void> {
|
||||
for (const pluginName of this.loadedPluginNames) {
|
||||
try {
|
||||
await pluginManager.uninstallEditor(pluginName);
|
||||
} catch (error) {
|
||||
console.error(`[PluginLoader] Failed to unload plugin ${pluginName}:`, error);
|
||||
}
|
||||
}
|
||||
this.loadedPluginNames.clear();
|
||||
}
|
||||
}
|
||||
208
packages/editor-app/src/styles/PluginGeneratorWindow.css
Normal file
208
packages/editor-app/src/styles/PluginGeneratorWindow.css
Normal file
@@ -0,0 +1,208 @@
|
||||
.plugin-generator-window {
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-generator-window .modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.plugin-generator-window .modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.plugin-generator-window .close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-generator-window .close-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.plugin-generator-window .modal-body {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.plugin-generator-window .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-generator-window .form-group label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.plugin-generator-window .form-group input[type="text"] {
|
||||
padding: 10px 12px;
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family-mono);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-generator-window .form-group input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.plugin-generator-window .form-group input[type="text"]:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.plugin-generator-window .path-input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-generator-window .path-input-group input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.plugin-generator-window .select-path-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-generator-window .select-path-btn:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.plugin-generator-window .select-path-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.plugin-generator-window .checkbox-group {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.plugin-generator-window .checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.plugin-generator-window .checkbox-group input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.plugin-generator-window .checkbox-group input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.plugin-generator-window .checkbox-group span {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.plugin-generator-window .error-message {
|
||||
padding: 12px;
|
||||
background: rgba(206, 145, 120, 0.1);
|
||||
border: 1px solid rgba(206, 145, 120, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #CE9178;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.plugin-generator-window .modal-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--color-border-default);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin-generator-window .btn {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plugin-generator-window .btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.plugin-generator-window .btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.plugin-generator-window .btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.plugin-generator-window .btn-secondary {
|
||||
background: var(--color-bg-overlay);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.plugin-generator-window .btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { World, Entity, Scene, createLogger, Time, Core } from '@esengine/ecs-framework';
|
||||
import { World, Entity, Scene, createLogger, Time, Core, ComponentRegistry, Component } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BehaviorTreeNode as BehaviorTreeNodeComponent,
|
||||
BlackboardComponent,
|
||||
@@ -324,17 +324,19 @@ export class BehaviorTreeExecutor {
|
||||
private addNodeComponents(entity: Entity, node: BehaviorTreeNode): void {
|
||||
const category = node.template.category;
|
||||
const data = node.data;
|
||||
const nodeType = node.template.type;
|
||||
|
||||
if (category === '根节点' || data.nodeType === 'root') {
|
||||
// 根节点使用专门的 RootNode 组件
|
||||
entity.addComponent(new RootNode());
|
||||
} else if (category === '动作') {
|
||||
} else if (nodeType === NodeType.Action) {
|
||||
// 根据节点类型而不是 category 来判断,这样可以支持自定义 category
|
||||
this.addActionComponent(entity, node);
|
||||
} else if (category === '条件') {
|
||||
} else if (nodeType === NodeType.Condition) {
|
||||
this.addConditionComponent(entity, node);
|
||||
} else if (category === '组合') {
|
||||
} else if (nodeType === NodeType.Composite) {
|
||||
this.addCompositeComponent(entity, node);
|
||||
} else if (category === '装饰器') {
|
||||
} else if (nodeType === NodeType.Decorator) {
|
||||
this.addDecoratorComponent(entity, node);
|
||||
}
|
||||
}
|
||||
@@ -369,6 +371,21 @@ export class BehaviorTreeExecutor {
|
||||
const action = new ExecuteAction();
|
||||
action.actionCode = node.data.actionCode ?? 'return TaskStatus.Success;';
|
||||
entity.addComponent(action);
|
||||
} else {
|
||||
const ComponentClass = node.template.componentClass ||
|
||||
(node.template.className ? ComponentRegistry.getComponentType(node.template.className) : null);
|
||||
|
||||
if (ComponentClass) {
|
||||
try {
|
||||
const component = new (ComponentClass as any)();
|
||||
Object.assign(component, node.data);
|
||||
entity.addComponent(component as Component);
|
||||
} catch (error) {
|
||||
logger.error(`创建动作组件失败: ${node.template.className}, error: ${error}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`未找到动作组件类: ${node.template.className}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +417,21 @@ export class BehaviorTreeExecutor {
|
||||
condition.conditionCode = node.data.conditionCode ?? '';
|
||||
condition.invertResult = node.data.invertResult ?? false;
|
||||
entity.addComponent(condition);
|
||||
} else {
|
||||
const ComponentClass = node.template.componentClass ||
|
||||
(node.template.className ? ComponentRegistry.getComponentType(node.template.className) : null);
|
||||
|
||||
if (ComponentClass) {
|
||||
try {
|
||||
const component = new (ComponentClass as any)();
|
||||
Object.assign(component, node.data);
|
||||
entity.addComponent(component as Component);
|
||||
} catch (error) {
|
||||
logger.error(`创建条件组件失败: ${node.template.className}, error: ${error}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`未找到条件组件类: ${node.template.className}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { transformSync } from 'esbuild';
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
@@ -45,89 +44,86 @@ loadEditorPackages();
|
||||
|
||||
const userProjectPlugin = () => ({
|
||||
name: 'user-project-middleware',
|
||||
configureServer(server: any) {
|
||||
server.middlewares.use(async (req: any, res: any, next: any) => {
|
||||
if (req.url?.startsWith('/@user-project/')) {
|
||||
const urlWithoutQuery = req.url.split('?')[0];
|
||||
const relativePath = decodeURIComponent(urlWithoutQuery.substring('/@user-project'.length));
|
||||
resolveId(id: string, importer?: string) {
|
||||
if (id.startsWith('/@user-project/')) {
|
||||
return id;
|
||||
}
|
||||
|
||||
// 处理从 /@user-project/ 模块导入的相对路径
|
||||
if (importer && importer.startsWith('/@user-project/')) {
|
||||
if (id.startsWith('./') || id.startsWith('../')) {
|
||||
const importerDir = path.dirname(importer.substring('/@user-project'.length));
|
||||
let resolvedPath = path.join(importerDir, id);
|
||||
resolvedPath = resolvedPath.replace(/\\/g, '/');
|
||||
|
||||
// 尝试添加扩展名
|
||||
let projectPath: string | null = null;
|
||||
for (const [, path] of userProjectPathMap) {
|
||||
projectPath = path;
|
||||
for (const [, p] of userProjectPathMap) {
|
||||
projectPath = p;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!projectPath) {
|
||||
res.statusCode = 503;
|
||||
res.end('Project path not set. Please open a project first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(projectPath, relativePath);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error('[Vite] File not found:', filePath);
|
||||
res.statusCode = 404;
|
||||
res.end(`File not found: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fs.statSync(filePath).isDirectory()) {
|
||||
res.statusCode = 400;
|
||||
res.end(`Path is a directory: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
editorPackageMapping.forEach((srcPath, packageName) => {
|
||||
const escapedPackageName = packageName.replace(/\//g, '\\/');
|
||||
const regex = new RegExp(`from\\s+['"]${escapedPackageName}['"]`, 'g');
|
||||
content = content.replace(
|
||||
regex,
|
||||
`from "/@fs/${srcPath.replace(/\\/g, '/')}"`
|
||||
);
|
||||
});
|
||||
|
||||
const fileDir = path.dirname(filePath);
|
||||
const relativeImportRegex = /from\s+['"](\.\.?\/[^'"]+)['"]/g;
|
||||
content = content.replace(relativeImportRegex, (match, importPath) => {
|
||||
if (importPath.match(/\.(ts|js|tsx|jsx)$/)) {
|
||||
return match;
|
||||
if (projectPath) {
|
||||
const possibleExtensions = ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.js'];
|
||||
for (const ext of possibleExtensions) {
|
||||
const testPath = path.join(projectPath, resolvedPath + ext);
|
||||
if (fs.existsSync(testPath) && !fs.statSync(testPath).isDirectory()) {
|
||||
return '/@user-project' + (resolvedPath + ext).replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
const possibleExtensions = ['.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.js'];
|
||||
for (const ext of possibleExtensions) {
|
||||
const resolvedPath = path.join(fileDir, importPath + ext);
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
const normalizedImport = (importPath + ext).replace(/\\/g, '/');
|
||||
return match.replace(importPath, normalizedImport);
|
||||
}
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
const result = transformSync(content, {
|
||||
loader: 'ts',
|
||||
format: 'esm',
|
||||
target: 'es2020',
|
||||
sourcemap: 'inline',
|
||||
sourcefile: filePath,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.end(result.code);
|
||||
} catch (err: any) {
|
||||
console.error('[Vite] Failed to transform TypeScript:', err);
|
||||
res.statusCode = 500;
|
||||
res.end(`Failed to compile: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
return '/@user-project' + resolvedPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
load(id: string) {
|
||||
if (id.startsWith('/@user-project/')) {
|
||||
const relativePath = decodeURIComponent(id.substring('/@user-project'.length));
|
||||
|
||||
let projectPath: string | null = null;
|
||||
for (const [, p] of userProjectPathMap) {
|
||||
projectPath = p;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!projectPath) {
|
||||
throw new Error('Project path not set. Please open a project first.');
|
||||
}
|
||||
|
||||
const filePath = path.join(projectPath, relativePath);
|
||||
console.log('[Vite] Loading file:', id);
|
||||
console.log('[Vite] Resolved path:', filePath);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
|
||||
if (fs.statSync(filePath).isDirectory()) {
|
||||
throw new Error(`Path is a directory: ${filePath}`);
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
editorPackageMapping.forEach((srcPath, packageName) => {
|
||||
const escapedPackageName = packageName.replace(/\//g, '\\/');
|
||||
const regex = new RegExp(`from\\s+['"]${escapedPackageName}['"]`, 'g');
|
||||
content = content.replace(
|
||||
regex,
|
||||
`from "/@fs/${srcPath.replace(/\\/g, '/')}"`
|
||||
);
|
||||
});
|
||||
|
||||
// 直接返回源码,让 Vite 的转换管道处理
|
||||
// Vite 已经正确配置了 TypeScript 和装饰器的转换
|
||||
return content;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
configureServer(server: any) {
|
||||
server.middlewares.use(async (req: any, res: any, next: any) => {
|
||||
|
||||
if (req.url === '/@ecs-framework-shim') {
|
||||
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
||||
@@ -155,13 +151,221 @@ const userProjectPlugin = () => ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/@plugin-generator') {
|
||||
let body = '';
|
||||
req.on('data', (chunk: any) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { pluginName, pluginVersion, outputPath, includeExample } = JSON.parse(body);
|
||||
|
||||
const pluginPath = path.join(outputPath, pluginName);
|
||||
|
||||
if (fs.existsSync(pluginPath)) {
|
||||
res.statusCode = 400;
|
||||
res.end(JSON.stringify({ error: 'Plugin directory already exists' }));
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(pluginPath, { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginPath, 'src'), { recursive: true });
|
||||
if (includeExample) {
|
||||
fs.mkdirSync(path.join(pluginPath, 'src', 'nodes'), { recursive: true });
|
||||
}
|
||||
|
||||
const packageJson = {
|
||||
name: pluginName,
|
||||
version: pluginVersion,
|
||||
description: `Behavior tree plugin for ${pluginName}`,
|
||||
main: 'dist/index.js',
|
||||
module: 'dist/index.js',
|
||||
types: 'dist/index.d.ts',
|
||||
exports: {
|
||||
'.': {
|
||||
types: './dist/index.d.ts',
|
||||
import: './dist/index.js',
|
||||
development: {
|
||||
types: './src/index.ts',
|
||||
import: './src/index.ts'
|
||||
}
|
||||
}
|
||||
},
|
||||
scripts: {
|
||||
build: 'tsc',
|
||||
watch: 'tsc --watch'
|
||||
},
|
||||
peerDependencies: {
|
||||
'@esengine/ecs-framework': '^2.2.8',
|
||||
'@esengine/editor-core': '^1.0.0'
|
||||
},
|
||||
dependencies: {
|
||||
'@esengine/behavior-tree': '^1.0.0'
|
||||
},
|
||||
devDependencies: {
|
||||
'typescript': '^5.8.3'
|
||||
}
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(pluginPath, 'package.json'),
|
||||
JSON.stringify(packageJson, null, 2)
|
||||
);
|
||||
|
||||
const tsconfig = {
|
||||
compilerOptions: {
|
||||
target: 'ES2020',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'node',
|
||||
declaration: true,
|
||||
outDir: './dist',
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
experimentalDecorators: true,
|
||||
emitDecoratorMetadata: true
|
||||
},
|
||||
include: ['src/**/*'],
|
||||
exclude: ['node_modules', 'dist']
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(pluginPath, 'tsconfig.json'),
|
||||
JSON.stringify(tsconfig, null, 2)
|
||||
);
|
||||
|
||||
const pluginInstanceName = `${pluginName.replace(/-/g, '')}Plugin`;
|
||||
|
||||
const indexTs = includeExample
|
||||
? `import './nodes/ExampleAction';
|
||||
|
||||
export { ${pluginInstanceName} } from './plugin';
|
||||
export * from './nodes/ExampleAction';
|
||||
|
||||
// 默认导出插件实例
|
||||
import { ${pluginInstanceName} as pluginInstance } from './plugin';
|
||||
export default pluginInstance;
|
||||
`
|
||||
: `export { ${pluginInstanceName} } from './plugin';
|
||||
|
||||
// 默认导出插件实例
|
||||
import { ${pluginInstanceName} as pluginInstance } from './plugin';
|
||||
export default pluginInstance;
|
||||
`;
|
||||
fs.writeFileSync(path.join(pluginPath, 'src', 'index.ts'), indexTs);
|
||||
|
||||
const pluginTs = `import type { IEditorPlugin } from '@esengine/editor-core';
|
||||
import { EditorPluginCategory } from '@esengine/editor-core';
|
||||
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { getRegisteredNodeTemplates } from '@esengine/behavior-tree';
|
||||
import type { NodeTemplate } from '@esengine/behavior-tree';
|
||||
|
||||
export class ${pluginName.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')}Plugin implements IEditorPlugin {
|
||||
readonly name = '${pluginName}';
|
||||
readonly version = '${pluginVersion}';
|
||||
readonly displayName = '${pluginName}';
|
||||
readonly category = EditorPluginCategory.Tool;
|
||||
readonly description = 'Behavior tree plugin for ${pluginName}';
|
||||
|
||||
async install(core: Core, services: ServiceContainer): Promise<void> {
|
||||
console.log('[${pluginName}] Plugin installed');
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
console.log('[${pluginName}] Plugin uninstalled');
|
||||
}
|
||||
|
||||
getNodeTemplates(): NodeTemplate[] {
|
||||
return getRegisteredNodeTemplates();
|
||||
}
|
||||
}
|
||||
|
||||
export const ${pluginName.replace(/-/g, '')}Plugin = new ${pluginName.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')}Plugin();
|
||||
`;
|
||||
fs.writeFileSync(path.join(pluginPath, 'src', 'plugin.ts'), pluginTs);
|
||||
|
||||
if (includeExample) {
|
||||
const exampleActionTs = `import { Component, Entity, ECSComponent, Serialize } from '@esengine/ecs-framework';
|
||||
import { BehaviorNode, BehaviorProperty, NodeType, TaskStatus, BlackboardComponent } from '@esengine/behavior-tree';
|
||||
|
||||
@ECSComponent('ExampleAction')
|
||||
@BehaviorNode({
|
||||
displayName: '示例动作',
|
||||
category: '自定义',
|
||||
type: NodeType.Action,
|
||||
icon: 'Star',
|
||||
description: '这是一个示例动作节点',
|
||||
color: '#FF9800'
|
||||
})
|
||||
export class ExampleAction extends Component {
|
||||
@Serialize()
|
||||
@BehaviorProperty({
|
||||
label: '消息内容',
|
||||
type: 'string',
|
||||
description: '要打印的消息'
|
||||
})
|
||||
message: string = 'Hello from example action!';
|
||||
|
||||
execute(entity: Entity, blackboard?: BlackboardComponent): TaskStatus {
|
||||
console.log(this.message);
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(
|
||||
path.join(pluginPath, 'src', 'nodes', 'ExampleAction.ts'),
|
||||
exampleActionTs
|
||||
);
|
||||
}
|
||||
|
||||
const readme = `# ${pluginName}
|
||||
|
||||
Behavior tree plugin for ${pluginName}
|
||||
|
||||
## Installation
|
||||
|
||||
\`\`\`bash
|
||||
npm install
|
||||
npm run build
|
||||
\`\`\`
|
||||
|
||||
## Usage
|
||||
|
||||
在编辑器中加载此插件:
|
||||
|
||||
\`\`\`typescript
|
||||
import { ${pluginName.replace(/-/g, '')}Plugin } from '${pluginName}';
|
||||
import { EditorPluginManager } from '@esengine/editor-core';
|
||||
|
||||
// 在编辑器启动时注册插件
|
||||
const pluginManager = Core.services.resolve(EditorPluginManager);
|
||||
await pluginManager.installEditor(${pluginName.replace(/-/g, '')}Plugin);
|
||||
\`\`\`
|
||||
`;
|
||||
fs.writeFileSync(path.join(pluginPath, 'README.md'), readme);
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify({ success: true, path: pluginPath }));
|
||||
} catch (err: any) {
|
||||
console.error('[Vite] Failed to generate plugin:', err);
|
||||
res.statusCode = 500;
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), userProjectPlugin()],
|
||||
plugins: [
|
||||
...react({
|
||||
tsDecorators: true,
|
||||
}),
|
||||
userProjectPlugin() as any
|
||||
],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
host: host || false,
|
||||
|
||||
Reference in New Issue
Block a user