fix(behavior-tree): 修复插件节点执行问题并完善文档

This commit is contained in:
YHH
2025-10-28 11:45:35 +08:00
parent fe791e83a8
commit f0b4453a5f
28 changed files with 5475 additions and 127 deletions

View 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"
}
}

View File

@@ -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"
}
}

View File

@@ -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}

View File

@@ -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 }

View 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>
);
}

View 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();
}
}

View 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);
}

View File

@@ -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}`);
}
}
}

View File

@@ -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,