Feature/runtime cdn and plugin loader (#240)

* feat(ui): 完善 UI 布局系统和编辑器可视化工具

* refactor: 移除 ModuleRegistry,统一使用 PluginManager 插件系统

* fix: 修复 CodeQL 警告并提升测试覆盖率

* refactor: 分离运行时入口点,解决 runtime bundle 包含 React 的问题

* fix(ci): 添加 editor-core 和 editor-runtime 到 CI 依赖构建步骤

* docs: 完善 ServiceContainer 文档,新增 Symbol.for 模式和 @InjectProperty 说明

* fix(ci): 修复 type-check 失败问题

* fix(ci): 修复类型检查失败问题

* fix(ci): 修复类型检查失败问题

* fix(ci): behavior-tree 构建添加 @tauri-apps 外部依赖

* fix(ci): behavior-tree 添加 @tauri-apps/plugin-fs 类型依赖

* fix(ci): platform-web 添加缺失的 behavior-tree 依赖

* fix(lint): 移除正则表达式中不必要的转义字符
This commit is contained in:
YHH
2025-11-27 20:42:46 +08:00
committed by GitHub
parent 71869b1a58
commit 107439d70c
367 changed files with 10661 additions and 12473 deletions

View File

@@ -1,667 +0,0 @@
import {
React,
useState,
useEffect,
type ICompiler,
type CompileResult,
type CompilerContext,
type IFileSystem,
Icons,
createLogger,
} from '@esengine/editor-runtime';
import { GlobalBlackboardTypeGenerator } from '../generators/GlobalBlackboardTypeGenerator';
import { EditorFormatConverter, BehaviorTreeAssetSerializer } from '@esengine/behavior-tree';
import { useBehaviorTreeDataStore } from '../application/state/BehaviorTreeDataStore';
const { File, FolderTree, FolderOpen } = Icons;
const logger = createLogger('BehaviorTreeCompiler');
export interface BehaviorTreeCompileOptions {
mode: 'single' | 'workspace';
assetOutputPath: string;
typeOutputPath: string;
selectedFiles: string[];
fileFormats: Map<string, 'json' | 'binary'>;
currentFile?: string;
currentFilePath?: string;
}
export class BehaviorTreeCompiler implements ICompiler<BehaviorTreeCompileOptions> {
readonly id = 'behavior-tree';
readonly name = '行为树编译器';
readonly description = '将行为树文件编译为运行时资产和TypeScript类型定义';
private projectPath: string | null = null;
private currentOptions: BehaviorTreeCompileOptions | null = null;
async compile(options: BehaviorTreeCompileOptions, context: CompilerContext): Promise<CompileResult> {
this.projectPath = context.projectPath;
this.currentOptions = options;
const fileSystem = context.moduleContext.fileSystem;
if (!this.projectPath) {
return {
success: false,
message: '错误:没有打开的项目',
errors: ['请先打开一个项目']
};
}
try {
const outputFiles: string[] = [];
const errors: string[] = [];
if (options.mode === 'workspace') {
for (const fileId of options.selectedFiles) {
const format = options.fileFormats.get(fileId) || 'binary';
const result = await this.compileFile(fileId, options.assetOutputPath, options.typeOutputPath, format, fileSystem);
if (result.success) {
outputFiles.push(...(result.outputFiles || []));
} else {
errors.push(`${fileId}: ${result.message}`);
}
}
const globalTypeResult = await this.generateGlobalBlackboardTypes(options.typeOutputPath, fileSystem);
if (globalTypeResult.success) {
outputFiles.push(...(globalTypeResult.outputFiles || []));
} else {
errors.push(globalTypeResult.message);
}
} else {
const currentFileName = this.getCurrentFileName();
const currentFilePath = this.currentOptions?.currentFilePath;
if (!currentFileName) {
return {
success: false,
message: '错误:没有打开的行为树文件',
errors: ['请先打开一个行为树文件']
};
}
const format = options.fileFormats.get(currentFileName) || 'binary';
const result = await this.compileFileWithPath(
currentFileName,
currentFilePath || `${this.projectPath}/.ecs/behaviors/${currentFileName}.btree`,
options.assetOutputPath,
options.typeOutputPath,
format,
fileSystem
);
if (result.success) {
outputFiles.push(...(result.outputFiles || []));
} else {
errors.push(result.message);
}
}
if (errors.length > 0) {
return {
success: false,
message: `编译完成,但有 ${errors.length} 个错误`,
outputFiles,
errors
};
}
return {
success: true,
message: `成功编译 ${outputFiles.length} 个文件`,
outputFiles
};
} catch (error) {
return {
success: false,
message: `编译失败: ${error}`,
errors: [String(error)]
};
}
}
private async compileFile(
fileId: string,
assetOutputPath: string,
typeOutputPath: string,
format: 'json' | 'binary',
fileSystem: IFileSystem
): Promise<CompileResult> {
const btreePath = `${this.projectPath}/.ecs/behaviors/${fileId}.btree`;
return this.compileFileWithPath(fileId, btreePath, assetOutputPath, typeOutputPath, format, fileSystem);
}
private async compileFileWithPath(
fileId: string,
btreePath: string,
assetOutputPath: string,
typeOutputPath: string,
format: 'json' | 'binary',
fileSystem: IFileSystem
): Promise<CompileResult> {
try {
logger.info(`Reading file: ${btreePath}`);
const fileContent = await fileSystem.readFile(btreePath);
const treeData = JSON.parse(fileContent);
const editorFormat = this.convertToEditorFormat(treeData, fileId);
const asset = EditorFormatConverter.toAsset(editorFormat);
let runtimeAsset: string | Uint8Array;
const extension = format === 'json' ? '.btree.json' : '.btree.bin';
const assetPath = `${assetOutputPath}/${fileId}${extension}`;
if (format === 'json') {
runtimeAsset = BehaviorTreeAssetSerializer.serialize(asset, { format: 'json', pretty: true });
await fileSystem.writeFile(assetPath, runtimeAsset as string);
} else {
runtimeAsset = BehaviorTreeAssetSerializer.serialize(asset, { format: 'binary' });
await fileSystem.writeBinary(assetPath, runtimeAsset as Uint8Array);
}
const blackboardVars = treeData.blackboard || {};
logger.info(`${fileId} blackboard vars:`, blackboardVars);
const typeContent = this.generateBlackboardTypes(fileId, blackboardVars);
const typePath = `${typeOutputPath}/${fileId}.ts`;
await fileSystem.writeFile(typePath, typeContent);
logger.info(`Generated type file: ${typePath}`);
return {
success: true,
message: `成功编译 ${fileId}`,
outputFiles: [assetPath, typePath]
};
} catch (error) {
return {
success: false,
message: `编译 ${fileId} 失败: ${error}`,
errors: [String(error)]
};
}
}
/**
* 将存储的 JSON 数据转换为 EditorFormat
* @param treeData - 从文件读取的原始数据
* @param fileId - 文件标识符
* @returns 编辑器格式数据
*/
private convertToEditorFormat(treeData: any, fileId: string): any {
// 如果已经是新格式(包含 nodes 数组),直接使用
if (treeData.nodes && Array.isArray(treeData.nodes)) {
return {
version: treeData.version || '1.0.0',
metadata: treeData.metadata || {
name: fileId,
description: ''
},
nodes: treeData.nodes,
connections: treeData.connections || [],
blackboard: treeData.blackboard || {}
};
}
// 兼容旧格式,返回默认结构
return {
version: '1.0.0',
metadata: {
name: fileId,
description: ''
},
nodes: [],
connections: [],
blackboard: treeData.blackboard || {}
};
}
private async generateGlobalBlackboardTypes(
typeOutputPath: string,
fileSystem: IFileSystem
): Promise<CompileResult> {
try {
if (!this.projectPath) {
throw new Error('No project path');
}
const btreeFiles = await fileSystem.scanFiles(`${this.projectPath}/.ecs/behaviors`, '*.btree');
const variables: any[] = [];
for (const fileId of btreeFiles) {
const btreePath = `${this.projectPath}/.ecs/behaviors/${fileId}.btree`;
const fileContent = await fileSystem.readFile(btreePath);
const treeData = JSON.parse(fileContent);
const blackboard = treeData.blackboard || {};
for (const [key, value] of Object.entries(blackboard)) {
variables.push({
name: key,
type: this.inferType(value),
defaultValue: value
});
}
}
const config = {
version: '1.0.0',
variables
};
const typeContent = GlobalBlackboardTypeGenerator.generate(config);
const typePath = `${typeOutputPath}/GlobalBlackboard.ts`;
await fileSystem.writeFile(typePath, typeContent);
return {
success: true,
message: '成功生成全局黑板类型',
outputFiles: [typePath]
};
} catch (error) {
return {
success: false,
message: `生成全局黑板类型失败: ${error}`,
errors: [String(error)]
};
}
}
private generateBlackboardTypes(behaviorName: string, blackboardVars: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`export interface ${behaviorName}Blackboard {`);
for (const [key, value] of Object.entries(blackboardVars)) {
const type = this.inferType(value);
lines.push(` ${key}: ${type};`);
}
lines.push('}');
return lines.join('\n');
}
private inferType(value: unknown): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'string') return 'string';
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return 'boolean';
if (Array.isArray(value)) return 'unknown[]';
if (typeof value === 'object') return 'Record<string, unknown>';
return 'unknown';
}
private getCurrentFileName(): string | null {
if (this.currentOptions?.currentFile) {
return this.currentOptions.currentFile;
}
return null;
}
validateOptions(options: BehaviorTreeCompileOptions): string | null {
if (!options.assetOutputPath) {
return '请选择资产输出路径';
}
if (!options.typeOutputPath) {
return '请选择类型定义输出路径';
}
if (options.mode === 'workspace' && options.selectedFiles.length === 0) {
return '请至少选择一个文件';
}
if (options.mode === 'single' && !this.getCurrentFileName()) {
return '没有打开的行为树文件';
}
return null;
}
createConfigUI(onOptionsChange: (options: BehaviorTreeCompileOptions) => void, context: CompilerContext): React.ReactElement {
return <BehaviorTreeCompileConfigUI onOptionsChange={onOptionsChange} context={context} />;
}
}
interface ConfigUIProps {
onOptionsChange: (options: BehaviorTreeCompileOptions) => void;
context: CompilerContext;
}
function BehaviorTreeCompileConfigUI({ onOptionsChange, context }: ConfigUIProps) {
const { projectPath, moduleContext } = context;
const { fileSystem, dialog } = moduleContext;
const [mode, setMode] = useState<'single' | 'workspace'>('workspace');
const [assetOutputPath, setAssetOutputPath] = useState('');
const [typeOutputPath, setTypeOutputPath] = useState('');
const [availableFiles, setAvailableFiles] = useState<string[]>([]);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [fileFormats, setFileFormats] = useState<Map<string, 'json' | 'binary'>>(new Map());
const [selectAll, setSelectAll] = useState(true);
useEffect(() => {
const loadFiles = async () => {
if (projectPath) {
const files = await fileSystem.scanFiles(`${projectPath}/.ecs/behaviors`, '*.btree');
setAvailableFiles(files);
setSelectedFiles(new Set(files));
const formats = new Map<string, 'json' | 'binary'>();
files.forEach((file: string) => formats.set(file, 'binary'));
setFileFormats(formats);
}
};
loadFiles();
const savedAssetPath = localStorage.getItem('export-asset-path');
const savedTypePath = localStorage.getItem('export-type-path');
// Set default paths based on projectPath if no saved paths
if (savedAssetPath) {
setAssetOutputPath(savedAssetPath);
} else if (projectPath) {
const defaultAssetPath = `${projectPath}/assets/behaviors`;
setAssetOutputPath(defaultAssetPath);
}
if (savedTypePath) {
setTypeOutputPath(savedTypePath);
} else if (projectPath) {
const defaultTypePath = `${projectPath}/src/types/behaviors`;
setTypeOutputPath(defaultTypePath);
}
}, [projectPath]);
const currentFilePath = useBehaviorTreeDataStore((state) => state.currentFilePath);
const currentFileName = useBehaviorTreeDataStore((state) => state.currentFileName);
useEffect(() => {
onOptionsChange({
mode,
assetOutputPath,
typeOutputPath,
selectedFiles: mode === 'workspace' ? Array.from(selectedFiles) : [],
fileFormats,
currentFile: currentFileName || undefined,
currentFilePath: currentFilePath || undefined
});
}, [mode, assetOutputPath, typeOutputPath, selectedFiles, fileFormats, onOptionsChange, currentFileName, currentFilePath]);
const handleBrowseAssetPath = async () => {
const selected = await dialog.openDialog({
directory: true,
multiple: false,
title: '选择资产输出目录',
defaultPath: assetOutputPath || projectPath || undefined
});
if (selected && typeof selected === 'string') {
setAssetOutputPath(selected);
localStorage.setItem('export-asset-path', selected);
}
};
const handleBrowseTypePath = async () => {
const selected = await dialog.openDialog({
directory: true,
multiple: false,
title: '选择类型定义输出目录',
defaultPath: typeOutputPath || projectPath || undefined
});
if (selected && typeof selected === 'string') {
setTypeOutputPath(selected);
localStorage.setItem('export-type-path', selected);
}
};
const handleSelectAll = () => {
if (selectAll) {
setSelectedFiles(new Set());
setSelectAll(false);
} else {
setSelectedFiles(new Set(availableFiles));
setSelectAll(true);
}
};
const handleToggleFile = (file: string) => {
const newSelected = new Set(selectedFiles);
if (newSelected.has(file)) {
newSelected.delete(file);
} else {
newSelected.add(file);
}
setSelectedFiles(newSelected);
setSelectAll(newSelected.size === availableFiles.length);
};
const handleFileFormatChange = (file: string, format: 'json' | 'binary') => {
const newFormats = new Map(fileFormats);
newFormats.set(file, format);
setFileFormats(newFormats);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* 模式选择 */}
<div style={{ display: 'flex', gap: '8px', borderBottom: '1px solid #3e3e3e', paddingBottom: '8px' }}>
<button
onClick={() => setMode('workspace')}
style={{
flex: 1,
padding: '8px 16px',
background: mode === 'workspace' ? '#0e639c' : '#3a3a3a',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
fontSize: '13px'
}}
>
<FolderTree size={16} />
</button>
<button
onClick={() => setMode('single')}
style={{
flex: 1,
padding: '8px 16px',
background: mode === 'single' ? '#0e639c' : '#3a3a3a',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
fontSize: '13px'
}}
>
<File size={16} />
</button>
</div>
{/* 模式说明 */}
<div style={{
padding: '8px 12px',
background: '#1e3a5f',
borderRadius: '4px',
fontSize: '11px',
color: '#8ac3ff',
lineHeight: '1.5'
}}>
{mode === 'workspace' ? (
<>
<strong></strong> <code style={{ background: '#0d2744', padding: '2px 4px', borderRadius: '2px' }}>{projectPath}/.ecs/behaviors/</code> .btree
</>
) : (
<>
<strong></strong>
{currentFilePath && (
<div style={{ marginTop: '4px', wordBreak: 'break-all' }}>
<code style={{ background: '#0d2744', padding: '2px 4px', borderRadius: '2px' }}>{currentFilePath}</code>
</div>
)}
{!currentFilePath && (
<div style={{ marginTop: '4px', color: '#ffaa00' }}>
</div>
)}
</>
)}
</div>
{/* 资产输出路径 */}
<div>
<div style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600, color: '#ccc' }}>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={assetOutputPath}
onChange={(e) => setAssetOutputPath(e.target.value)}
placeholder="选择资产输出目录..."
style={{
flex: 1,
padding: '8px 12px',
background: '#2d2d2d',
border: '1px solid #3a3a3a',
borderRadius: '4px',
color: '#ccc',
fontSize: '12px'
}}
/>
<button
onClick={handleBrowseAssetPath}
style={{
padding: '8px 16px',
background: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<FolderOpen size={14} />
</button>
</div>
</div>
{/* TypeScript类型输出路径 */}
<div>
<div style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600, color: '#ccc' }}>
TypeScript
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={typeOutputPath}
onChange={(e) => setTypeOutputPath(e.target.value)}
placeholder="选择类型定义输出目录..."
style={{
flex: 1,
padding: '8px 12px',
background: '#2d2d2d',
border: '1px solid #3a3a3a',
borderRadius: '4px',
color: '#ccc',
fontSize: '12px'
}}
/>
<button
onClick={handleBrowseTypePath}
style={{
padding: '8px 16px',
background: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<FolderOpen size={14} />
</button>
</div>
</div>
{/* 文件列表 */}
{mode === 'workspace' && availableFiles.length > 0 && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<div style={{ fontSize: '13px', fontWeight: 600, color: '#ccc' }}>
({selectedFiles.size}/{availableFiles.length})
</div>
<button
onClick={handleSelectAll}
style={{
padding: '4px 12px',
background: '#3a3a3a',
border: 'none',
borderRadius: '3px',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px'
}}
>
{selectAll ? '取消全选' : '全选'}
</button>
</div>
<div style={{ maxHeight: '200px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '4px' }}>
{availableFiles.map((file) => (
<div
key={file}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px',
background: selectedFiles.has(file) ? '#2a2d2e' : '#1e1e1e',
border: `1px solid ${selectedFiles.has(file) ? '#0e639c' : '#3a3a3a'}`,
borderRadius: '4px',
fontSize: '12px'
}}
>
<input
type="checkbox"
checked={selectedFiles.has(file)}
onChange={() => handleToggleFile(file)}
style={{ cursor: 'pointer' }}
/>
<File size={14} style={{ color: '#ab47bc' }} />
<span style={{ flex: 1, color: '#ccc' }}>{file}.btree</span>
<select
value={fileFormats.get(file) || 'binary'}
onChange={(e) => handleFileFormatChange(file, e.target.value as 'json' | 'binary')}
onClick={(e) => e.stopPropagation()}
style={{
padding: '4px 8px',
background: '#2d2d2d',
border: '1px solid #3a3a3a',
borderRadius: '3px',
color: '#ccc',
fontSize: '11px',
cursor: 'pointer'
}}
>
<option value="binary"></option>
<option value="json">JSON</option>
</select>
</div>
))}
</div>
</div>
)}
</div>
);
}